diff --git a/frontends/api/package.json b/frontends/api/package.json index e7f2764d51..12319de795 100644 --- a/frontends/api/package.json +++ b/frontends/api/package.json @@ -29,7 +29,7 @@ "ol-test-utilities": "0.0.0" }, "dependencies": { - "@mitodl/mitxonline-api-axios": "^2026.3.24", + "@mitodl/mitxonline-api-axios": "^2026.3.25", "@tanstack/react-query": "^5.66.0", "axios": "^1.12.2", "tiny-invariant": "^1.3.3" diff --git a/frontends/api/src/mitxonline/hooks/enrollment/index.ts b/frontends/api/src/mitxonline/hooks/enrollment/index.ts index 22377c473b..97398dfef6 100644 --- a/frontends/api/src/mitxonline/hooks/enrollment/index.ts +++ b/frontends/api/src/mitxonline/hooks/enrollment/index.ts @@ -96,6 +96,9 @@ const useCreateVerifiedProgramEnrollment = () => { queryClient.invalidateQueries({ queryKey: enrollmentKeys.courseRunEnrollmentsList(), }) + queryClient.invalidateQueries({ + queryKey: enrollmentKeys.programEnrollmentsList(), + }) }, }) } diff --git a/frontends/api/src/mitxonline/test-utils/urls.ts b/frontends/api/src/mitxonline/test-utils/urls.ts index 9eee598272..06d1ff8df3 100644 --- a/frontends/api/src/mitxonline/test-utils/urls.ts +++ b/frontends/api/src/mitxonline/test-utils/urls.ts @@ -96,8 +96,8 @@ const baskets = { } const verifiedProgramEnrollments = { - create: (programId: string, courserunId: string) => - `${API_BASE_URL}/api/v2/verified_program_enrollments/${encodeURIComponent(programId)}/${encodeURIComponent(courserunId)}/`, + create: (courserunId: string) => + `${API_BASE_URL}/api/v2/verified_program_enrollments/${encodeURIComponent(courserunId)}/`, } export { diff --git a/frontends/main/package.json b/frontends/main/package.json index 6c910e0f1c..d59b526da7 100644 --- a/frontends/main/package.json +++ b/frontends/main/package.json @@ -14,7 +14,7 @@ "@emotion/styled": "^11.11.0", "@floating-ui/react": "^0.27.16", "@mitodl/course-search-utils": "^3.5.2", - "@mitodl/mitxonline-api-axios": "^2026.3.24", + "@mitodl/mitxonline-api-axios": "^2026.3.25", "@mitodl/smoot-design": "^6.24.0", "@mui/material": "^6.4.5", "@mui/material-nextjs": "^6.4.3", 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 49c0c662f3..d16e3dbeaa 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.test.tsx @@ -1209,10 +1209,7 @@ describe.each([ // Mock the enrollment endpoint const programEnrollmentEndpoint = - mitxonline.urls.verifiedProgramEnrollments.create( - programEnrollment.program.readable_id, - run.courseware_id, - ) + mitxonline.urls.verifiedProgramEnrollments.create(run.courseware_id) setMockResponse.post(programEnrollmentEndpoint, {}) renderWithProviders( diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx index 2fe41742e3..9682d33109 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx @@ -34,7 +34,10 @@ import { import { mitxUserQueries } from "api/mitxonline-hooks/user" import { useQuery } from "@tanstack/react-query" import { coursePageView, programPageView, programView } from "@/common/urls" -import { mitxonlineLegacyUrl } from "@/common/mitxonline" +import { + mitxonlineLegacyUrl, + isVerifiedEnrollmentMode, +} from "@/common/mitxonline" import { useReplaceBasketItem } from "api/mitxonline-hooks/baskets" import { EnrollmentStatus, getBestRun, getEnrollmentStatus } from "./helpers" import { @@ -45,12 +48,6 @@ import { } from "@mitodl/mitxonline-api-axios/v2" import CourseEnrollmentDialog from "@/page-components/EnrollmentDialogs/CourseEnrollmentDialog" -const EnrollmentMode = { - Audit: "audit", - Verified: "verified", -} as const -type EnrollmentMode = (typeof EnrollmentMode)[keyof typeof EnrollmentMode] - export const DashboardType = { Course: "course", CourseRunEnrollment: "courserun-enrollment", @@ -335,7 +332,7 @@ const useEnrollmentHandler = () => { return } createVerifiedProgramEnrollment.mutate( - { courserun_id: readableId, program_id: programCoursewareId }, + { courserun_id: readableId, request_body: [programCoursewareId] }, { onSuccess: () => { window.location.href = href @@ -688,15 +685,16 @@ const DashboardCard: React.FC = ({ const canUpgrade = isCourseRunEnrollment && - resource.data.enrollment_mode !== EnrollmentMode.Verified && + !isVerifiedEnrollmentMode(resource.data.enrollment_mode) && (enrollmentRun?.is_upgradable ?? false) && (enrollmentRun?.upgrade_product_is_active ?? false) // Handle enrollment click for courses const handleEnrollmentClick = React.useCallback(() => { if (isCourse) { - const isVerifiedProgramEnrollment = - programEnrollment?.enrollment_mode === EnrollmentMode.Verified + const isVerifiedProgramEnrollment = isVerifiedEnrollmentMode( + programEnrollment?.enrollment_mode, + ) enrollment.enroll({ course: resource.data, @@ -770,7 +768,7 @@ const DashboardCard: React.FC = ({ ) : null} {isCourseRunEnrollment && - resource.data.enrollment_mode !== EnrollmentMode.Verified && + !isVerifiedEnrollmentMode(resource.data.enrollment_mode) && offerUpgrade ? ( { const courseEnrollment = mitxonline.factories.enrollment.courseEnrollment({ b2b_contract_id: null, - run: { - ...mitxonline.factories.enrollment.courseEnrollment().run, - course: { - ...mitxonline.factories.enrollment.courseEnrollment().run.course, - title: "My Test Course", - }, - }, }) const programEnrollment = - mitxonline.factories.enrollment.programEnrollmentV3({ - program: { - ...mitxonline.factories.programs.simpleProgram(), - title: "My Test Program", - }, - }) + mitxonline.factories.enrollment.programEnrollmentV3() mockedUseFeatureFlagEnabled.mockReturnValue(true) setMockResponse.get(mitxonline.urls.enrollment.enrollmentsListV3(), [ @@ -271,11 +260,11 @@ describe("EnrollmentDisplay", () => { // Course title appears in desktop + mobile cards expect( - (await screen.findAllByText("My Test Course")).length, + (await screen.findAllByText(courseEnrollment.run.course.title)).length, ).toBeGreaterThan(0) // Program title appears in desktop + mobile cards expect( - (await screen.findAllByText("My Test Program")).length, + (await screen.findAllByText(programEnrollment.program.title)).length, ).toBeGreaterThan(0) }) @@ -1318,9 +1307,9 @@ describe("EnrollmentDisplay", () => { const cards = screen.getAllByTestId("enrollment-card-desktop") const card = cards.find((c) => within(c).queryByText("Clickable Course")) - expect(card).toBeDefined() + invariant(card, "Expected to find a card containing 'Clickable Course'") - const startButton = within(card!).getByTestId("courseware-button") + const startButton = within(card).getByTestId("courseware-button") await user.click(startButton) // Should open CourseEnrollmentDialog @@ -1441,84 +1430,200 @@ describe("EnrollmentDisplay", () => { expect(screen.queryByText("Requirements")).not.toBeInTheDocument() }) - test("Clicking 'Start Course' in verified program does one-click enrollment", async () => { + /** + * Sets up a verified program dashboard scenario with: + * - A parent program with a verified enrollment + * - A direct child course (regular requirement) + * - A child program-as-course with its own module course + * + * Mocks all API responses. Does not render — callers handle rendering + * and assertions. + */ + const setupProgramDashboardVerifiedEnrollmentScenario = () => { const mitxOnlineUser = mitxonline.factories.user.user() setMockResponse.get(mitxonline.urls.userMe.get(), mitxOnlineUser) - const reqTree = - new mitxonline.factories.requirements.RequirementTreeBuilder() - const requirements = reqTree.addOperator({ - operator: "all_of", - title: "Program Requirements", + // Child course (direct requirement of the parent program) + const childCourseRun = mitxonline.factories.courses.courseRun({ + b2b_contract: null, + is_enrollable: true, + courseware_url: faker.internet.url(), }) - requirements.addCourse({ course: 1 }) - - const program = mitxonline.factories.programs.program({ - id: 888, - courses: [1], - req_tree: reqTree.serialize(), + const childCourse = mitxonline.factories.courses.course({ + courseruns: [childCourseRun], + next_run_id: childCourseRun.id, }) - const run = mitxonline.factories.courses.courseRun({ + // Module course (child of the program-as-course) + const moduleRun = mitxonline.factories.courses.courseRun({ b2b_contract: null, is_enrollable: true, courseware_url: faker.internet.url(), }) + const moduleCourse = mitxonline.factories.courses.course({ + courseruns: [moduleRun], + next_run_id: moduleRun.id, + }) - const courses = { - count: 1, - next: null, - previous: null, - results: [ - mitxonline.factories.courses.course({ - id: 1, - title: "Test Course", - courseruns: [run], - next_run_id: run.id, - }), - ], - } + // Program-as-course (child requirement of the parent program) + const programAsCourseReqTree = + new mitxonline.factories.requirements.RequirementTreeBuilder() + const moduleSection = programAsCourseReqTree.addOperator({ + operator: "all_of", + title: "Modules", + }) + moduleSection.addCourse({ course: moduleCourse.id }) + + const programAsCourse = mitxonline.factories.programs.program({ + display_mode: "course", + courses: [moduleCourse.id], + req_tree: programAsCourseReqTree.serialize(), + }) - const programEnrollment = + // Parent program with both a course and a program-as-course + const parentReqTree = + new mitxonline.factories.requirements.RequirementTreeBuilder() + const parentRequirements = parentReqTree.addOperator({ + operator: "all_of", + title: "Program Requirements", + }) + parentRequirements.addCourse({ course: childCourse.id }) + parentRequirements.addProgram({ program: programAsCourse.id }) + + const parentProgram = mitxonline.factories.programs.program({ + courses: [childCourse.id], + req_tree: parentReqTree.serialize(), + }) + + const parentProgramEnrollment = mitxonline.factories.enrollment.programEnrollmentV3({ - enrollment_mode: "verified", // Verified program enrollment + enrollment_mode: "verified", program: { - id: program.id, - title: program.title, - live: program.live, - program_type: program.program_type, - readable_id: program.readable_id, + id: parentProgram.id, + title: parentProgram.title, + live: parentProgram.live, + program_type: parentProgram.program_type, + readable_id: parentProgram.readable_id, }, }) mockedUseFeatureFlagEnabled.mockReturnValue(true) - setMockResponse.get(mitxonline.urls.enrollment.enrollmentsListV3(), []) // No course enrollments yet + setMockResponse.get(mitxonline.urls.enrollment.enrollmentsListV3(), []) setMockResponse.get( mitxonline.urls.programEnrollments.enrollmentsListV3(), - [programEnrollment], + [parentProgramEnrollment], + ) + setMockResponse.get( + mitxonline.urls.programs.programDetail(parentProgram.id), + parentProgram, ) - setMockResponse.get(mitxonline.urls.programs.programDetail(888), program) setMockResponse.get( mitxonline.urls.courses.coursesList({ - id: program.courses, - page_size: program.courses.length, + id: parentProgram.courses, + page_size: parentProgram.courses.length, }), - courses, + { count: 1, next: null, previous: null, results: [childCourse] }, + ) + setMockResponse.get( + mitxonline.urls.programs.programsList({ + id: [programAsCourse.id], + page_size: 1, + }), + { + count: 1, + next: null, + previous: null, + results: [programAsCourse], + }, + ) + setMockResponse.get( + mitxonline.urls.courses.coursesList({ + id: [moduleCourse.id], + page_size: 1, + }), + { count: 1, next: null, previous: null, results: [moduleCourse] }, ) - // Mock the enrollment endpoint - const programEnrollmentEndpoint = + // Mock verified enrollment endpoints for both course runs + const childCourseEnrollmentEndpoint = mitxonline.urls.verifiedProgramEnrollments.create( - programEnrollment.program.readable_id, - run.courseware_id, + childCourseRun.courseware_id, ) - setMockResponse.post(programEnrollmentEndpoint, {}) + setMockResponse.post(childCourseEnrollmentEndpoint, {}) - renderWithProviders() + const moduleCourseEnrollmentEndpoint = + mitxonline.urls.verifiedProgramEnrollments.create( + moduleRun.courseware_id, + ) + setMockResponse.post(moduleCourseEnrollmentEndpoint, {}) + + return { + parentProgram, + parentProgramEnrollment, + childCourse, + childCourseRun, + childCourseEnrollmentEndpoint, + programAsCourse, + moduleCourse, + moduleRun, + moduleCourseEnrollmentEndpoint, + } + } + + test("Clicking 'Start Course' on a regular course in a verified program does one-click enrollment", async () => { + const { + parentProgram, + parentProgramEnrollment, + childCourse, + childCourseEnrollmentEndpoint, + } = setupProgramDashboardVerifiedEnrollmentScenario() + + renderWithProviders() await screen.findByText("Program Requirements") + await waitFor( + () => { + const skeletons = screen.queryAllByTestId("skeleton") + expect(skeletons).toHaveLength(0) + }, + { timeout: 3000 }, + ) + + const cards = screen.getAllByTestId("enrollment-card-desktop") + const card = cards.find((c) => within(c).queryByText(childCourse.title)) + invariant( + card, + `Expected to find a card containing "${childCourse.title}"`, + ) + + const startButton = within(card).getByTestId("courseware-button") + await user.click(startButton) + + await waitFor(() => { + expect(mockAxiosInstance.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + url: childCourseEnrollmentEndpoint, + data: JSON.stringify([parentProgramEnrollment.program.readable_id]), + }), + ) + }) - // Wait for the card to load + expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + }) + + test("Clicking 'Start Course' on a module in a program-as-course sends both parent and grandparent program IDs", async () => { + const { + parentProgram, + parentProgramEnrollment, + programAsCourse, + moduleCourse, + moduleCourseEnrollmentEndpoint, + } = setupProgramDashboardVerifiedEnrollmentScenario() + + renderWithProviders() + + await screen.findByText("Program Requirements") await waitFor( () => { const skeletons = screen.queryAllByTestId("skeleton") @@ -1527,25 +1632,29 @@ describe("EnrollmentDisplay", () => { { timeout: 3000 }, ) - // Find the card and click the button const cards = screen.getAllByTestId("enrollment-card-desktop") - const card = cards.find((c) => within(c).queryByText("Test Course")) - expect(card).toBeDefined() + const card = cards.find((c) => within(c).queryByText(moduleCourse.title)) + invariant( + card, + `Expected to find a card containing "${moduleCourse.title}"`, + ) - const startButton = within(card!).getByTestId("courseware-button") + const startButton = within(card).getByTestId("courseware-button") await user.click(startButton) - // Should call enrollment endpoint (not open dialog) await waitFor(() => { expect(mockAxiosInstance.request).toHaveBeenCalledWith( expect.objectContaining({ method: "POST", - url: programEnrollmentEndpoint, + url: moduleCourseEnrollmentEndpoint, + data: JSON.stringify([ + programAsCourse.readable_id, + parentProgramEnrollment.program.readable_id, + ]), }), ) }) - // Dialog should NOT appear expect(screen.queryByRole("dialog")).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 bd0e622bcb..2d23234a4f 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx @@ -180,6 +180,7 @@ const isProgramAsCourseEnrollment = ( type ProgramAsCourseProgramData = { id: number + readable_id: string title?: string | null start_date?: string | null end_date?: string | null @@ -222,100 +223,58 @@ const EnrollmentExpandCollapse: React.FC = ({ ? maybeShown : maybeShown.slice(MIN_VISIBLE) - return ( - <> - - {shownResources.map((resource) => { - if (isProgramAsCourseEnrollment(resource)) { - const courseProgram = courseProgramsById.get( - resource.data.program.id, - ) - if (!courseProgram) { - return ( - - ) - } + const renderResource = (resource: DashboardResource) => { + if (isProgramAsCourseEnrollment(resource)) { + const courseProgram = courseProgramsById.get(resource.data.program.id) + if (!courseProgram) { + return ( + + ) + } - return ( - - ) + return ( + + ) + } - return ( - - ) - })} + return ( + + ) + } + + return ( + <> + + {shownResources.map(renderResource)} {hiddenResources.length === 0 ? null : ( <> - {hiddenResources.map((resource) => { - if (isProgramAsCourseEnrollment(resource)) { - const courseProgram = courseProgramsById.get( - resource.data.program.id, - ) - if (!courseProgram) { - return ( - - ) - } - - return ( - - ) - } - - return ( - - ) - })} + {hiddenResources.map(renderResource)} @@ -712,6 +671,16 @@ const ProgramEnrollmentDisplay: React.FC = ({ } moduleEnrollmentsByCourseId={enrollmentsByCourseId} courseProgramEnrollment={item.courseProgramEnrollment} + ancestorProgramEnrollment={ + programEnrollment + ? { + readable_id: + programEnrollment.program.readable_id, + enrollment_mode: + programEnrollment.enrollment_mode, + } + : undefined + } /> ) } @@ -770,16 +739,12 @@ const AllEnrollmentsDisplay: React.FC = () => { ) }) ?? [] - const programAsCourseProgramIds = React.useMemo( - () => - filteredProgramEnrollments - .filter( - (enrollment) => - enrollment.program.display_mode === DisplayModeEnum.Course, - ) - .map((enrollment) => enrollment.program.id), - [filteredProgramEnrollments], - ) + const programAsCourseProgramIds = filteredProgramEnrollments + .filter( + (enrollment) => + enrollment.program.display_mode === DisplayModeEnum.Course, + ) + .map((enrollment) => enrollment.program.id) const { data: homeCoursePrograms, isLoading: homeCourseProgramsLoading } = useQuery({ @@ -790,15 +755,13 @@ const AllEnrollmentsDisplay: React.FC = () => { enabled: programAsCourseProgramIds.length > 0, }) - const homeCourseProgramModuleIds = React.useMemo(() => { - const uniqueIds = new Set() - ;(homeCoursePrograms?.results ?? []).forEach((courseProgram) => { - ;(courseProgram.courses ?? []).forEach((courseId) => - uniqueIds.add(courseId), - ) - }) - return [...uniqueIds] - }, [homeCoursePrograms?.results]) + const homeCourseProgramModuleIds = [ + ...new Set( + homeCoursePrograms?.results.flatMap( + (courseProgram) => getIdsFromReqTree(courseProgram.req_tree).courseIds, + ), + ), + ] const { data: homeCourseProgramModuleCourses, diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ModuleCard.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ModuleCard.tsx index be480f61ae..87291cbbe0 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ModuleCard.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ModuleCard.tsx @@ -30,17 +30,11 @@ import { EnrollmentStatus, getBestRun, getEnrollmentStatus } from "./helpers" import { CourseWithCourseRunsSerializerV2, CourseRunEnrollmentV3, - V3UserProgramEnrollment, CourseRunV2, + EnrollmentModeEnum, } from "@mitodl/mitxonline-api-axios/v2" import CourseEnrollmentDialog from "@/page-components/EnrollmentDialogs/CourseEnrollmentDialog" -const EnrollmentMode = { - Audit: "audit", - Verified: "verified", -} as const -type EnrollmentMode = (typeof EnrollmentMode)[keyof typeof EnrollmentMode] - export const DashboardType = { Course: "course", CourseRunEnrollment: "courserun-enrollment", @@ -266,28 +260,28 @@ const useEnrollmentHandler = () => { const enroll = React.useCallback( ({ + courseRun, course, - readableId, - href, isB2B, - isVerifiedProgram, - programCoursewareId, + useVerifiedEnrollment, + parentProgramIds, }: { + courseRun: CourseRunV2 course: CourseWithCourseRunsSerializerV2 - readableId?: string - href?: string isB2B?: boolean - isVerifiedProgram?: boolean - programCoursewareId?: string + useVerifiedEnrollment?: boolean + parentProgramIds?: string[] }) => { + const readableId = courseRun.courseware_id + const href = courseRun.courseware_url + if (!readableId || !href) { + console.warn("Cannot enroll: missing required data", { + readableId, + href, + }) + return + } if (isB2B) { - if (!readableId || !href) { - console.warn("Cannot enroll in B2B course: missing required data", { - readableId, - href, - }) - return - } const userCountry = mitxOnlineUser.data?.legal_address?.country const userYearOfBirth = mitxOnlineUser.data?.user_profile?.year_of_birth const showJustInTimeDialog = !userCountry || !userYearOfBirth @@ -307,16 +301,12 @@ const useEnrollmentHandler = () => { }, ) } - } else if (isVerifiedProgram && programCoursewareId && readableId) { - if (!href) { - console.warn( - "Cannot enroll in verified program course: missing href", - { href }, - ) - return - } + } else if (useVerifiedEnrollment && parentProgramIds?.length) { createVerifiedProgramEnrollment.mutate( - { courserun_id: readableId, program_id: programCoursewareId }, + { + courserun_id: readableId, + request_body: parentProgramIds, + }, { onSuccess: () => { window.location.href = href @@ -570,7 +560,8 @@ type DashboardCardProps = { className?: string variant?: "default" | "stacked" contractId?: number - programEnrollment?: V3UserProgramEnrollment + useVerifiedEnrollment?: boolean + parentProgramIds?: string[] onUpgradeError?: (error: string) => void } @@ -670,7 +661,8 @@ const DashboardCourseCard: React.FC = ({ className, variant = "default", contractId, - programEnrollment, + useVerifiedEnrollment, + parentProgramIds, onUpgradeError, }) => { const enrollment = useEnrollmentHandler() @@ -705,15 +697,9 @@ const DashboardCourseCard: React.FC = ({ const disableEnrollment = isCourse && !hasEnrollableRuns - const readableId = isCourse - ? courseRun?.courseware_id - : isCourseRunEnrollment - ? resource.data.run.courseware_id - : undefined - const canUpgrade = isCourseRunEnrollment && - resource.data.enrollment_mode !== EnrollmentMode.Verified && + resource.data.enrollment_mode !== EnrollmentModeEnum.Verified && (enrollmentRun?.is_upgradable ?? false) && (enrollmentRun?.upgrade_product_is_active ?? false) @@ -722,31 +708,23 @@ const DashboardCourseCard: React.FC = ({ const upgradeProductId = enrollmentRun?.upgrade_product_id const handleEnrollmentClick = React.useCallback(() => { - if (!isCourse) return - - const isVerifiedProgramEnrollment = - programEnrollment?.enrollment_mode === EnrollmentMode.Verified + if (!isCourse || !courseRun) return enrollment.enroll({ + courseRun, course: resource.data, - readableId, - href: buttonHref ?? coursewareUrl ?? undefined, isB2B: !!b2bContractId, - isVerifiedProgram: isVerifiedProgramEnrollment, - programCoursewareId: isVerifiedProgramEnrollment - ? programEnrollment?.program.readable_id - : undefined, + useVerifiedEnrollment, + parentProgramIds, }) }, [ b2bContractId, - buttonHref, - coursewareUrl, enrollment, isCourse, - programEnrollment?.enrollment_mode, - programEnrollment?.program.readable_id, - readableId, + useVerifiedEnrollment, + parentProgramIds, resource, + courseRun, ]) const titleHref = isCourseRunEnrollment ? (buttonHref ?? coursewareUrl) : null @@ -788,7 +766,7 @@ const DashboardCourseCard: React.FC = ({ const showUpgradeLink = isCourseRunEnrollment && - resource.data.enrollment_mode !== EnrollmentMode.Verified && + resource.data.enrollment_mode !== EnrollmentModeEnum.Verified && offerUpgrade const showCertificateSection = certificateLink || showUpgradeLink const startDate = courseRun?.start_date ?? enrollmentRun?.start_date 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 961f1b9714..6d89ec398d 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx @@ -5,60 +5,62 @@ import { setMockResponse, setupLocationMock, user, + within, } from "@/test-utils" +import { mockAxiosInstance } from "api/test-utils" import * as mitxonline from "api/mitxonline-test-utils" import { ProgramAsCourseCard } from "./ProgramAsCourseCard" +import { waitFor } from "@testing-library/react" +import invariant from "tiny-invariant" import moment from "moment" describe("ProgramAsCourseCard", () => { setupLocationMock() + /** + * Creates a ProgramAsCourseCard data set with: + * - A program with two module courses linked via req_tree + * - An enrollment in the first module (no grades, no certificate) + * - Optionally, a program enrollment for the courselike program + * - Mock response for the user endpoint + */ const setupCardData = ({ - programId, - includeProgramEnrollment, + includeProgramEnrollment = false, startDate, endDate, }: { - programId: number - includeProgramEnrollment: boolean + includeProgramEnrollment?: boolean startDate?: string | null endDate?: string | null - }) => { + } = {}) => { + const moduleOne = mitxonline.factories.courses.course({ + courseruns: [mitxonline.factories.courses.courseRun()], + }) + const moduleTwo = mitxonline.factories.courses.course({ + courseruns: [mitxonline.factories.courses.courseRun()], + }) + const reqTree = new mitxonline.factories.requirements.RequirementTreeBuilder() const modules = reqTree.addOperator({ operator: "all_of", title: "Modules", }) - modules.addCourse({ course: 1 }) - modules.addCourse({ course: 2 }) + modules.addCourse({ course: moduleOne.id }) + modules.addCourse({ course: moduleTwo.id }) const program = mitxonline.factories.programs.program({ - id: programId, - title: "Micro Program", - courses: [1, 2], + courses: [moduleOne.id, moduleTwo.id], req_tree: reqTree.serialize(), start_date: startDate ?? null, end_date: endDate ?? null, }) - const moduleOne = mitxonline.factories.courses.course({ - id: 1, - title: "Module One", - courseruns: [mitxonline.factories.courses.courseRun()], - }) - const moduleTwo = mitxonline.factories.courses.course({ - id: 2, - title: "Module Two", - courseruns: [mitxonline.factories.courses.courseRun()], - }) - const moduleEnrollment = mitxonline.factories.enrollment.courseEnrollment({ run: { ...moduleOne.courseruns[0], course: moduleOne, }, - grades: [], certificate: null, }) @@ -90,10 +92,7 @@ describe("ProgramAsCourseCard", () => { } test("renders modules and progress summary", async () => { - const cardData = setupCardData({ - programId: 301, - includeProgramEnrollment: true, - }) + const cardData = setupCardData({ includeProgramEnrollment: true }) renderWithProviders( { />, ) - await screen.findByText("Micro Program") + await screen.findByText(cardData.courseProgram.title) expect(screen.getByText("2 Modules (0 of 2 complete)")).toBeInTheDocument() - expect(screen.getAllByText("Module One").length).toBeGreaterThan(0) - expect(screen.getAllByText("Module Two").length).toBeGreaterThan(0) + expect( + screen.getAllByText(cardData.moduleCourses[0].title).length, + ).toBeGreaterThan(0) + expect( + screen.getAllByText(cardData.moduleCourses[1].title).length, + ).toBeGreaterThan(0) }) test("renders when user is not enrolled in the ProgramAsCourse", async () => { - const cardData = setupCardData({ - programId: 302, - includeProgramEnrollment: false, - }) + const cardData = setupCardData() renderWithProviders( { />, ) - await screen.findByText("Micro Program") + await screen.findByText(cardData.courseProgram.title) expect(screen.getByText("Not Started")).toBeInTheDocument() }) test("shows date popover content when date summary is clicked", async () => { const cardData = setupCardData({ - programId: 303, includeProgramEnrollment: true, startDate: moment().subtract(5, "days").toISOString(), endDate: moment().add(5, "days").toISOString(), @@ -152,50 +151,109 @@ describe("ProgramAsCourseCard", () => { expect(await screen.findByText("Important Dates:")).toBeInTheDocument() }) - test("renders module rows in req_tree order, not API result order", async () => { - const reqTree = - new mitxonline.factories.requirements.RequirementTreeBuilder() - const modules = reqTree.addOperator({ - operator: "all_of", - title: "Modules", - }) - modules.addCourse({ course: 2 }) - modules.addCourse({ course: 1 }) + test("displays module rows in req_tree order, irrespective of moduleCourses order", async () => { + const cardData = setupCardData() + const [moduleOne, moduleTwo] = cardData.moduleCourses - const moduleOne = mitxonline.factories.courses.course({ - id: 1, - title: "Module One", - courseruns: [mitxonline.factories.courses.courseRun()], + renderWithProviders( + , + ) + + await screen.findByText(cardData.courseProgram.title) + const rows = await screen.findAllByTestId("enrollment-card-desktop") + // req_tree has moduleOne first, moduleTwo second (from setupCardData) + expect(rows[0]).toHaveTextContent(moduleOne.title) + expect(rows[1]).toHaveTextContent(moduleTwo.title) + }) + + test("clicking 'Start Course' on an unenrolled module uses verified enrollment when ancestor has verified mode", async () => { + const cardData = setupCardData() + const [moduleOne] = cardData.moduleCourses + + // Create a run we control for the first module + const run = mitxonline.factories.courses.courseRun({ + is_enrollable: true, + courseware_url: "https://courses.example.com/run1", }) - const moduleTwo = mitxonline.factories.courses.course({ - id: 2, - title: "Module Two", - courseruns: [mitxonline.factories.courses.courseRun()], + const moduleWithRun = mitxonline.factories.courses.course({ + id: moduleOne.id, + courseruns: [run], + next_run_id: run.id, }) - const courseProgram = mitxonline.factories.programs.program({ - id: 304, - title: "Micro Program", - courses: [1, 2], - req_tree: reqTree.serialize(), - }) + const enrollmentEndpoint = + mitxonline.urls.verifiedProgramEnrollments.create(run.courseware_id) + setMockResponse.post(enrollmentEndpoint, {}) - setMockResponse.get( - mitxonline.urls.userMe.get(), - mitxonline.factories.user.user(), + renderWithProviders( + , + ) + + const cards = await screen.findAllByTestId("enrollment-card-desktop") + const card = cards.find((c) => within(c).queryByText(moduleWithRun.title)) + invariant( + card, + `Expected to find a card containing "${moduleWithRun.title}"`, ) + const startButton = within(card).getByTestId("courseware-button") + await user.click(startButton) + + await waitFor(() => { + expect(mockAxiosInstance.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + url: enrollmentEndpoint, + data: JSON.stringify([ + cardData.courseProgram.readable_id, + "grandparent-program", + ]), + }), + ) + }) + }) + + test("clicking 'Start Course' on an unenrolled module opens enrollment dialog when no ancestor is verified", async () => { + const cardData = setupCardData() + const [moduleOne] = cardData.moduleCourses + + const run = mitxonline.factories.courses.courseRun({ + is_enrollable: true, + }) + const moduleWithRun = mitxonline.factories.courses.course({ + id: moduleOne.id, + courseruns: [run], + next_run_id: run.id, + }) renderWithProviders( , ) - await screen.findByText("Micro Program") - const rows = await screen.findAllByTestId("enrollment-card-desktop") - expect(rows[0]).toHaveTextContent("Module Two") - expect(rows[1]).toHaveTextContent("Module One") + const cards = await screen.findAllByTestId("enrollment-card-desktop") + const card = cards.find((c) => within(c).queryByText(moduleWithRun.title)) + invariant( + card, + `Expected to find a card containing "${moduleWithRun.title}"`, + ) + const startButton = within(card).getByTestId("courseware-button") + await user.click(startButton) + + await screen.findByRole("dialog", { name: moduleWithRun.title }) }) }) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx index d13ef9caef..f3a4cf8d2c 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx @@ -20,7 +20,10 @@ import { DashboardType as ModuleCardType, } from "./ModuleCard" import { formatDate } from "ol-utilities" -import { getIdsFromReqTree } from "@/common/mitxonline" +import { + getIdsFromReqTree, + isVerifiedEnrollmentMode, +} from "@/common/mitxonline" const ProgramCardRoot = styled.div(({ theme }) => ({ display: "flex", @@ -244,17 +247,47 @@ const getRelativeDateContent = ( } 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[] } + /** + * child courses of the program. These correspond to nodes in the req_tree. + */ moduleCourses: CourseWithCourseRunsSerializerV2[] + /** + * Enrollments in the child courses. These may or may not exist, depending on + * whether the user has started that course. + */ moduleEnrollmentsByCourseId: Record + /** + * Enrollment in the courselike program, if user has an enrollment in it. + */ courseProgramEnrollment?: V3UserProgramEnrollment + /** + * Additional ancestor program enrollments. + * + * This facilitates verified enrollments. For example: + * - Ancestor Program P1 + * - Courselike Program P1a + * - Child Course C1, etc... + * + * Initially, a user will have a verified enrollment in P1 but NOT P1a. + * We pass P1's enrollment as an ancestorProgramEnrollment. This allows us to + * request a verified enrollment in both C1 and P1a. + */ + ancestorProgramEnrollment?: { + readable_id: string + enrollment_mode?: string | null + } Component?: React.ElementType className?: string } @@ -278,6 +311,7 @@ const ProgramAsCourseCard: React.FC = ({ moduleCourses, moduleEnrollmentsByCourseId, courseProgramEnrollment, + ancestorProgramEnrollment, Component, className, }) => { @@ -339,6 +373,17 @@ const ProgramAsCourseCard: React.FC = ({ ) const showDatePopoverTrigger = Boolean(datePopoverContent) + const parentProgramIds = [ + courseProgram.readable_id, + ...(ancestorProgramEnrollment + ? [ancestorProgramEnrollment.readable_id] + : []), + ] + const useVerifiedEnrollment = [ + courseProgramEnrollment?.enrollment_mode, + ancestorProgramEnrollment?.enrollment_mode, + ].some(isVerifiedEnrollmentMode) + return ( = ({ runId: bestEnrollment?.run.id, })} resource={resource} - programEnrollment={courseProgramEnrollment} + useVerifiedEnrollment={useVerifiedEnrollment} + parentProgramIds={parentProgramIds} variant="stacked" /> ) diff --git a/frontends/main/src/common/mitxonline.ts b/frontends/main/src/common/mitxonline.ts index e18f9976f6..b66e525eb6 100644 --- a/frontends/main/src/common/mitxonline.ts +++ b/frontends/main/src/common/mitxonline.ts @@ -6,7 +6,11 @@ import type { ProductFlexiblePrice, V2ProgramRequirement, } from "@mitodl/mitxonline-api-axios/v2" -import { DiscountTypeEnum, NodeTypeEnum } from "@mitodl/mitxonline-api-axios/v2" +import { + DiscountTypeEnum, + EnrollmentModeEnum, + NodeTypeEnum, +} from "@mitodl/mitxonline-api-axios/v2" import invariant from "tiny-invariant" const NEXT_PUBLIC_MITX_ONLINE_LEGACY_BASE_URL = @@ -183,6 +187,11 @@ const getBestRun = ( if (contractId) runs = runs.filter((run) => run.b2b_contract === contractId) return runs.find((run) => run.id === course.next_run_id) ?? runs[0] } + +const isVerifiedEnrollmentMode = (mode?: string | null) => { + return mode === EnrollmentModeEnum.Verified +} + export { formatPrice, priceWithDiscount, @@ -192,5 +201,6 @@ export { getEnrollmentType, getIdsFromReqTree, getBestRun, + isVerifiedEnrollmentMode, } export type { PriceWithDiscount, EnrollmentType } diff --git a/yarn.lock b/yarn.lock index 1ba46b873d..2052d9db39 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3514,13 +3514,13 @@ __metadata: languageName: node linkType: hard -"@mitodl/mitxonline-api-axios@npm:^2026.3.24": - version: 2026.3.24 - resolution: "@mitodl/mitxonline-api-axios@npm:2026.3.24" +"@mitodl/mitxonline-api-axios@npm:^2026.3.25": + version: 2026.3.25 + resolution: "@mitodl/mitxonline-api-axios@npm:2026.3.25" dependencies: "@types/node": "npm:^20.11.19" axios: "npm:^1.6.5" - checksum: 10/aa3c320515a8436df8c16164dd37d96ee0a9900d2e6575e9d151326a5398be6e20dfadb899bfe0604551080da64cefa1158f63d520f333325dfb1f4024415c6e + checksum: 10/978e7e669c67778b0a6326072b57546d615ea5e02540675a171c4eb6f84949ef5645b6c0dff5fdbf9c9df823f4f4ece9e5ad1fa3eda43559f419f36b77afb333 languageName: node linkType: hard @@ -8863,7 +8863,7 @@ __metadata: resolution: "api@workspace:frontends/api" dependencies: "@faker-js/faker": "npm:^10.0.0" - "@mitodl/mitxonline-api-axios": "npm:^2026.3.24" + "@mitodl/mitxonline-api-axios": "npm:^2026.3.25" "@tanstack/react-query": "npm:^5.66.0" "@testing-library/react": "npm:^16.3.0" axios: "npm:^1.12.2" @@ -16058,7 +16058,7 @@ __metadata: "@floating-ui/react": "npm:^0.27.16" "@happy-dom/jest-environment": "npm:^20.1.0" "@mitodl/course-search-utils": "npm:^3.5.2" - "@mitodl/mitxonline-api-axios": "npm:^2026.3.24" + "@mitodl/mitxonline-api-axios": "npm:^2026.3.25" "@mitodl/smoot-design": "npm:^6.24.0" "@mui/material": "npm:^6.4.5" "@mui/material-nextjs": "npm:^6.4.3"