From 0ec2f85285a06ca42ea781a8757fd82b15f16583 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Tue, 24 Mar 2026 20:00:41 -0400 Subject: [PATCH 01/19] bump client --- frontends/api/package.json | 2 +- frontends/main/package.json | 2 +- yarn.lock | 12 ++++++------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/frontends/api/package.json b/frontends/api/package.json index e7f2764d51..1d70ecac94 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": "https://github.com/mitodl/mitxonline-api-clients/raw/83ae19dce4aa041d52bcb854efcec5f4fa9bd013/src/typescript/mitxonline-api-axios/package.tgz", "@tanstack/react-query": "^5.66.0", "axios": "^1.12.2", "tiny-invariant": "^1.3.3" diff --git a/frontends/main/package.json b/frontends/main/package.json index 6c910e0f1c..dba9b8e111 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": "https://github.com/mitodl/mitxonline-api-clients/raw/83ae19dce4aa041d52bcb854efcec5f4fa9bd013/src/typescript/mitxonline-api-axios/package.tgz", "@mitodl/smoot-design": "^6.24.0", "@mui/material": "^6.4.5", "@mui/material-nextjs": "^6.4.3", diff --git a/yarn.lock b/yarn.lock index 1ba46b873d..477523766d 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@https://github.com/mitodl/mitxonline-api-clients/raw/83ae19dce4aa041d52bcb854efcec5f4fa9bd013/src/typescript/mitxonline-api-axios/package.tgz": + version: 2026.3.9 + resolution: "@mitodl/mitxonline-api-axios@https://github.com/mitodl/mitxonline-api-clients/raw/83ae19dce4aa041d52bcb854efcec5f4fa9bd013/src/typescript/mitxonline-api-axios/package.tgz" dependencies: "@types/node": "npm:^20.11.19" axios: "npm:^1.6.5" - checksum: 10/aa3c320515a8436df8c16164dd37d96ee0a9900d2e6575e9d151326a5398be6e20dfadb899bfe0604551080da64cefa1158f63d520f333325dfb1f4024415c6e + checksum: 10/fc3edfc0899082e48517a4f8a1c7855247b78498e1dc49e6b31fbf2b42025b122782a3d67de18e742f49e416efc3ae3954cf510a71f68c2698071460caacd62b 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": "https://github.com/mitodl/mitxonline-api-clients/raw/83ae19dce4aa041d52bcb854efcec5f4fa9bd013/src/typescript/mitxonline-api-axios/package.tgz" "@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": "https://github.com/mitodl/mitxonline-api-clients/raw/83ae19dce4aa041d52bcb854efcec5f4fa9bd013/src/typescript/mitxonline-api-axios/package.tgz" "@mitodl/smoot-design": "npm:^6.24.0" "@mui/material": "npm:^6.4.5" "@mui/material-nextjs": "npm:^6.4.3" From 65d05969ae019895658d446060ad8ff56dcd5ff5 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Tue, 24 Mar 2026 20:00:59 -0400 Subject: [PATCH 02/19] bump client --- frontends/api/src/mitxonline/test-utils/urls.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 { From 97b90bd68ea6aa837cc0083f0579e0d7c323d8fd Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Tue, 24 Mar 2026 20:03:30 -0400 Subject: [PATCH 03/19] fix verified program enrollment API call to match updated client The API changed from program_id path param to request_body array. Update DashboardCard and the enrollment test to use the new signature. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../DashboardPage/CoursewareDisplay/DashboardCard.tsx | 2 +- .../CoursewareDisplay/EnrollmentDisplay.test.tsx | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx index 2fe41742e3..dc12fd7773 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx @@ -335,7 +335,7 @@ const useEnrollmentHandler = () => { return } createVerifiedProgramEnrollment.mutate( - { courserun_id: readableId, program_id: programCoursewareId }, + { courserun_id: readableId, request_body: [programCoursewareId] }, { onSuccess: () => { window.location.href = href diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx index d130432bef..8958a338e2 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx @@ -1508,10 +1508,7 @@ describe("EnrollmentDisplay", () => { // 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() From 61c2fa02bc0e2f14eecaf1ac094ccdb34fefee39 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Tue, 24 Mar 2026 20:05:12 -0400 Subject: [PATCH 04/19] add ancestorPrograms prop for verified enrollment across program hierarchy ModuleCard now accepts ancestorPrograms (array of {readable_id, enrollment_mode}) instead of a single programEnrollment. When any ancestor has verified enrollment_mode, it calls createVerifiedProgramEnrollment with all ancestor readable_ids. This enables verified enrollment for courses nested inside a program-as-course whose grandparent program has the verified enrollment. ProgramAsCourseCard passes ancestorPrograms through to ModuleCard. EnrollmentDisplay assembles the array: home dashboard passes the parent program-as-course; program dashboard passes both parent and grandparent. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../CoursewareDisplay/EnrollmentDisplay.tsx | 24 ++++++ .../CoursewareDisplay/ModuleCard.tsx | 82 ++++++++---------- .../ProgramAsCourseCard.test.tsx | 86 +++++++++++++++++++ .../CoursewareDisplay/ProgramAsCourseCard.tsx | 5 +- 4 files changed, 148 insertions(+), 49 deletions(-) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx index bd0e622bcb..5eae7ae946 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx @@ -38,6 +38,7 @@ import { import { contractQueries } from "api/mitxonline-hooks/contracts" import NotFoundPage from "@/app-pages/ErrorPage/NotFoundPage" import { ProgramAsCourseCard } from "./ProgramAsCourseCard" +import type { AncestorProgram } from "./ModuleCard" import { getIdsFromReqTree } from "@/common/mitxonline" const Wrapper = styled.div(({ theme }) => ({ @@ -253,6 +254,12 @@ const EnrollmentExpandCollapse: React.FC = ({ } moduleEnrollmentsByCourseId={enrollmentsByCourseId} courseProgramEnrollment={resource.data} + ancestorPrograms={[ + { + readable_id: resource.data.program.readable_id, + enrollment_mode: resource.data.enrollment_mode, + }, + ]} /> ) } @@ -698,6 +705,22 @@ const ProgramEnrollmentDisplay: React.FC = ({ } if (item.resourceType === "program-as-course") { + const ancestors: AncestorProgram[] = [] + if (item.courseProgramEnrollment) { + ancestors.push({ + readable_id: + item.courseProgramEnrollment.program.readable_id, + enrollment_mode: + item.courseProgramEnrollment.enrollment_mode, + }) + } + if (programEnrollment) { + ancestors.push({ + readable_id: programEnrollment.program.readable_id, + enrollment_mode: programEnrollment.enrollment_mode, + }) + } + return ( = ({ } moduleEnrollmentsByCourseId={enrollmentsByCourseId} courseProgramEnrollment={item.courseProgramEnrollment} + ancestorPrograms={ancestors} /> ) } diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ModuleCard.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ModuleCard.tsx index be480f61ae..31363b513c 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ModuleCard.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ModuleCard.tsx @@ -30,7 +30,6 @@ import { EnrollmentStatus, getBestRun, getEnrollmentStatus } from "./helpers" import { CourseWithCourseRunsSerializerV2, CourseRunEnrollmentV3, - V3UserProgramEnrollment, CourseRunV2, } from "@mitodl/mitxonline-api-axios/v2" import CourseEnrollmentDialog from "@/page-components/EnrollmentDialogs/CourseEnrollmentDialog" @@ -51,6 +50,11 @@ export type DashboardResource = | { type: "course"; data: CourseWithCourseRunsSerializerV2 } | { type: "courserun-enrollment"; data: CourseRunEnrollmentV3 } +export type AncestorProgram = { + readable_id: string + enrollment_mode?: string | null +} + /** * Gets the certificate link for a dashboard resource based on its type. */ @@ -266,28 +270,26 @@ const useEnrollmentHandler = () => { const enroll = React.useCallback( ({ + courseRun, course, - readableId, - href, isB2B, - isVerifiedProgram, - programCoursewareId, + ancestorPrograms, }: { + courseRun: CourseRunV2 course: CourseWithCourseRunsSerializerV2 - readableId?: string - href?: string isB2B?: boolean - isVerifiedProgram?: boolean - programCoursewareId?: string + ancestorPrograms?: AncestorProgram[] }) => { + 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 +309,16 @@ const useEnrollmentHandler = () => { }, ) } - } else if (isVerifiedProgram && programCoursewareId && readableId) { - if (!href) { - console.warn( - "Cannot enroll in verified program course: missing href", - { href }, - ) - return - } + } else if ( + ancestorPrograms?.some( + (p) => p.enrollment_mode === EnrollmentMode.Verified, + ) + ) { createVerifiedProgramEnrollment.mutate( - { courserun_id: readableId, program_id: programCoursewareId }, + { + courserun_id: readableId, + request_body: ancestorPrograms.map((p) => p.readable_id), + }, { onSuccess: () => { window.location.href = href @@ -570,7 +572,7 @@ type DashboardCardProps = { className?: string variant?: "default" | "stacked" contractId?: number - programEnrollment?: V3UserProgramEnrollment + ancestorPrograms?: AncestorProgram[] onUpgradeError?: (error: string) => void } @@ -670,7 +672,7 @@ const DashboardCourseCard: React.FC = ({ className, variant = "default", contractId, - programEnrollment, + ancestorPrograms, onUpgradeError, }) => { const enrollment = useEnrollmentHandler() @@ -705,12 +707,6 @@ 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 && @@ -722,31 +718,21 @@ 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, + ancestorPrograms, }) }, [ b2bContractId, - buttonHref, - coursewareUrl, enrollment, isCourse, - programEnrollment?.enrollment_mode, - programEnrollment?.program.readable_id, - readableId, + ancestorPrograms, resource, + courseRun, ]) const titleHref = isCourseRunEnrollment ? (buttonHref ?? coursewareUrl) : null 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..72a4e8b552 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx @@ -5,9 +5,12 @@ 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 moment from "moment" describe("ProgramAsCourseCard", () => { @@ -198,4 +201,87 @@ describe("ProgramAsCourseCard", () => { expect(rows[0]).toHaveTextContent("Module Two") expect(rows[1]).toHaveTextContent("Module One") }) + + test("clicking 'Start Course' on an unenrolled module uses verified enrollment when an ancestor has verified mode", async () => { + const run = mitxonline.factories.courses.courseRun({ + is_enrollable: true, + courseware_url: "https://courses.example.com/run1", + }) + + const cardData = setupCardData({ + programId: 305, + includeProgramEnrollment: false, + }) + // Override module courses so we control the run + const moduleOne = mitxonline.factories.courses.course({ + id: 1, + courseruns: [run], + next_run_id: run.id, + }) + + const enrollmentEndpoint = + mitxonline.urls.verifiedProgramEnrollments.create(run.courseware_id) + setMockResponse.post(enrollmentEndpoint, {}) + + renderWithProviders( + , + ) + + const cards = await screen.findAllByTestId("enrollment-card-desktop") + const card = cards.find((c) => within(c).queryByText(moduleOne.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(["parent-program", "grandparent-program"]), + }), + ) + }) + }) + + test("clicking 'Start Course' on an unenrolled module opens enrollment dialog when no ancestor is verified", async () => { + const run = mitxonline.factories.courses.courseRun({ + is_enrollable: true, + }) + + const cardData = setupCardData({ + programId: 306, + includeProgramEnrollment: false, + }) + const moduleOne = mitxonline.factories.courses.course({ + id: 1, + courseruns: [run], + next_run_id: run.id, + }) + + renderWithProviders( + , + ) + + const cards = await screen.findAllByTestId("enrollment-card-desktop") + const card = cards.find((c) => within(c).queryByText(moduleOne.title)) + const startButton = within(card!).getByTestId("courseware-button") + await user.click(startButton) + + await screen.findByRole("dialog", { name: moduleOne.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..95179cd7f9 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx @@ -6,6 +6,7 @@ import { V3UserProgramEnrollment, V2ProgramRequirement, } from "@mitodl/mitxonline-api-axios/v2" +import type { AncestorProgram } from "./ModuleCard" import { EnrollmentStatus, getEnrollmentStatus, @@ -255,6 +256,7 @@ interface ProgramAsCourseCardProps { moduleCourses: CourseWithCourseRunsSerializerV2[] moduleEnrollmentsByCourseId: Record courseProgramEnrollment?: V3UserProgramEnrollment + ancestorPrograms?: AncestorProgram[] Component?: React.ElementType className?: string } @@ -278,6 +280,7 @@ const ProgramAsCourseCard: React.FC = ({ moduleCourses, moduleEnrollmentsByCourseId, courseProgramEnrollment, + ancestorPrograms, Component, className, }) => { @@ -420,7 +423,7 @@ const ProgramAsCourseCard: React.FC = ({ runId: bestEnrollment?.run.id, })} resource={resource} - programEnrollment={courseProgramEnrollment} + ancestorPrograms={ancestorPrograms} variant="stacked" /> ) From 1111e4f283a715055693d39ba05dedaaef450dbd Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Tue, 24 Mar 2026 20:19:35 -0400 Subject: [PATCH 05/19] fix review issues: deduplicate render paths, move AncestorProgram type, fix legacy test - Extract renderResource() in EnrollmentExpandCollapse to eliminate duplicated map callback between shown/hidden resource lists. This also fixes the missing ancestorPrograms prop in the hidden resources path. - Move AncestorProgram type from ModuleCard to helpers.ts (shared domain concept, not card-specific). - Fix DashboardCard.test.tsx verified enrollment URL to match new API signature (courserun_id only, no program_id in path). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../CoursewareDisplay/DashboardCard.test.tsx | 5 +- .../CoursewareDisplay/EnrollmentDisplay.tsx | 144 +++++++----------- .../CoursewareDisplay/ModuleCard.tsx | 12 +- .../CoursewareDisplay/ProgramAsCourseCard.tsx | 2 +- .../CoursewareDisplay/helpers.ts | 6 + 5 files changed, 65 insertions(+), 104 deletions(-) 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/EnrollmentDisplay.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx index 5eae7ae946..8a27ebed9d 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx @@ -15,6 +15,7 @@ import { import { Alert } from "@mitodl/smoot-design" import { useQuery } from "@tanstack/react-query" import { + type AncestorProgram, EnrollmentStatus, getEnrollmentStatus, getProgramEnrollmentStatus, @@ -38,7 +39,6 @@ import { import { contractQueries } from "api/mitxonline-hooks/contracts" import NotFoundPage from "@/app-pages/ErrorPage/NotFoundPage" import { ProgramAsCourseCard } from "./ProgramAsCourseCard" -import type { AncestorProgram } from "./ModuleCard" import { getIdsFromReqTree } from "@/common/mitxonline" const Wrapper = styled.div(({ theme }) => ({ @@ -223,106 +223,64 @@ 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)} diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ModuleCard.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ModuleCard.tsx index 31363b513c..cc0bed75eb 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ModuleCard.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ModuleCard.tsx @@ -26,7 +26,12 @@ import { mitxUserQueries } from "api/mitxonline-hooks/user" import { useQuery } from "@tanstack/react-query" import { mitxonlineLegacyUrl } from "@/common/mitxonline" import { useReplaceBasketItem } from "api/mitxonline-hooks/baskets" -import { EnrollmentStatus, getBestRun, getEnrollmentStatus } from "./helpers" +import { + EnrollmentStatus, + getBestRun, + getEnrollmentStatus, + type AncestorProgram, +} from "./helpers" import { CourseWithCourseRunsSerializerV2, CourseRunEnrollmentV3, @@ -50,11 +55,6 @@ export type DashboardResource = | { type: "course"; data: CourseWithCourseRunsSerializerV2 } | { type: "courserun-enrollment"; data: CourseRunEnrollmentV3 } -export type AncestorProgram = { - readable_id: string - enrollment_mode?: string | null -} - /** * Gets the certificate link for a dashboard resource based on its type. */ diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx index 95179cd7f9..76415235d4 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx @@ -6,8 +6,8 @@ import { V3UserProgramEnrollment, V2ProgramRequirement, } from "@mitodl/mitxonline-api-axios/v2" -import type { AncestorProgram } from "./ModuleCard" import { + type AncestorProgram, EnrollmentStatus, getEnrollmentStatus, getKey, diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/helpers.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/helpers.ts index 06ed0f8ce8..8b515535dd 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/helpers.ts +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/helpers.ts @@ -101,6 +101,11 @@ const getProgramEnrollmentStatus = ( return EnrollmentStatus.NotEnrolled } +type AncestorProgram = { + readable_id: string + enrollment_mode?: string | null +} + export { ResourceType, EnrollmentStatus, @@ -110,3 +115,4 @@ export { getEnrollmentStatus, getProgramEnrollmentStatus, } +export type { AncestorProgram } From aefff74350d8598d3af2125a6a2f22d50916de96 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Tue, 24 Mar 2026 20:25:00 -0400 Subject: [PATCH 06/19] remove unnecessary factory overrides in enrollment display test Use factory defaults instead of hardcoded titles and spreading throwaway factory instances. Assert on the factory-generated values instead. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../EnrollmentDisplay.test.tsx | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx index 8958a338e2..dbb45f4f43 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx @@ -239,21 +239,9 @@ describe("EnrollmentDisplay", () => { 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 +259,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) }) From e094fe15e0f2df436ba10946279e1c6718bb1aa8 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Tue, 24 Mar 2026 20:29:18 -0400 Subject: [PATCH 07/19] use EnrollmentModeEnum from API client instead of local redefinition Replace the hand-rolled EnrollmentMode const in ModuleCard with EnrollmentModeEnum from @mitodl/mitxonline-api-axios. Use the same type for AncestorProgram.enrollment_mode in helpers.ts for type safety. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../DashboardPage/CoursewareDisplay/ModuleCard.tsx | 13 ++++--------- .../DashboardPage/CoursewareDisplay/helpers.ts | 3 ++- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ModuleCard.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ModuleCard.tsx index cc0bed75eb..bbd48901d4 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ModuleCard.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ModuleCard.tsx @@ -36,15 +36,10 @@ import { CourseWithCourseRunsSerializerV2, CourseRunEnrollmentV3, 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", @@ -311,7 +306,7 @@ const useEnrollmentHandler = () => { } } else if ( ancestorPrograms?.some( - (p) => p.enrollment_mode === EnrollmentMode.Verified, + (p) => p.enrollment_mode === EnrollmentModeEnum.Verified, ) ) { createVerifiedProgramEnrollment.mutate( @@ -709,7 +704,7 @@ const DashboardCourseCard: React.FC = ({ 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) @@ -774,7 +769,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/helpers.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/helpers.ts index 8b515535dd..0a338f5f6c 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/helpers.ts +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/helpers.ts @@ -1,6 +1,7 @@ import { CourseRunEnrollmentV3, CourseWithCourseRunsSerializerV2, + EnrollmentModeEnum, V3UserProgramEnrollment, } from "@mitodl/mitxonline-api-axios/v2" import { getBestRun } from "@/common/mitxonline" @@ -103,7 +104,7 @@ const getProgramEnrollmentStatus = ( type AncestorProgram = { readable_id: string - enrollment_mode?: string | null + enrollment_mode?: EnrollmentModeEnum | null } export { From ab1776750a67cedffd6c7d913ee1a11d4465ad47 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Tue, 24 Mar 2026 21:05:12 -0400 Subject: [PATCH 08/19] simplify ancestor program props: ProgramAsCourseCard assembles IDs and verified flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace ancestorPrograms array with a cleaner design: - ProgramAsCourseCard accepts optional ancestorProgramEnrollment (the grandparent enrollment, singular) and assembles parentProgramIds + useVerifiedEnrollment from courseProgram.readable_id + ancestor - ModuleCard accepts simple parentProgramIds (string[]) and useVerifiedEnrollment (boolean) — no enrollment objects needed - Remove AncestorProgram type from helpers.ts (no longer needed) This fixes the bug where ancestorPrograms was only populated when courseProgramEnrollment existed — the exact case we don't need it. Now the parent readable_id always comes from courseProgram (the program detail object), which exists regardless of enrollment status. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../CoursewareDisplay/EnrollmentDisplay.tsx | 35 ++++++------------- .../CoursewareDisplay/ModuleCard.tsx | 33 ++++++++--------- .../ProgramAsCourseCard.test.tsx | 19 +++++----- .../CoursewareDisplay/ProgramAsCourseCard.tsx | 22 +++++++++--- .../CoursewareDisplay/helpers.ts | 7 ---- 5 files changed, 53 insertions(+), 63 deletions(-) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx index 8a27ebed9d..e3d9f92516 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx @@ -15,7 +15,6 @@ import { import { Alert } from "@mitodl/smoot-design" import { useQuery } from "@tanstack/react-query" import { - type AncestorProgram, EnrollmentStatus, getEnrollmentStatus, getProgramEnrollmentStatus, @@ -181,6 +180,7 @@ const isProgramAsCourseEnrollment = ( type ProgramAsCourseProgramData = { id: number + readable_id: string title?: string | null start_date?: string | null end_date?: string | null @@ -249,12 +249,6 @@ const EnrollmentExpandCollapse: React.FC = ({ } moduleEnrollmentsByCourseId={enrollmentsByCourseId} courseProgramEnrollment={resource.data} - ancestorPrograms={[ - { - readable_id: resource.data.program.readable_id, - enrollment_mode: resource.data.enrollment_mode, - }, - ]} /> ) } @@ -663,22 +657,6 @@ const ProgramEnrollmentDisplay: React.FC = ({ } if (item.resourceType === "program-as-course") { - const ancestors: AncestorProgram[] = [] - if (item.courseProgramEnrollment) { - ancestors.push({ - readable_id: - item.courseProgramEnrollment.program.readable_id, - enrollment_mode: - item.courseProgramEnrollment.enrollment_mode, - }) - } - if (programEnrollment) { - ancestors.push({ - readable_id: programEnrollment.program.readable_id, - enrollment_mode: programEnrollment.enrollment_mode, - }) - } - return ( = ({ } moduleEnrollmentsByCourseId={enrollmentsByCourseId} courseProgramEnrollment={item.courseProgramEnrollment} - ancestorPrograms={ancestors} + ancestorProgramEnrollment={ + programEnrollment + ? { + readable_id: + programEnrollment.program.readable_id, + enrollment_mode: + programEnrollment.enrollment_mode, + } + : undefined + } /> ) } diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ModuleCard.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ModuleCard.tsx index bbd48901d4..87291cbbe0 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ModuleCard.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ModuleCard.tsx @@ -26,12 +26,7 @@ import { mitxUserQueries } from "api/mitxonline-hooks/user" import { useQuery } from "@tanstack/react-query" import { mitxonlineLegacyUrl } from "@/common/mitxonline" import { useReplaceBasketItem } from "api/mitxonline-hooks/baskets" -import { - EnrollmentStatus, - getBestRun, - getEnrollmentStatus, - type AncestorProgram, -} from "./helpers" +import { EnrollmentStatus, getBestRun, getEnrollmentStatus } from "./helpers" import { CourseWithCourseRunsSerializerV2, CourseRunEnrollmentV3, @@ -268,12 +263,14 @@ const useEnrollmentHandler = () => { courseRun, course, isB2B, - ancestorPrograms, + useVerifiedEnrollment, + parentProgramIds, }: { courseRun: CourseRunV2 course: CourseWithCourseRunsSerializerV2 isB2B?: boolean - ancestorPrograms?: AncestorProgram[] + useVerifiedEnrollment?: boolean + parentProgramIds?: string[] }) => { const readableId = courseRun.courseware_id const href = courseRun.courseware_url @@ -304,15 +301,11 @@ const useEnrollmentHandler = () => { }, ) } - } else if ( - ancestorPrograms?.some( - (p) => p.enrollment_mode === EnrollmentModeEnum.Verified, - ) - ) { + } else if (useVerifiedEnrollment && parentProgramIds?.length) { createVerifiedProgramEnrollment.mutate( { courserun_id: readableId, - request_body: ancestorPrograms.map((p) => p.readable_id), + request_body: parentProgramIds, }, { onSuccess: () => { @@ -567,7 +560,8 @@ type DashboardCardProps = { className?: string variant?: "default" | "stacked" contractId?: number - ancestorPrograms?: AncestorProgram[] + useVerifiedEnrollment?: boolean + parentProgramIds?: string[] onUpgradeError?: (error: string) => void } @@ -667,7 +661,8 @@ const DashboardCourseCard: React.FC = ({ className, variant = "default", contractId, - ancestorPrograms, + useVerifiedEnrollment, + parentProgramIds, onUpgradeError, }) => { const enrollment = useEnrollmentHandler() @@ -719,13 +714,15 @@ const DashboardCourseCard: React.FC = ({ courseRun, course: resource.data, isB2B: !!b2bContractId, - ancestorPrograms, + useVerifiedEnrollment, + parentProgramIds, }) }, [ b2bContractId, enrollment, isCourse, - ancestorPrograms, + useVerifiedEnrollment, + parentProgramIds, resource, courseRun, ]) 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 72a4e8b552..cb1146ff91 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx @@ -202,7 +202,7 @@ describe("ProgramAsCourseCard", () => { expect(rows[1]).toHaveTextContent("Module One") }) - test("clicking 'Start Course' on an unenrolled module uses verified enrollment when an ancestor has verified mode", async () => { + test("clicking 'Start Course' on an unenrolled module uses verified enrollment when ancestor has verified mode", async () => { const run = mitxonline.factories.courses.courseRun({ is_enrollable: true, courseware_url: "https://courses.example.com/run1", @@ -212,7 +212,6 @@ describe("ProgramAsCourseCard", () => { programId: 305, includeProgramEnrollment: false, }) - // Override module courses so we control the run const moduleOne = mitxonline.factories.courses.course({ id: 1, courseruns: [run], @@ -228,10 +227,10 @@ describe("ProgramAsCourseCard", () => { courseProgram={cardData.courseProgram} moduleCourses={[moduleOne, cardData.moduleCourses[1]]} moduleEnrollmentsByCourseId={{}} - ancestorPrograms={[ - { readable_id: "parent-program", enrollment_mode: "audit" }, - { readable_id: "grandparent-program", enrollment_mode: "verified" }, - ]} + ancestorProgramEnrollment={{ + readable_id: "grandparent-program", + enrollment_mode: "verified", + }} />, ) @@ -245,7 +244,10 @@ describe("ProgramAsCourseCard", () => { expect.objectContaining({ method: "POST", url: enrollmentEndpoint, - data: JSON.stringify(["parent-program", "grandparent-program"]), + data: JSON.stringify([ + cardData.courseProgram.readable_id, + "grandparent-program", + ]), }), ) }) @@ -271,9 +273,6 @@ describe("ProgramAsCourseCard", () => { courseProgram={cardData.courseProgram} moduleCourses={[moduleOne, cardData.moduleCourses[1]]} moduleEnrollmentsByCourseId={{}} - ancestorPrograms={[ - { readable_id: "parent-program", enrollment_mode: "audit" }, - ]} />, ) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx index 76415235d4..9e9281edd9 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx @@ -7,7 +7,6 @@ import { V2ProgramRequirement, } from "@mitodl/mitxonline-api-axios/v2" import { - type AncestorProgram, EnrollmentStatus, getEnrollmentStatus, getKey, @@ -247,6 +246,7 @@ const getRelativeDateContent = ( interface ProgramAsCourseCardProps { courseProgram: { id: number + readable_id: string title?: string | null start_date?: string | null end_date?: string | null @@ -256,7 +256,10 @@ interface ProgramAsCourseCardProps { moduleCourses: CourseWithCourseRunsSerializerV2[] moduleEnrollmentsByCourseId: Record courseProgramEnrollment?: V3UserProgramEnrollment - ancestorPrograms?: AncestorProgram[] + ancestorProgramEnrollment?: { + readable_id: string + enrollment_mode?: string | null + } Component?: React.ElementType className?: string } @@ -280,7 +283,7 @@ const ProgramAsCourseCard: React.FC = ({ moduleCourses, moduleEnrollmentsByCourseId, courseProgramEnrollment, - ancestorPrograms, + ancestorProgramEnrollment, Component, className, }) => { @@ -342,6 +345,16 @@ const ProgramAsCourseCard: React.FC = ({ ) const showDatePopoverTrigger = Boolean(datePopoverContent) + const parentProgramIds = [ + courseProgram.readable_id, + ...(ancestorProgramEnrollment + ? [ancestorProgramEnrollment.readable_id] + : []), + ] + const useVerifiedEnrollment = + courseProgramEnrollment?.enrollment_mode === "verified" || + ancestorProgramEnrollment?.enrollment_mode === "verified" + return ( = ({ runId: bestEnrollment?.run.id, })} resource={resource} - ancestorPrograms={ancestorPrograms} + useVerifiedEnrollment={useVerifiedEnrollment} + parentProgramIds={parentProgramIds} variant="stacked" /> ) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/helpers.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/helpers.ts index 0a338f5f6c..06ed0f8ce8 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/helpers.ts +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/helpers.ts @@ -1,7 +1,6 @@ import { CourseRunEnrollmentV3, CourseWithCourseRunsSerializerV2, - EnrollmentModeEnum, V3UserProgramEnrollment, } from "@mitodl/mitxonline-api-axios/v2" import { getBestRun } from "@/common/mitxonline" @@ -102,11 +101,6 @@ const getProgramEnrollmentStatus = ( return EnrollmentStatus.NotEnrolled } -type AncestorProgram = { - readable_id: string - enrollment_mode?: EnrollmentModeEnum | null -} - export { ResourceType, EnrollmentStatus, @@ -116,4 +110,3 @@ export { getEnrollmentStatus, getProgramEnrollmentStatus, } -export type { AncestorProgram } From 41a148d977ba5ec50c2d48db6eedd6d181625649 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Wed, 25 Mar 2026 08:12:07 -0400 Subject: [PATCH 09/19] add some docstrings, minor refactor --- .../CoursewareDisplay/EnrollmentDisplay.tsx | 32 ++++++++----------- .../CoursewareDisplay/ProgramAsCourseCard.tsx | 25 +++++++++++++++ 2 files changed, 38 insertions(+), 19 deletions(-) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx index e3d9f92516..2d23234a4f 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx @@ -739,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({ @@ -759,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/ProgramAsCourseCard.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx index 9e9281edd9..8f4d1b2e6a 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx @@ -244,6 +244,9 @@ const getRelativeDateContent = ( } interface ProgramAsCourseCardProps { + /** + * The courselike program to display. + */ courseProgram: { id: number readable_id: string @@ -253,9 +256,31 @@ interface ProgramAsCourseCardProps { 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 + * - Courslike Program P1a + * - Child Course C1, etc... + * + * Initially, a user will have a verified enrollment in P1 but NOT P2. + * 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 From d2da48c6213998b1258806c50a725e04db537233 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Wed, 25 Mar 2026 08:30:19 -0400 Subject: [PATCH 10/19] remove hardcoded IDs and titles from ProgramAsCourseCard tests Auto-generate program and module IDs inside setupCardData instead of requiring callers to pass arbitrary values. Assert on factory-generated values (cardData.courseProgram.title, cardData.moduleCourses[0].title) instead of hardcoded strings. Add docstring to setupCardData. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ProgramAsCourseCard.test.tsx | 137 ++++++++---------- 1 file changed, 58 insertions(+), 79 deletions(-) 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 cb1146ff91..46c0aee11e 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx @@ -16,46 +16,45 @@ 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], @@ -93,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(), @@ -156,37 +152,23 @@ describe("ProgramAsCourseCard", () => { }) test("renders module rows in req_tree order, not API result order", async () => { + const cardData = setupCardData() + const [moduleOne, moduleTwo] = cardData.moduleCourses + + // Override req_tree to reverse the order: moduleTwo first, then moduleOne const reqTree = new mitxonline.factories.requirements.RequirementTreeBuilder() const modules = reqTree.addOperator({ operator: "all_of", title: "Modules", }) - modules.addCourse({ course: 2 }) - modules.addCourse({ course: 1 }) - - 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()], - }) + modules.addCourse({ course: moduleTwo.id }) + modules.addCourse({ course: moduleOne.id }) - const courseProgram = mitxonline.factories.programs.program({ - id: 304, - title: "Micro Program", - courses: [1, 2], + const courseProgram = { + ...cardData.courseProgram, req_tree: reqTree.serialize(), - }) - - setMockResponse.get( - mitxonline.urls.userMe.get(), - mitxonline.factories.user.user(), - ) + } renderWithProviders( { />, ) - await screen.findByText("Micro Program") + await screen.findByText(courseProgram.title) const rows = await screen.findAllByTestId("enrollment-card-desktop") - expect(rows[0]).toHaveTextContent("Module Two") - expect(rows[1]).toHaveTextContent("Module One") + expect(rows[0]).toHaveTextContent(moduleTwo.title) + expect(rows[1]).toHaveTextContent(moduleOne.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 cardData = setupCardData({ - programId: 305, - includeProgramEnrollment: false, - }) - const moduleOne = mitxonline.factories.courses.course({ - id: 1, + const moduleWithRun = mitxonline.factories.courses.course({ + id: moduleOne.id, courseruns: [run], next_run_id: run.id, }) @@ -225,7 +206,7 @@ describe("ProgramAsCourseCard", () => { renderWithProviders( { ) const cards = await screen.findAllByTestId("enrollment-card-desktop") - const card = cards.find((c) => within(c).queryByText(moduleOne.title)) + const card = cards.find((c) => within(c).queryByText(moduleWithRun.title)) const startButton = within(card!).getByTestId("courseware-button") await user.click(startButton) @@ -254,16 +235,14 @@ describe("ProgramAsCourseCard", () => { }) 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 cardData = setupCardData({ - programId: 306, - includeProgramEnrollment: false, - }) - const moduleOne = mitxonline.factories.courses.course({ - id: 1, + const moduleWithRun = mitxonline.factories.courses.course({ + id: moduleOne.id, courseruns: [run], next_run_id: run.id, }) @@ -271,16 +250,16 @@ describe("ProgramAsCourseCard", () => { renderWithProviders( , ) const cards = await screen.findAllByTestId("enrollment-card-desktop") - const card = cards.find((c) => within(c).queryByText(moduleOne.title)) + const card = cards.find((c) => within(c).queryByText(moduleWithRun.title)) const startButton = within(card!).getByTestId("courseware-button") await user.click(startButton) - await screen.findByRole("dialog", { name: moduleOne.title }) + await screen.findByRole("dialog", { name: moduleWithRun.title }) }) }) From 230ed8a45e6d75ac260ec3ffe70cf882237ae09f Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Wed, 25 Mar 2026 08:32:30 -0400 Subject: [PATCH 11/19] simplify req_tree ordering test to reverse moduleCourses instead of req_tree The test verifies display order follows req_tree, not the moduleCourses array order. Reversing the moduleCourses input is simpler and more directly tests the behavior. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ProgramAsCourseCard.test.tsx | 28 +++++-------------- 1 file changed, 7 insertions(+), 21 deletions(-) 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 46c0aee11e..1cb8fcf7c0 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx @@ -151,37 +151,23 @@ describe("ProgramAsCourseCard", () => { expect(await screen.findByText("Important Dates:")).toBeInTheDocument() }) - test("renders module rows in req_tree order, not API result order", async () => { + test("displays module rows in req_tree order, irrespective of moduleCourses order", async () => { const cardData = setupCardData() const [moduleOne, moduleTwo] = cardData.moduleCourses - // Override req_tree to reverse the order: moduleTwo first, then moduleOne - const reqTree = - new mitxonline.factories.requirements.RequirementTreeBuilder() - const modules = reqTree.addOperator({ - operator: "all_of", - title: "Modules", - }) - modules.addCourse({ course: moduleTwo.id }) - modules.addCourse({ course: moduleOne.id }) - - const courseProgram = { - ...cardData.courseProgram, - req_tree: reqTree.serialize(), - } - renderWithProviders( , ) - await screen.findByText(courseProgram.title) + await screen.findByText(cardData.courseProgram.title) const rows = await screen.findAllByTestId("enrollment-card-desktop") - expect(rows[0]).toHaveTextContent(moduleTwo.title) - expect(rows[1]).toHaveTextContent(moduleOne.title) + // 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 () => { From e13bc5bcee7bec9f03926d590c0e72727196d5ad Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Wed, 25 Mar 2026 09:27:27 -0400 Subject: [PATCH 12/19] improve test quality: add integration test, remove unnecessary overrides, use invariant - Add integration test for grandparent verified enrollment flowing through EnrollmentDisplay -> ProgramAsCourseCard -> ModuleCard, asserting both parent and grandparent readable_ids in POST body - Split verified enrollment test into two: regular course (one program ID) and program-as-course module (two program IDs) - Extract setupProgramDashboardVerifiedEnrollmentScenario helper (API setup only, no render) - Remove unnecessary grades: [] factory override in ProgramAsCourseCard tests - Remove hardcoded IDs and titles in EnrollmentDisplay verified enrollment test - Replace card\! non-null assertions with invariant() for clearer failure messages Co-Authored-By: Claude Opus 4.6 (1M context) --- .../EnrollmentDisplay.test.tsx | 232 ++++++++++++++---- .../ProgramAsCourseCard.test.tsx | 14 +- 2 files changed, 189 insertions(+), 57 deletions(-) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx index dbb45f4f43..917e6190dd 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx @@ -14,6 +14,7 @@ import { mockAxiosInstance } from "api/test-utils" import { useFeatureFlagEnabled } from "posthog-js/react" import { setupEnrollments } from "./test-utils" import { faker } from "@faker-js/faker/locale/en" +import invariant from "tiny-invariant" jest.mock("posthog-js/react") const mockedUseFeatureFlagEnabled = jest @@ -1306,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 @@ -1429,81 +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(), + }) + + // 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 programEnrollment = + 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 = - mitxonline.urls.verifiedProgramEnrollments.create(run.courseware_id) - setMockResponse.post(programEnrollmentEndpoint, {}) + // Mock verified enrollment endpoints for both course runs + const childCourseEnrollmentEndpoint = + mitxonline.urls.verifiedProgramEnrollments.create( + childCourseRun.courseware_id, + ) + 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]), + }), + ) + }) + + 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() - // Wait for the card to load + renderWithProviders() + + await screen.findByText("Program Requirements") await waitFor( () => { const skeletons = screen.queryAllByTestId("skeleton") @@ -1512,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/ProgramAsCourseCard.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx index 1cb8fcf7c0..6d89ec398d 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx @@ -11,6 +11,7 @@ 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", () => { @@ -60,7 +61,6 @@ describe("ProgramAsCourseCard", () => { ...moduleOne.courseruns[0], course: moduleOne, }, - grades: [], certificate: null, }) @@ -203,7 +203,11 @@ describe("ProgramAsCourseCard", () => { const cards = await screen.findAllByTestId("enrollment-card-desktop") const card = cards.find((c) => within(c).queryByText(moduleWithRun.title)) - const startButton = within(card!).getByTestId("courseware-button") + invariant( + card, + `Expected to find a card containing "${moduleWithRun.title}"`, + ) + const startButton = within(card).getByTestId("courseware-button") await user.click(startButton) await waitFor(() => { @@ -243,7 +247,11 @@ describe("ProgramAsCourseCard", () => { const cards = await screen.findAllByTestId("enrollment-card-desktop") const card = cards.find((c) => within(c).queryByText(moduleWithRun.title)) - const startButton = within(card!).getByTestId("courseware-button") + 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 }) From fb303ba98109fe613de063c4b7506cfd5d900e40 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Wed, 25 Mar 2026 10:07:45 -0400 Subject: [PATCH 13/19] fix typo: Courslike -> Courselike in docstring Co-Authored-By: Claude Opus 4.6 (1M context) --- .../DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx index 8f4d1b2e6a..46a7b79cbe 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx @@ -274,7 +274,7 @@ interface ProgramAsCourseCardProps { * * This facilitates verified enrollments. For example: * - Ancestor Program P1 - * - Courslike Program P1a + * - Courselike Program P1a * - Child Course C1, etc... * * Initially, a user will have a verified enrollment in P1 but NOT P2. From 07df11a68803de2d9b56af42759d284ad88e266b Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Wed, 25 Mar 2026 10:18:40 -0400 Subject: [PATCH 14/19] add isVerifiedEnrollmentMode helper --- .../CoursewareDisplay/ProgramAsCourseCard.tsx | 12 ++++++++---- frontends/main/src/common/mitxonline.ts | 12 +++++++++++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx index 46a7b79cbe..0bb542c644 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", @@ -376,9 +379,10 @@ const ProgramAsCourseCard: React.FC = ({ ? [ancestorProgramEnrollment.readable_id] : []), ] - const useVerifiedEnrollment = - courseProgramEnrollment?.enrollment_mode === "verified" || - ancestorProgramEnrollment?.enrollment_mode === "verified" + const useVerifiedEnrollment = [ + courseProgramEnrollment?.enrollment_mode, + ancestorProgramEnrollment?.enrollment_mode, + ].some(isVerifiedEnrollmentMode) return ( 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 } From 05f205eb7868ec7317423c55e3bc229cf676e860 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Wed, 25 Mar 2026 10:34:44 -0400 Subject: [PATCH 15/19] use isVerifiedEnrollmentMode in legacy DashboardCard Replace local EnrollmentMode constant with the shared isVerifiedEnrollmentMode helper from @/common/mitxonline, consistent with ModuleCard and ProgramAsCourseCard. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../CoursewareDisplay/DashboardCard.tsx | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx index dc12fd7773..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", @@ -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 ? ( Date: Wed, 25 Mar 2026 10:35:01 -0400 Subject: [PATCH 16/19] fix typo --- .../DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx index 0bb542c644..f3a4cf8d2c 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx @@ -280,7 +280,7 @@ interface ProgramAsCourseCardProps { * - Courselike Program P1a * - Child Course C1, etc... * - * Initially, a user will have a verified enrollment in P1 but NOT P2. + * 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. */ From 761533ff5a90342df0343723c2e4a9c434bfb748 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Wed, 25 Mar 2026 10:39:12 -0400 Subject: [PATCH 17/19] update client --- frontends/api/package.json | 2 +- frontends/main/package.json | 2 +- yarn.lock | 12 ++++++------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/frontends/api/package.json b/frontends/api/package.json index 1d70ecac94..434480f041 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": "https://github.com/mitodl/mitxonline-api-clients/raw/83ae19dce4aa041d52bcb854efcec5f4fa9bd013/src/typescript/mitxonline-api-axios/package.tgz", + "@mitodl/mitxonline-api-axios": "https://github.com/mitodl/mitxonline-api-clients/raw/e9983c59c9d1db15a1c6f7cbd68f35dd041f8edc/src/typescript/mitxonline-api-axios/package.tgz", "@tanstack/react-query": "^5.66.0", "axios": "^1.12.2", "tiny-invariant": "^1.3.3" diff --git a/frontends/main/package.json b/frontends/main/package.json index dba9b8e111..79bc254da7 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": "https://github.com/mitodl/mitxonline-api-clients/raw/83ae19dce4aa041d52bcb854efcec5f4fa9bd013/src/typescript/mitxonline-api-axios/package.tgz", + "@mitodl/mitxonline-api-axios": "https://github.com/mitodl/mitxonline-api-clients/raw/e9983c59c9d1db15a1c6f7cbd68f35dd041f8edc/src/typescript/mitxonline-api-axios/package.tgz", "@mitodl/smoot-design": "^6.24.0", "@mui/material": "^6.4.5", "@mui/material-nextjs": "^6.4.3", diff --git a/yarn.lock b/yarn.lock index 477523766d..f829bece26 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3514,13 +3514,13 @@ __metadata: languageName: node linkType: hard -"@mitodl/mitxonline-api-axios@https://github.com/mitodl/mitxonline-api-clients/raw/83ae19dce4aa041d52bcb854efcec5f4fa9bd013/src/typescript/mitxonline-api-axios/package.tgz": - version: 2026.3.9 - resolution: "@mitodl/mitxonline-api-axios@https://github.com/mitodl/mitxonline-api-clients/raw/83ae19dce4aa041d52bcb854efcec5f4fa9bd013/src/typescript/mitxonline-api-axios/package.tgz" +"@mitodl/mitxonline-api-axios@https://github.com/mitodl/mitxonline-api-clients/raw/e9983c59c9d1db15a1c6f7cbd68f35dd041f8edc/src/typescript/mitxonline-api-axios/package.tgz": + version: 2026.3.24 + resolution: "@mitodl/mitxonline-api-axios@https://github.com/mitodl/mitxonline-api-clients/raw/e9983c59c9d1db15a1c6f7cbd68f35dd041f8edc/src/typescript/mitxonline-api-axios/package.tgz" dependencies: "@types/node": "npm:^20.11.19" axios: "npm:^1.6.5" - checksum: 10/fc3edfc0899082e48517a4f8a1c7855247b78498e1dc49e6b31fbf2b42025b122782a3d67de18e742f49e416efc3ae3954cf510a71f68c2698071460caacd62b + checksum: 10/25d81563d9ebc519fb4e1f9c72d9631c63d7a3f12e9e59aa9aa0ba29fd1e261ecbe60b19f3f50b8a6d6b8747e923d20d969473ac5d1b3d27de11276821b1751a 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": "https://github.com/mitodl/mitxonline-api-clients/raw/83ae19dce4aa041d52bcb854efcec5f4fa9bd013/src/typescript/mitxonline-api-axios/package.tgz" + "@mitodl/mitxonline-api-axios": "https://github.com/mitodl/mitxonline-api-clients/raw/e9983c59c9d1db15a1c6f7cbd68f35dd041f8edc/src/typescript/mitxonline-api-axios/package.tgz" "@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": "https://github.com/mitodl/mitxonline-api-clients/raw/83ae19dce4aa041d52bcb854efcec5f4fa9bd013/src/typescript/mitxonline-api-axios/package.tgz" + "@mitodl/mitxonline-api-axios": "https://github.com/mitodl/mitxonline-api-clients/raw/e9983c59c9d1db15a1c6f7cbd68f35dd041f8edc/src/typescript/mitxonline-api-axios/package.tgz" "@mitodl/smoot-design": "npm:^6.24.0" "@mui/material": "npm:^6.4.5" "@mui/material-nextjs": "npm:^6.4.3" From 68dfe262dfef706bc407418aecdfd2a292f59106 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Wed, 25 Mar 2026 10:47:51 -0400 Subject: [PATCH 18/19] bump client --- frontends/api/package.json | 2 +- frontends/main/package.json | 2 +- yarn.lock | 12 ++++++------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/frontends/api/package.json b/frontends/api/package.json index 434480f041..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": "https://github.com/mitodl/mitxonline-api-clients/raw/e9983c59c9d1db15a1c6f7cbd68f35dd041f8edc/src/typescript/mitxonline-api-axios/package.tgz", + "@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/main/package.json b/frontends/main/package.json index 79bc254da7..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": "https://github.com/mitodl/mitxonline-api-clients/raw/e9983c59c9d1db15a1c6f7cbd68f35dd041f8edc/src/typescript/mitxonline-api-axios/package.tgz", + "@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/yarn.lock b/yarn.lock index f829bece26..2052d9db39 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3514,13 +3514,13 @@ __metadata: languageName: node linkType: hard -"@mitodl/mitxonline-api-axios@https://github.com/mitodl/mitxonline-api-clients/raw/e9983c59c9d1db15a1c6f7cbd68f35dd041f8edc/src/typescript/mitxonline-api-axios/package.tgz": - version: 2026.3.24 - resolution: "@mitodl/mitxonline-api-axios@https://github.com/mitodl/mitxonline-api-clients/raw/e9983c59c9d1db15a1c6f7cbd68f35dd041f8edc/src/typescript/mitxonline-api-axios/package.tgz" +"@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/25d81563d9ebc519fb4e1f9c72d9631c63d7a3f12e9e59aa9aa0ba29fd1e261ecbe60b19f3f50b8a6d6b8747e923d20d969473ac5d1b3d27de11276821b1751a + 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": "https://github.com/mitodl/mitxonline-api-clients/raw/e9983c59c9d1db15a1c6f7cbd68f35dd041f8edc/src/typescript/mitxonline-api-axios/package.tgz" + "@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": "https://github.com/mitodl/mitxonline-api-clients/raw/e9983c59c9d1db15a1c6f7cbd68f35dd041f8edc/src/typescript/mitxonline-api-axios/package.tgz" + "@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" From 3985b0217a05ddee0db5398c0c47f4dce4952ad3 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Wed, 25 Mar 2026 13:22:23 -0400 Subject: [PATCH 19/19] invalidate program enrollments, too --- frontends/api/src/mitxonline/hooks/enrollment/index.ts | 3 +++ 1 file changed, 3 insertions(+) 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(), + }) }, }) }