From d309a524e7776159e0fd113466a3ec8f1738990a Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Tue, 10 Feb 2026 10:52:29 -0500 Subject: [PATCH 01/10] update smoot-design for new alerts (#2933) * update smoot-design for new alerts * more smoot bumps * whoopsie-daisy * whooooopsie * remove an unnecessary element * fix a test * pin smoot to a real version --- frontends/main/package.json | 2 +- .../CoursewareDisplay/DashboardDialogs.tsx | 6 ++--- .../CoursewareDisplay/EnrollmentDisplay.tsx | 12 +++++----- .../DashboardPage/HomeContent.test.tsx | 4 ++-- .../app-pages/DashboardPage/HomeContent.tsx | 22 +++++++++---------- frontends/ol-components/package.json | 2 +- yarn.lock | 12 +++++----- 7 files changed, 27 insertions(+), 33 deletions(-) diff --git a/frontends/main/package.json b/frontends/main/package.json index 17df39126d..77ba512620 100644 --- a/frontends/main/package.json +++ b/frontends/main/package.json @@ -15,7 +15,7 @@ "@floating-ui/react": "^0.27.16", "@mitodl/course-search-utils": "^3.5.0", "@mitodl/mitxonline-api-axios": "^2026.1.14", - "@mitodl/smoot-design": "^6.23.0", + "@mitodl/smoot-design": "^6.24.0", "@mui/material": "^6.4.5", "@mui/material-nextjs": "^6.4.3", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardDialogs.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardDialogs.tsx index a95bf2ffbd..9dd86ba994 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardDialogs.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardDialogs.tsx @@ -100,10 +100,8 @@ const EmailSettingsDialogInner: React.FC = ({ Update your email preferences for {title}. - - Unchecking the box will prevent you from receiving important course - updates and emails. - + Unchecking the box will prevent you from receiving important course + updates and emails. { closable={true} onClose={() => setUpgradeError(null)} > - - {upgradeError}{" "} - - Contact Support - {" "} - for assistance. - + {upgradeError}{" "} + + Contact Support + {" "} + for assistance. )} { name: "Your MIT Learning Journey", }) - expect(screen.queryByText("Enrollment Error")).not.toBeInTheDocument() + expect(screen.queryByText(/Enrollment Error/)).not.toBeInTheDocument() }) test("Displays enrollment error alert when query param is present and then clears it", async () => { @@ -275,7 +275,7 @@ describe("HomeContent", () => { }) // Verify the alert was shown - expect(screen.getByText("Enrollment Error")).toBeInTheDocument() + expect(screen.getByText(/Enrollment Error/)).toBeInTheDocument() expect( screen.getByText( /The Enrollment Code is incorrect or no longer available/, diff --git a/frontends/main/src/app-pages/DashboardPage/HomeContent.tsx b/frontends/main/src/app-pages/DashboardPage/HomeContent.tsx index 8b4e022d08..9b0858be87 100644 --- a/frontends/main/src/app-pages/DashboardPage/HomeContent.tsx +++ b/frontends/main/src/app-pages/DashboardPage/HomeContent.tsx @@ -114,18 +114,16 @@ const HomeContent: React.FC = () => { {showEnrollmentError && ( - - - Enrollment Error - - - {" - "} - The Enrollment Code is incorrect or no longer available.{" "} - - Contact Support - {" "} - for assistance. - + + The Enrollment Code is incorrect or no longer available.{" "} + + Contact Support + {" "} + for assistance. )} diff --git a/frontends/ol-components/package.json b/frontends/ol-components/package.json index f8685406b3..5e2f625c93 100644 --- a/frontends/ol-components/package.json +++ b/frontends/ol-components/package.json @@ -67,7 +67,7 @@ "typescript": "^5.5.4" }, "peerDependencies": { - "@mitodl/smoot-design": "^6.23.0", + "@mitodl/smoot-design": "^6.24.0", "next": "^16.1.6" } } diff --git a/yarn.lock b/yarn.lock index b5dc65dcaf..bbb5d8ba05 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3524,9 +3524,9 @@ __metadata: languageName: node linkType: hard -"@mitodl/smoot-design@npm:^6.23.0": - version: 6.23.0 - resolution: "@mitodl/smoot-design@npm:6.23.0" +"@mitodl/smoot-design@npm:^6.24.0": + version: 6.24.0 + resolution: "@mitodl/smoot-design@npm:6.24.0" dependencies: "@ai-sdk/react": "npm:1.2.12" "@emotion/cache": "npm:^11.14.0" @@ -3551,7 +3551,7 @@ __metadata: "@remixicon/react": ^4.2.0 react: ^18 || ^19 react-dom: ^18 || ^19 - checksum: 10/145082fa7afff17fd76610f4a1d217fe5f687aa8b7eea87ece225d36135ecbb5b40de5bbbc262a0d0042759cff4ca0c3d681ddacea40f86445f0e3e89840e8ea + checksum: 10/ca8c5a25939e80f70d65a2a44056a2a1ecd778f298ac0153fa8e2b1861950280e17f8c33d4acfdc28967e29481ac112eecf7a80389431a47faa0f52c54ae1c11 languageName: node linkType: hard @@ -16015,7 +16015,7 @@ __metadata: "@happy-dom/jest-environment": "npm:^20.1.0" "@mitodl/course-search-utils": "npm:^3.5.0" "@mitodl/mitxonline-api-axios": "npm:^2026.1.14" - "@mitodl/smoot-design": "npm:^6.23.0" + "@mitodl/smoot-design": "npm:^6.24.0" "@mui/material": "npm:^6.4.5" "@mui/material-nextjs": "npm:^6.4.3" "@radix-ui/react-dropdown-menu": "npm:^2.1.16" @@ -17614,7 +17614,7 @@ __metadata: validator: "npm:^13.11.0" wheel-indicator: "npm:^1.3.0" peerDependencies: - "@mitodl/smoot-design": ^6.23.0 + "@mitodl/smoot-design": ^6.24.0 next: ^16.1.6 languageName: unknown linkType: soft From 79c50510c629ce29dc23deaf9922644d20bbbfaa Mon Sep 17 00:00:00 2001 From: Nathan Levesque Date: Tue, 10 Feb 2026 15:00:05 -0500 Subject: [PATCH 02/10] Switch to v3 for program enrollments (#2931) * Switch to v3 for program enrollments * Feedback * Feedback * Fix tests --- frontends/api/package.json | 2 +- .../mitxonline/hooks/enrollment/queries.ts | 6 +- .../test-utils/factories/enrollment.ts | 15 ++- .../test-utils/factories/programs.ts | 19 +++- .../api/src/mitxonline/test-utils/urls.ts | 3 +- frontends/main/package.json | 2 +- .../DashboardPage/ContractContent.test.tsx | 10 +- .../DashboardPage/ContractContent.tsx | 4 +- .../CoursewareDisplay/DashboardCard.test.tsx | 8 +- .../CoursewareDisplay/DashboardCard.tsx | 4 +- .../DashboardDialogs.test.tsx | 2 +- .../EnrollmentDisplay.test.tsx | 104 +++++++++--------- .../CoursewareDisplay/EnrollmentDisplay.tsx | 4 +- .../CoursewareDisplay/test-utils.ts | 3 +- .../DashboardPage/HomeContent.test.tsx | 2 +- .../OrganizationRedirect.test.tsx | 2 +- .../ProductPages/ProductSummary.test.tsx | 5 + .../ProgramEnrollmentButton.test.tsx | 12 +- .../ProductPages/ProgramPage.test.tsx | 2 +- .../organization/[orgSlug]/page.test.tsx | 1 - yarn.lock | 12 +- 21 files changed, 116 insertions(+), 106 deletions(-) diff --git a/frontends/api/package.json b/frontends/api/package.json index 08f90d5272..c0d5ef226b 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.1.14", + "@mitodl/mitxonline-api-axios": "^2026.2.5", "@tanstack/react-query": "^5.66.0", "axios": "^1.12.2", "tiny-invariant": "^1.3.3" diff --git a/frontends/api/src/mitxonline/hooks/enrollment/queries.ts b/frontends/api/src/mitxonline/hooks/enrollment/queries.ts index 2110e1206e..c7c8881773 100644 --- a/frontends/api/src/mitxonline/hooks/enrollment/queries.ts +++ b/frontends/api/src/mitxonline/hooks/enrollment/queries.ts @@ -1,7 +1,7 @@ import { queryOptions } from "@tanstack/react-query" import type { CourseRunEnrollmentRequestV2, - V2UserProgramEnrollmentDetail, + V3UserProgramEnrollment, } from "@mitodl/mitxonline-api-axios/v2" import { courseRunEnrollmentsApi, programEnrollmentsApi } from "../../clients" @@ -35,9 +35,9 @@ const enrollmentQueries = { programEnrollmentsList: (opts?: RawAxiosRequestConfig) => queryOptions({ queryKey: enrollmentKeys.programEnrollmentsList(opts), - queryFn: async (): Promise => { + queryFn: async (): Promise => { return programEnrollmentsApi - .v2ProgramEnrollmentsList(opts) + .v3ProgramEnrollmentsList(opts) .then((res) => res.data) }, }), diff --git a/frontends/api/src/mitxonline/test-utils/factories/enrollment.ts b/frontends/api/src/mitxonline/test-utils/factories/enrollment.ts index aac16f82f6..f19d91bbf7 100644 --- a/frontends/api/src/mitxonline/test-utils/factories/enrollment.ts +++ b/frontends/api/src/mitxonline/test-utils/factories/enrollment.ts @@ -6,7 +6,7 @@ import type { CourseRunEnrollmentRequestV2, CourseRunGrade, UserProgramEnrollmentDetail, - V2UserProgramEnrollmentDetail, + V3UserProgramEnrollment, } from "@mitodl/mitxonline-api-axios/v2" import { UniqueEnforcer } from "enforce-unique" import { factories } from ".." @@ -194,12 +194,12 @@ const programEnrollment: PartialFactory = ( return mergeOverrides(defaults, overrides) } -const programEnrollmentV2: PartialFactory = ( +const programEnrollmentV3: PartialFactory = ( overrides = {}, -): V2UserProgramEnrollmentDetail => { - const program = factories.programs.program() +): V3UserProgramEnrollment => { + const program = factories.programs.simpleProgram() const hasCertificate = faker.datatype.boolean() - const defaults: V2UserProgramEnrollmentDetail = { + const defaults: V3UserProgramEnrollment = { certificate: hasCertificate ? { uuid: faker.string.uuid(), @@ -207,9 +207,8 @@ const programEnrollmentV2: PartialFactory = ( } : null, program: program, - enrollments: [courseEnrollment()], } - return mergeOverrides(defaults, overrides) + return mergeOverrides(defaults, overrides) } // Not paginated @@ -222,5 +221,5 @@ export { courseEnrollments, grade, programEnrollment, - programEnrollmentV2, + programEnrollmentV3, } diff --git a/frontends/api/src/mitxonline/test-utils/factories/programs.ts b/frontends/api/src/mitxonline/test-utils/factories/programs.ts index 6235598c37..19a4c91c3c 100644 --- a/frontends/api/src/mitxonline/test-utils/factories/programs.ts +++ b/frontends/api/src/mitxonline/test-utils/factories/programs.ts @@ -3,6 +3,7 @@ import type { PartialFactory } from "ol-test-utilities" import type { V2Program, V2ProgramCollection, + V3SimpleProgram, } from "@mitodl/mitxonline-api-axios/v2" import { faker } from "@faker-js/faker/locale/en" import { UniqueEnforcer } from "enforce-unique" @@ -105,4 +106,20 @@ const programCollection: PartialFactory = ( return mergeOverrides(defaults, overrides) } -export { program, programs, programCollection } +const simpleProgram: PartialFactory = (overrides = {}) => { + const defaults: V3SimpleProgram = { + id: uniqueProgramId.enforce(() => faker.number.int()), + title: faker.lorem.words(3), + readable_id: faker.lorem.slug(), + program_type: faker.helpers.arrayElement([ + "certificate", + "degree", + "diploma", + ]), + live: faker.datatype.boolean(), + } + + return mergeOverrides(defaults, overrides) +} + +export { program, programs, programCollection, simpleProgram } diff --git a/frontends/api/src/mitxonline/test-utils/urls.ts b/frontends/api/src/mitxonline/test-utils/urls.ts index 5eaef27397..504b470727 100644 --- a/frontends/api/src/mitxonline/test-utils/urls.ts +++ b/frontends/api/src/mitxonline/test-utils/urls.ts @@ -25,8 +25,7 @@ const enrollment = { } const programEnrollments = { - enrollmentsList: () => `${API_BASE_URL}/api/v1/program_enrollments/`, - enrollmentsListV2: () => `${API_BASE_URL}/api/v2/program_enrollments/`, + enrollmentsListV3: () => `${API_BASE_URL}/api/v3/program_enrollments/`, } const b2b = { diff --git a/frontends/main/package.json b/frontends/main/package.json index 77ba512620..1bd6c11b59 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.0", - "@mitodl/mitxonline-api-axios": "^2026.1.14", + "@mitodl/mitxonline-api-axios": "^2026.2.5", "@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/ContractContent.test.tsx b/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx index 14bdd26574..a407ef87f7 100644 --- a/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx @@ -25,8 +25,7 @@ const makeGrade = factories.enrollment.grade describe("ContractContent", () => { beforeEach(() => { setMockResponse.get(urls.enrollment.enrollmentsListV2(), []) - setMockResponse.get(urls.programEnrollments.enrollmentsList(), []) - setMockResponse.get(urls.programEnrollments.enrollmentsListV2(), []) + setMockResponse.get(urls.programEnrollments.enrollmentsListV3(), []) setMockResponse.get(urls.contracts.contractsList(), []) }) @@ -695,7 +694,7 @@ describe("ContractContent", () => { url: "/certificate/program/cert-123", }, } - const programEnrollment = factories.enrollment.programEnrollmentV2({ + const programEnrollment = factories.enrollment.programEnrollmentV3({ program: programWithCertificate, certificate: { uuid: programWithCertificate.certificate.uuid, @@ -726,10 +725,7 @@ describe("ContractContent", () => { results: coursesA, }, ) - setMockResponse.get(urls.programEnrollments.enrollmentsList(), [ - programEnrollment, - ]) - setMockResponse.get(urls.programEnrollments.enrollmentsListV2(), [ + setMockResponse.get(urls.programEnrollments.enrollmentsListV3(), [ programEnrollment, ]) diff --git a/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx b/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx index 332e6a3596..4efc7de605 100644 --- a/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx +++ b/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx @@ -22,11 +22,11 @@ import { import graduateLogo from "@/public/images/dashboard/graduate.png" import { CourseRunEnrollmentRequestV2, - V2UserProgramEnrollmentDetail, ContractPage, OrganizationPage, V2ProgramCollection, V2Program, + V3UserProgramEnrollment, } from "@mitodl/mitxonline-api-axios/v2" import { mitxUserQueries } from "api/mitxonline-hooks/user" import { ButtonLink } from "@mitodl/smoot-design" @@ -350,7 +350,7 @@ const OrgProgramDisplay: React.FC<{ program: V2Program contract?: ContractPage courseRunEnrollments?: CourseRunEnrollmentRequestV2[] - programEnrollments?: V2UserProgramEnrollmentDetail[] + programEnrollments?: V3UserProgramEnrollment[] programLoading: boolean orgId: number }> = ({ 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 0f28792bf5..d36e5ad939 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.test.tsx @@ -1303,8 +1303,8 @@ describe.each([ test("renders program card with title", () => { setupUserApis() const programEnrollment = - mitxonline.factories.enrollment.programEnrollmentV2({ - program: mitxonline.factories.programs.program({ + mitxonline.factories.enrollment.programEnrollmentV3({ + program: mitxonline.factories.programs.simpleProgram({ title: "Test Program Title", }), }) @@ -1326,8 +1326,8 @@ describe.each([ test("program card does not show course-specific elements", () => { setupUserApis() const programEnrollment = - mitxonline.factories.enrollment.programEnrollmentV2({ - program: mitxonline.factories.programs.program({ + mitxonline.factories.enrollment.programEnrollmentV3({ + program: mitxonline.factories.programs.simpleProgram({ title: "Test Program", }), }) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx index 0d8428e3ec..ef6ba88a40 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx @@ -34,7 +34,7 @@ import { EnrollmentStatus, getBestRun, getEnrollmentStatus } from "./helpers" import { CourseWithCourseRunsSerializerV2, CourseRunEnrollmentRequestV2, - V2UserProgramEnrollmentDetail, + V3UserProgramEnrollment, } from "@mitodl/mitxonline-api-axios/v2" const EnrollmentMode = { @@ -53,7 +53,7 @@ export type DashboardType = (typeof DashboardType)[keyof typeof DashboardType] type DashboardResource = | { type: "course"; data: CourseWithCourseRunsSerializerV2 } | { type: "courserun-enrollment"; data: CourseRunEnrollmentRequestV2 } - | { type: "program-enrollment"; data: V2UserProgramEnrollmentDetail } + | { type: "program-enrollment"; data: V3UserProgramEnrollment } /** * Gets the certificate link for a dashboard resource based on its type. diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardDialogs.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardDialogs.test.tsx index 9119e310b8..1a378398a3 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardDialogs.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardDialogs.test.tsx @@ -41,7 +41,7 @@ describe("DashboardDialogs", () => { enrollments, ) setMockResponse.get( - mitxonline.urls.programEnrollments.enrollmentsListV2(), + mitxonline.urls.programEnrollments.enrollmentsListV3(), [], ) setMockResponse.get(mitxonline.urls.contracts.contractsList(), []) 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 3a27dd4731..054862eca3 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx @@ -30,7 +30,7 @@ describe("EnrollmentDisplay", () => { enrollments, ) setMockResponse.get( - mitxonline.urls.programEnrollments.enrollmentsListV2(), + mitxonline.urls.programEnrollments.enrollmentsListV3(), [], ) setMockResponse.get(mitxonline.urls.contracts.contractsList(), []) @@ -112,18 +112,11 @@ describe("EnrollmentDisplay", () => { setMockResponse.get(mitxonline.urls.userMe.get(), mitxOnlineUser) const programEnrollment = - mitxonline.factories.enrollment.programEnrollmentV2({ + mitxonline.factories.enrollment.programEnrollmentV3({ program: { - ...mitxonline.factories.programs.program(), + ...mitxonline.factories.programs.simpleProgram(), title: "My Test Program", }, - enrollments: [ - { - ...mitxonline.factories.enrollment.courseEnrollment(), - b2b_contract_id: null, - b2b_organization_id: null, - }, - ], }) mockedUseFeatureFlagEnabled.mockReturnValue(true) @@ -138,7 +131,7 @@ describe("EnrollmentDisplay", () => { }), ]) setMockResponse.get( - mitxonline.urls.programEnrollments.enrollmentsListV2(), + mitxonline.urls.programEnrollments.enrollmentsListV3(), [programEnrollment], ) setMockResponse.get(mitxonline.urls.contracts.contractsList(), []) @@ -167,18 +160,11 @@ describe("EnrollmentDisplay", () => { }, }) const programEnrollment = - mitxonline.factories.enrollment.programEnrollmentV2({ + mitxonline.factories.enrollment.programEnrollmentV3({ program: { - ...mitxonline.factories.programs.program(), + ...mitxonline.factories.programs.simpleProgram(), title: "My Test Program", }, - enrollments: [ - { - ...mitxonline.factories.enrollment.courseEnrollment(), - b2b_contract_id: null, - b2b_organization_id: null, - }, - ], }) mockedUseFeatureFlagEnabled.mockReturnValue(true) @@ -186,7 +172,7 @@ describe("EnrollmentDisplay", () => { courseEnrollment, ]) setMockResponse.get( - mitxonline.urls.programEnrollments.enrollmentsListV2(), + mitxonline.urls.programEnrollments.enrollmentsListV3(), [programEnrollment], ) setMockResponse.get(mitxonline.urls.contracts.contractsList(), []) @@ -212,7 +198,7 @@ describe("EnrollmentDisplay", () => { mockedUseFeatureFlagEnabled.mockReturnValue(true) setMockResponse.get(mitxonline.urls.enrollment.enrollmentsListV2(), []) setMockResponse.get( - mitxonline.urls.programEnrollments.enrollmentsListV2(), + mitxonline.urls.programEnrollments.enrollmentsListV3(), [], ) setMockResponse.get(mitxonline.urls.contracts.contractsList(), []) @@ -237,33 +223,19 @@ describe("EnrollmentDisplay", () => { setMockResponse.get(mitxonline.urls.userMe.get(), mitxOnlineUser) const b2bProgramEnrollment = - mitxonline.factories.enrollment.programEnrollmentV2({ + mitxonline.factories.enrollment.programEnrollmentV3({ program: { - ...mitxonline.factories.programs.program(), + ...mitxonline.factories.programs.simpleProgram(), title: "B2B Program", }, - enrollments: [ - { - ...mitxonline.factories.enrollment.courseEnrollment(), - b2b_contract_id: 123, - b2b_organization_id: 456, - }, - ], }) const nonB2BProgramEnrollment = - mitxonline.factories.enrollment.programEnrollmentV2({ + mitxonline.factories.enrollment.programEnrollmentV3({ program: { - ...mitxonline.factories.programs.program(), + ...mitxonline.factories.programs.simpleProgram(), title: "Personal Program", }, - enrollments: [ - { - ...mitxonline.factories.enrollment.courseEnrollment(), - b2b_contract_id: null, - b2b_organization_id: null, - }, - ], }) mockedUseFeatureFlagEnabled.mockReturnValue(true) @@ -278,7 +250,7 @@ describe("EnrollmentDisplay", () => { }), ]) setMockResponse.get( - mitxonline.urls.programEnrollments.enrollmentsListV2(), + mitxonline.urls.programEnrollments.enrollmentsListV3(), [b2bProgramEnrollment, nonB2BProgramEnrollment], ) // Mock contracts to filter out the B2B program @@ -312,10 +284,16 @@ describe("EnrollmentDisplay", () => { mockedUseFeatureFlagEnabled.mockReturnValue(true) setMockResponse.get(mitxonline.urls.enrollment.enrollmentsListV2(), []) setMockResponse.get( - mitxonline.urls.programEnrollments.enrollmentsListV2(), + mitxonline.urls.programEnrollments.enrollmentsListV3(), [ - mitxonline.factories.enrollment.programEnrollmentV2({ - program: program, + mitxonline.factories.enrollment.programEnrollmentV3({ + program: { + id: program.id, + title: program.title, + live: program.live, + program_type: program.program_type, + readable_id: program.readable_id, + }, }), ], ) @@ -368,10 +346,16 @@ describe("EnrollmentDisplay", () => { mockedUseFeatureFlagEnabled.mockReturnValue(true) setMockResponse.get(mitxonline.urls.enrollment.enrollmentsListV2(), []) setMockResponse.get( - mitxonline.urls.programEnrollments.enrollmentsListV2(), + mitxonline.urls.programEnrollments.enrollmentsListV3(), [ - mitxonline.factories.enrollment.programEnrollmentV2({ - program: program, + mitxonline.factories.enrollment.programEnrollmentV3({ + program: { + id: program.id, + title: program.title, + live: program.live, + program_type: program.program_type, + readable_id: program.readable_id, + }, }), ], ) @@ -437,10 +421,16 @@ describe("EnrollmentDisplay", () => { mockedUseFeatureFlagEnabled.mockReturnValue(true) setMockResponse.get(mitxonline.urls.enrollment.enrollmentsListV2(), []) setMockResponse.get( - mitxonline.urls.programEnrollments.enrollmentsListV2(), + mitxonline.urls.programEnrollments.enrollmentsListV3(), [ - mitxonline.factories.enrollment.programEnrollmentV2({ - program: program, + mitxonline.factories.enrollment.programEnrollmentV3({ + program: { + id: program.id, + title: program.title, + live: program.live, + program_type: program.program_type, + readable_id: program.readable_id, + }, }), ], ) @@ -523,10 +513,16 @@ describe("EnrollmentDisplay", () => { mockedUseFeatureFlagEnabled.mockReturnValue(true) setMockResponse.get(mitxonline.urls.enrollment.enrollmentsListV2(), []) setMockResponse.get( - mitxonline.urls.programEnrollments.enrollmentsListV2(), + mitxonline.urls.programEnrollments.enrollmentsListV3(), [ - mitxonline.factories.enrollment.programEnrollmentV2({ - program: program, + mitxonline.factories.enrollment.programEnrollmentV3({ + program: { + id: program.id, + title: program.title, + live: program.live, + program_type: program.program_type, + readable_id: program.readable_id, + }, }), ], ) @@ -567,7 +563,7 @@ describe("EnrollmentDisplay", () => { setMockResponse.get(mitxonline.urls.enrollment.enrollmentsListV2(), []) // User is not enrolled in any programs setMockResponse.get( - mitxonline.urls.programEnrollments.enrollmentsListV2(), + mitxonline.urls.programEnrollments.enrollmentsListV3(), [], ) setMockResponse.get(mitxonline.urls.programs.programDetail(888), program) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx index 80d131b0f0..73c205d62a 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx @@ -27,7 +27,7 @@ import { programsQueries } from "api/mitxonline-hooks/programs" import { CourseRunEnrollmentRequestV2, V2ProgramRequirement, - V2UserProgramEnrollmentDetail, + V3UserProgramEnrollment, } from "@mitodl/mitxonline-api-axios/v2" import { contractQueries } from "api/mitxonline-hooks/contracts" import NotFoundPage from "@/app-pages/ErrorPage/NotFoundPage" @@ -160,7 +160,7 @@ const sortEnrollments = (enrollments: CourseRunEnrollmentRequestV2[]) => { interface EnrollmentExpandCollapseProps { shownCourseRunEnrollments: CourseRunEnrollmentRequestV2[] hiddenCourseRunEnrollments: CourseRunEnrollmentRequestV2[] - programEnrollments?: V2UserProgramEnrollmentDetail[] + programEnrollments?: V3UserProgramEnrollment[] isLoading?: boolean onUpgradeError?: (error: string) => void } diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/test-utils.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/test-utils.ts index b96bb0725f..8ca6c0da68 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/test-utils.ts +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/test-utils.ts @@ -292,9 +292,8 @@ function setupOrgDashboardMocks( ) // Empty defaults - setMockResponse.get(mitxonline.urls.programEnrollments.enrollmentsList(), []) setMockResponse.get( - mitxonline.urls.programEnrollments.enrollmentsListV2(), + mitxonline.urls.programEnrollments.enrollmentsListV3(), [], ) setMockResponse.get(mitxonline.urls.contracts.contractsList(), contracts) diff --git a/frontends/main/src/app-pages/DashboardPage/HomeContent.test.tsx b/frontends/main/src/app-pages/DashboardPage/HomeContent.test.tsx index 04a6f022ea..a66506fb4f 100644 --- a/frontends/main/src/app-pages/DashboardPage/HomeContent.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/HomeContent.test.tsx @@ -127,7 +127,7 @@ describe("HomeContent", () => { setMockResponse.get(urls.userLists.membershipList(), []) setMockResponse.get(urls.learningPaths.membershipList(), []) setMockResponse.get( - mitxonline.urls.programEnrollments.enrollmentsListV2(), + mitxonline.urls.programEnrollments.enrollmentsListV3(), [], ) return { resources } diff --git a/frontends/main/src/app-pages/DashboardPage/OrganizationRedirect.test.tsx b/frontends/main/src/app-pages/DashboardPage/OrganizationRedirect.test.tsx index cbea1946c1..69bb144611 100644 --- a/frontends/main/src/app-pages/DashboardPage/OrganizationRedirect.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/OrganizationRedirect.test.tsx @@ -20,7 +20,7 @@ describe("OrganizationRedirect", () => { beforeEach(() => { mockReplace.mockClear() localStorage.clear() - setMockResponse.get(urls.programEnrollments.enrollmentsList(), []) + setMockResponse.get(urls.programEnrollments.enrollmentsListV3(), []) setMockResponse.get(urls.contracts.contractsList(), []) }) diff --git a/frontends/main/src/app-pages/ProductPages/ProductSummary.test.tsx b/frontends/main/src/app-pages/ProductPages/ProductSummary.test.tsx index 2145b86a50..6bc5146a95 100644 --- a/frontends/main/src/app-pages/ProductPages/ProductSummary.test.tsx +++ b/frontends/main/src/app-pages/ProductPages/ProductSummary.test.tsx @@ -1083,6 +1083,7 @@ describe("Course In Programs Row", () => { id: 1, readable_id: "program-1", title: "Test Program 1", + type: "program", } const run = makeRun() const course = makeCourse({ @@ -1108,16 +1109,19 @@ describe("Course In Programs Row", () => { id: 1, readable_id: "program-1", title: "Test Program 1", + type: "program", }, { id: 2, readable_id: "program-2", title: "Test Program 2", + type: "program", }, { id: 3, readable_id: "program-3", title: "Test Program 3", + type: "program", }, ] const run = makeRun() @@ -1154,6 +1158,7 @@ describe("Course In Programs Row", () => { id: 1, readable_id: "program-1", title: "Test Program 1", + type: "program", } const course = makeCourse({ next_run_id: null, diff --git a/frontends/main/src/app-pages/ProductPages/ProgramEnrollmentButton.test.tsx b/frontends/main/src/app-pages/ProductPages/ProgramEnrollmentButton.test.tsx index 0ecc653871..25ac0b06bc 100644 --- a/frontends/main/src/app-pages/ProductPages/ProgramEnrollmentButton.test.tsx +++ b/frontends/main/src/app-pages/ProductPages/ProgramEnrollmentButton.test.tsx @@ -20,7 +20,7 @@ const mockedUseFeatureFlagEnabled = jest .mockImplementation(() => false) const makeProgram = mitxFactories.programs.program -const makeProgramEnrollment = mitxFactories.enrollment.programEnrollmentV2 +const makeProgramEnrollment = mitxFactories.enrollment.programEnrollmentV3 const makeUser = factories.user.user describe("ProgramEnrollmentButton", () => { @@ -37,7 +37,7 @@ describe("ProgramEnrollmentButton", () => { const userResponse = Promise.withResolvers() setMockResponse.get( - mitxUrls.programEnrollments.enrollmentsListV2(), + mitxUrls.programEnrollments.enrollmentsListV3(), enrollmentResponse.promise, ) setMockResponse.get(urls.userMe.get(), userResponse.promise) @@ -68,7 +68,7 @@ describe("ProgramEnrollmentButton", () => { const user = makeUser({ is_authenticated: true }) setMockResponse.get( - mitxUrls.programEnrollments.enrollmentsListV2(), + mitxUrls.programEnrollments.enrollmentsListV3(), enrollments, ) setMockResponse.get(urls.userMe.get(), user) @@ -92,7 +92,7 @@ describe("ProgramEnrollmentButton", () => { const user = makeUser({ is_authenticated: true }) setMockResponse.get( - mitxUrls.programEnrollments.enrollmentsListV2(), + mitxUrls.programEnrollments.enrollmentsListV3(), enrollments, ) setMockResponse.get(urls.userMe.get(), user) @@ -112,7 +112,7 @@ describe("ProgramEnrollmentButton", () => { ] setMockResponse.get( - mitxUrls.programEnrollments.enrollmentsListV2(), + mitxUrls.programEnrollments.enrollmentsListV3(), enrollments, ) setMockResponse.get(urls.userMe.get(), makeUser({ is_authenticated: true })) @@ -132,7 +132,7 @@ describe("ProgramEnrollmentButton", () => { test("Shows signup popover for anonymous users", async () => { const program = makeProgram() - setMockResponse.get(mitxUrls.programEnrollments.enrollmentsListV2(), [], { + setMockResponse.get(mitxUrls.programEnrollments.enrollmentsListV3(), [], { code: 403, }) setMockResponse.get( diff --git a/frontends/main/src/app-pages/ProductPages/ProgramPage.test.tsx b/frontends/main/src/app-pages/ProductPages/ProgramPage.test.tsx index 78735a86a2..feb902d161 100644 --- a/frontends/main/src/app-pages/ProductPages/ProgramPage.test.tsx +++ b/frontends/main/src/app-pages/ProductPages/ProgramPage.test.tsx @@ -126,7 +126,7 @@ const setupApis = ({ learnFactories.user.user({ is_authenticated: false }), ) - setMockResponse.get(urls.programEnrollments.enrollmentsListV2(), []) + setMockResponse.get(urls.programEnrollments.enrollmentsListV3(), []) return { courses } } diff --git a/frontends/main/src/app/dashboard/organization/[orgSlug]/page.test.tsx b/frontends/main/src/app/dashboard/organization/[orgSlug]/page.test.tsx index ebb8428454..26c991d1ae 100644 --- a/frontends/main/src/app/dashboard/organization/[orgSlug]/page.test.tsx +++ b/frontends/main/src/app/dashboard/organization/[orgSlug]/page.test.tsx @@ -21,7 +21,6 @@ useRouter.mockReturnValue({ describe("Organization Page", () => { beforeEach(() => { mockReplace.mockClear() - setMockResponse.get(urls.programEnrollments.enrollmentsList(), []) setMockResponse.get(urls.contracts.contractsList(), []) }) diff --git a/yarn.lock b/yarn.lock index bbb5d8ba05..c6ce6d1228 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3514,13 +3514,13 @@ __metadata: languageName: node linkType: hard -"@mitodl/mitxonline-api-axios@npm:^2026.1.14": - version: 2026.1.21 - resolution: "@mitodl/mitxonline-api-axios@npm:2026.1.21" +"@mitodl/mitxonline-api-axios@npm:^2026.2.5": + version: 2026.2.5 + resolution: "@mitodl/mitxonline-api-axios@npm:2026.2.5" dependencies: "@types/node": "npm:^20.11.19" axios: "npm:^1.6.5" - checksum: 10/68ebfaedcd59cf497adc3a765ef0268fb26d1c6454f6f159992a8f7311297148891989e5e891b3991136e9aaa3eac1f851a2079b0258e63f69e0fd8dce286c21 + checksum: 10/a8e41106fba15723571a6e9f44fb1fd613be364f0dfb0bf05bc5fe006eb286380133a35bb6b3439ed1702024599351484b1a7bf0e71b6e6f3175e9dbbeb2cf20 languageName: node linkType: hard @@ -8854,7 +8854,7 @@ __metadata: resolution: "api@workspace:frontends/api" dependencies: "@faker-js/faker": "npm:^10.0.0" - "@mitodl/mitxonline-api-axios": "npm:^2026.1.14" + "@mitodl/mitxonline-api-axios": "npm:^2026.2.5" "@tanstack/react-query": "npm:^5.66.0" "@testing-library/react": "npm:^16.3.0" axios: "npm:^1.12.2" @@ -16014,7 +16014,7 @@ __metadata: "@floating-ui/react": "npm:^0.27.16" "@happy-dom/jest-environment": "npm:^20.1.0" "@mitodl/course-search-utils": "npm:^3.5.0" - "@mitodl/mitxonline-api-axios": "npm:^2026.1.14" + "@mitodl/mitxonline-api-axios": "npm:^2026.2.5" "@mitodl/smoot-design": "npm:^6.24.0" "@mui/material": "npm:^6.4.5" "@mui/material-nextjs": "npm:^6.4.3" From 6bebe85561def2876b5752e8490127c5210225d9 Mon Sep 17 00:00:00 2001 From: Jon Kafton <939376+jonkafton@users.noreply.github.com> Date: Tue, 10 Feb 2026 21:31:44 +0100 Subject: [PATCH 03/10] fix: Replace destructuring for build time substitution (#2945) * Do not use desctructing for build time static replacement * Destructuring is ok in server components, but consistency here should serve as a reminder --- .../main/src/app/certificate/[certificateType]/[uuid]/page.tsx | 2 +- frontends/main/src/app/layout.tsx | 2 +- .../node/ArticleByLineInfoBar/ArticleByLineInfoBar.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontends/main/src/app/certificate/[certificateType]/[uuid]/page.tsx b/frontends/main/src/app/certificate/[certificateType]/[uuid]/page.tsx index b547edc61b..21e4f7c9ad 100644 --- a/frontends/main/src/app/certificate/[certificateType]/[uuid]/page.tsx +++ b/frontends/main/src/app/certificate/[certificateType]/[uuid]/page.tsx @@ -8,7 +8,7 @@ import { notFound } from "next/navigation" import { safeGenerateMetadata, standardizeMetadata } from "@/common/metadata" import { getQueryClient } from "@/app/getQueryClient" -const { NEXT_PUBLIC_ORIGIN } = process.env +const NEXT_PUBLIC_ORIGIN = process.env.NEXT_PUBLIC_ORIGIN enum CertificateType { Course = "course", diff --git a/frontends/main/src/app/layout.tsx b/frontends/main/src/app/layout.tsx index 9db5de12f1..276e786951 100644 --- a/frontends/main/src/app/layout.tsx +++ b/frontends/main/src/app/layout.tsx @@ -8,7 +8,7 @@ import Script from "next/script" import "./GlobalStyles" -const { NEXT_PUBLIC_ORIGIN } = process.env +const NEXT_PUBLIC_ORIGIN = process.env.NEXT_PUBLIC_ORIGIN /** * As part of the root layout, this metadata object is site-wide defaults diff --git a/frontends/main/src/page-components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBar.tsx b/frontends/main/src/page-components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBar.tsx index b1b84dcd9e..446e59b869 100644 --- a/frontends/main/src/page-components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBar.tsx +++ b/frontends/main/src/page-components/TiptapEditor/extensions/node/ArticleByLineInfoBar/ArticleByLineInfoBar.tsx @@ -11,7 +11,7 @@ import { useArticle } from "../../../ArticleContext" import { calculateReadTime } from "../../utils" import SharePopover from "@/components/SharePopover/SharePopover" -const { NEXT_PUBLIC_ORIGIN } = process.env +const NEXT_PUBLIC_ORIGIN = process.env.NEXT_PUBLIC_ORIGIN const StyledWrapper = styled.div(({ theme }) => ({ width: "100vw", From d78be5d7683034bc7c23bddedc5ad7cb8092cd08 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Tue, 10 Feb 2026 16:40:24 -0500 Subject: [PATCH 04/10] Allow iframes in ContractPage welcome message (and elsewhere) (#2943) * reorganize UnstyledRawHtml, allow some extra tags * require https --- .../DashboardPage/ContractContent.tsx | 45 +++++++++---------- .../app-pages/ProductPages/ProgramPage.tsx | 3 +- .../src/app-pages/ProductPages/RawHTML.tsx | 19 +------- .../UnstyledRawHTML/UnstyledRawHTML.tsx | 43 ++++++++++++++++++ 4 files changed, 67 insertions(+), 43 deletions(-) create mode 100644 frontends/main/src/components/UnstyledRawHTML/UnstyledRawHTML.tsx diff --git a/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx b/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx index 4efc7de605..45a0e1fc4d 100644 --- a/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx +++ b/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx @@ -1,7 +1,6 @@ "use client" import React, { useEffect } from "react" -import DOMPurify from "isomorphic-dompurify" import Image from "next/image" import { useQuery } from "@tanstack/react-query" import { @@ -38,6 +37,7 @@ import { getKey, selectBestEnrollment, } from "./CoursewareDisplay/helpers" +import UnstyledRawHTML from "@/components/UnstyledRawHTML/UnstyledRawHTML" const HeaderRoot = styled.div({ display: "flex", @@ -89,12 +89,21 @@ const ContractHeader: React.FC<{ ) } -const WelcomeMessageExtra = styled(Typography)({ +const WelcomeMessageExtra = styled(UnstyledRawHTML)(({ theme }) => ({ + ...theme.typography.body1, margin: 0, - p: { - margin: 0, + "*:first-child": { + marginTop: 0, }, -}) + "*:last-child": { + marginBottom: 0, + }, + iframe: { + width: "100%", + aspectRatio: "16 / 9", + border: "none", + }, +})) const WelcomeMessage: React.FC<{ contract?: ContractPage }> = ({ contract, @@ -105,7 +114,7 @@ const WelcomeMessage: React.FC<{ contract?: ContractPage }> = ({ return empty } const welcomeMessage = contract.welcome_message - const welcomeMessageExtra = DOMPurify.sanitize(contract.welcome_message_extra) + const welcomeMessageExtra = contract.welcome_message_extra if (!welcomeMessage || !welcomeMessageExtra) { return empty } @@ -122,12 +131,7 @@ const WelcomeMessage: React.FC<{ contract?: ContractPage }> = ({ {showingMore ? "Show less" : "Show more"} - {showingMore && ( - - )} + {showingMore && } ) } @@ -178,11 +182,12 @@ const ProgramCertificateButton = styled(ButtonLink)(({ theme }) => ({ gap: "10px", })) -const ProgramDescription = styled(Typography)({ +const ProgramDescription = styled(UnstyledRawHTML)(({ theme }) => ({ + ...theme.typography.body2, p: { margin: 0, }, -}) +})) const ProgramCollectionsList = styled(PlainList)({ display: "flex", @@ -231,7 +236,6 @@ const OrgProgramCollectionDisplay: React.FC<{ contract: ContractPage enrollments?: CourseRunEnrollmentRequestV2[] }> = ({ collection, contract, enrollments }) => { - const sanitizedDescription = DOMPurify.sanitize(collection.description ?? "") const { isLoading, programsWithCourses, hasAnyCourses } = useProgramCollectionCourses(collection, contract.id) const firstCourseIds = programsWithCourses @@ -266,10 +270,7 @@ const OrgProgramCollectionDisplay: React.FC<{ {collection.title} - + ) @@ -376,7 +377,6 @@ const OrgProgramDisplay: React.FC<{ ) - const sanitizedHtml = DOMPurify.sanitize(program.page.description) const courses = coursesQuery.data?.results.sort((a, b) => { return program.courses.indexOf(a.id) - program.courses.indexOf(b.id) @@ -389,10 +389,7 @@ const OrgProgramDisplay: React.FC<{ {program.title} - + {hasValidCertificate && ( = memo(({ html, className, Component = "div" }) => { - return ( - - ) -}) +import UnstyledRawHTML from "@/components/UnstyledRawHTML/UnstyledRawHTML" const RawHTML = styled(UnstyledRawHTML)(({ theme }) => ({ "*:first-child": { @@ -54,4 +38,3 @@ const RawHTML = styled(UnstyledRawHTML)(({ theme }) => ({ })) export default RawHTML -export { UnstyledRawHTML } diff --git a/frontends/main/src/components/UnstyledRawHTML/UnstyledRawHTML.tsx b/frontends/main/src/components/UnstyledRawHTML/UnstyledRawHTML.tsx new file mode 100644 index 0000000000..db33e6c13c --- /dev/null +++ b/frontends/main/src/components/UnstyledRawHTML/UnstyledRawHTML.tsx @@ -0,0 +1,43 @@ +import DOMPurify from "isomorphic-dompurify" +import type { Config } from "isomorphic-dompurify" +import React, { memo } from "react" +import classnames from "classnames" + +type UnstyledRawHTMLProps = { + html: string + className?: string + Component?: React.ElementType +} + +const SANITIZE_CONFIG: Config = { + ADD_TAGS: ["iframe"], + ADD_ATTR: [ + "allow", + "allowfullscreen", + "frameborder", + "scrolling", + "width", + "height", + "title", + "referrerpolicy", + ], + ADD_URI_SAFE_ATTR: ["src"], + ALLOWED_URI_REGEXP: /^(?:(?:https):)|^(?:data:image\/)/i, +} + +const UnstyledRawHTML: React.FC = memo( + ({ html, className, Component = "div" }) => { + return ( + + ) + }, +) + +export default UnstyledRawHTML +export type { UnstyledRawHTMLProps } From f4cf2ec7d70589d413407d5c98362b7a732e955d Mon Sep 17 00:00:00 2001 From: Carey P Gumaer Date: Tue, 10 Feb 2026 17:08:10 -0500 Subject: [PATCH 05/10] adjust dashboard card links (#2935) * add context menu items to link to product pages * remove titleAction argument as it's not necessary anymore * fix logic surrounding marketing urls and add tests * program title link should go to program dashboard * check include_in_learn_catalog before adding show course details to context menu * remove unnecessary array wrapper * remove unnecessary router usage * card title should perform enrollment when applicable * fix tests after rebase * fix issue with getting legacy product page URL from v3 program enrollment * use readable id to construct legacy program product page url --- .../DashboardPage/ContractContent.tsx | 2 - .../CoursewareDisplay/DashboardCard.test.tsx | 445 +++++++++++++----- .../CoursewareDisplay/DashboardCard.tsx | 138 ++++-- .../DashboardDialogs.test.tsx | 31 +- .../CoursewareDisplay/EnrollmentDisplay.tsx | 4 - 5 files changed, 432 insertions(+), 188 deletions(-) diff --git a/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx b/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx index 45a0e1fc4d..3e8a473795 100644 --- a/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx +++ b/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx @@ -336,7 +336,6 @@ const OrgProgramCollectionDisplay: React.FC<{ } noun="Module" offerUpgrade={false} - titleAction="courseware" buttonHref={bestEnrollment?.run.courseware_url} contractId={contract.id} /> @@ -434,7 +433,6 @@ const OrgProgramDisplay: React.FC<{ } noun="Module" offerUpgrade={false} - titleAction="courseware" buttonHref={bestEnrollment?.run.courseware_url} contractId={contract?.id} /> 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 d36e5ad939..21cf66b50f 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.test.tsx @@ -13,12 +13,16 @@ import { mockAxiosInstance } from "api/test-utils" import { DashboardCard, DashboardType, - getDefaultContextMenuItems, + getContextMenuItems, } from "./DashboardCard" import { dashboardCourse } from "./test-utils" import { faker } from "@faker-js/faker/locale/en" import moment from "moment" import { cartesianProduct } from "ol-test-utilities" +import { useFeatureFlagEnabled } from "posthog-js/react" + +jest.mock("posthog-js/react") +const mockedUseFeatureFlagEnabled = jest.mocked(useFeatureFlagEnabled) const EnrollmentMode = { Audit: "audit", @@ -81,68 +85,12 @@ describe.each([ setupLocationMock() - test("It shows course title and links to marketingUrl if titleAction is marketing and enrolled", async () => { - setupUserApis() - const marketingUrl = "?some-marketing-url" - const course = dashboardCourse({ - page: { - page_url: marketingUrl, - }, - }) - const enrollment = mitxonline.factories.enrollment.courseEnrollment({ - grades: [], // No passing grade = enrolled but not completed - run: { - ...mitxonline.factories.enrollment.courseEnrollment().run, - course: course, - }, - }) - renderWithProviders( - , - ) - - const card = getCard() - - const courseLink = within(card).getByRole("link", { - name: course.title, - }) - expect(courseLink).toHaveAttribute("href", marketingUrl) + beforeEach(() => { + // Default to feature flag disabled unless explicitly set in a test + mockedUseFeatureFlagEnabled.mockReturnValue(false) }) - test("It shows course title as clickable text (not link) if titleAction is marketing and not enrolled (non-B2B)", async () => { - setupUserApis() - const course = dashboardCourse({ - page: { - page_url: "?some-marketing-url", - }, - courseruns: [ - mitxonline.factories.courses.courseRun({ - b2b_contract: null, - }), - ], - }) - // No enrollment = not enrolled - renderWithProviders( - , - ) - - const card = getCard() - - // Should not be a link - expect( - within(card).queryByRole("link", { name: course.title }), - ).not.toBeInTheDocument() - // Should be clickable text - const titleText = within(card).getByText(course.title) - expect(titleText).toBeInTheDocument() - }) - - test("It shows course title and links to courseware if titleAction is courseware and enrolled", async () => { + test("It shows course title and links to courseware when enrolled", async () => { setupUserApis() const coursewareUrl = faker.internet.url() const courseRun = mitxonline.factories.courses.courseRun({ @@ -161,7 +109,6 @@ describe.each([ }) renderWithProviders( , ) @@ -174,7 +121,7 @@ describe.each([ expect(courseLink).toHaveAttribute("href", coursewareUrl) }) - test("It shows course title as clickable text (not link) if titleAction is courseware and not enrolled (non-B2B)", async () => { + test("It shows course title as clickable text (not link) when not enrolled (non-B2B)", async () => { setupUserApis() const course = dashboardCourse({ courseruns: [ @@ -185,10 +132,7 @@ describe.each([ }) // No enrollment = not enrolled renderWithProviders( - , + , ) const card = getCard() @@ -202,7 +146,7 @@ describe.each([ expect(titleText).toBeInTheDocument() }) - test("It shows course title as link if not enrolled but has B2B contract", async () => { + test("It shows course title as clickable text if not enrolled but has B2B contract", async () => { setupUserApis() const b2bContractId = faker.number.int() const coursewareUrl = faker.internet.url() @@ -216,21 +160,19 @@ describe.each([ ], next_run_id: null, // Ensure getBestRun uses the single run }) - // No enrollment passed, but B2B contract in run allows access + // No enrollment passed, B2B contract requires enrollment first renderWithProviders( - , + , ) const card = getCard() - // Should be a link for B2B courses - const courseLink = within(card).getByRole("link", { - name: course.title, - }) - expect(courseLink).toHaveAttribute("href", coursewareUrl) + // Should be clickable text, not a link (enrollment happens on click) + expect( + within(card).queryByRole("link", { name: course.title }), + ).not.toBeInTheDocument() + const titleText = within(card).getByText(course.title) + expect(titleText).toBeInTheDocument() }) test("Accepts a classname", () => { @@ -244,7 +186,6 @@ describe.each([ ]) renderWithProviders( , + , ) const card = getCard() @@ -359,7 +296,6 @@ describe.each([ }) const { view } = renderWithProviders( , ) @@ -800,10 +732,7 @@ describe.each([ next_run_id: run.id, // Ensure getBestRun uses this run }) renderWithProviders( - , + , ) const card = getCard() @@ -830,7 +759,6 @@ describe.each([ ]) const { view } = renderWithProviders( { setupUserApis() - const course = dashboardCourse() + const course = dashboardCourse({ include_in_learn_catalog: true }) const run = course.courseruns[0] const enrollment = mitxonline.factories.enrollment.courseEnrollment({ grades: [mitxonline.factories.enrollment.grade({ passed: true })], @@ -955,7 +881,6 @@ describe.each([ }) renderWithProviders( , ) @@ -1172,7 +1103,6 @@ describe.each([ }) renderWithProviders( , @@ -1181,7 +1111,7 @@ describe.each([ const triggerElement = trigger === "button" ? within(card).getByTestId("courseware-button") - : within(card).getByRole("link", { name: course.title }) + : within(card).getByText(course.title) await user.click(triggerElement) @@ -1212,7 +1142,6 @@ describe.each([ setupEnrollmentApis({ user: userData, course, run }) renderWithProviders( , @@ -1221,7 +1150,7 @@ describe.each([ const triggerElement = trigger === "button" ? within(card).getByTestId("courseware-button") - : within(card).getByRole("link", { name: course.title }) + : within(card).getByText(course.title) await user.click(triggerElement) @@ -1239,7 +1168,6 @@ describe.each([ renderWithProviders( , ) @@ -1278,7 +1206,6 @@ describe.each([ ))} @@ -1311,7 +1238,6 @@ describe.each([ renderWithProviders( { + setupUserApis() + const programEnrollment = + mitxonline.factories.enrollment.programEnrollmentV3({ + program: mitxonline.factories.programs.simpleProgram({ + title: "Test Program Title", + id: 123, + }), + }) + + renderWithProviders( + , + ) + + const card = getCard() + const titleLink = within(card).getByRole("link", { + name: "Test Program Title", + }) + expect(titleLink).toHaveAttribute("href", "/dashboard/program/123") + }) + test("program card does not show course-specific elements", () => { setupUserApis() const programEnrollment = @@ -1334,7 +1286,6 @@ describe.each([ renderWithProviders( { + mockedUseFeatureFlagEnabled.mockReturnValue(useProductPages) + setupUserApis() + + const marketingUrl = faker.internet.url() + const course = dashboardCourse({ + page: { page_url: marketingUrl }, + include_in_learn_catalog: true, + }) + const run = course.courseruns[0] + const enrollment = mitxonline.factories.enrollment.courseEnrollment({ + grades: [mitxonline.factories.enrollment.grade({ passed: true })], + enrollment_mode: EnrollmentMode.Verified, + run: { + ...run, + course: { ...course, page: { page_url: marketingUrl } }, + }, + }) + + renderWithProviders( + , + ) + + const card = getCard() + const contextMenuButton = within(card).getByRole("button", { + name: "More options", + }) + await user.click(contextMenuButton) + + const viewDetailsItem = screen.getByRole("menuitem", { + name: "View Course Details", + }) + + if (useProductPages) { + // Should have product page URL as href + expect(viewDetailsItem).toHaveAttribute( + "href", + `/courses/${course.readable_id}`, + ) + } else { + // Should have marketing URL as href + expect(viewDetailsItem).toHaveAttribute("href", marketingUrl) + } + }, + ) + + test("Context menu for program enrollment uses product page URLs when feature flag is enabled", async () => { + mockedUseFeatureFlagEnabled.mockReturnValue(true) + setupUserApis() + + const program = mitxonline.factories.programs.simpleProgram() + const programEnrollment = + mitxonline.factories.enrollment.programEnrollmentV3({ + program, + }) + + renderWithProviders( + , + ) + + const card = getCard() + const contextMenuButton = within(card).getByRole("button", { + name: "More options", + }) + await user.click(contextMenuButton) + + const viewDetailsItem = screen.getByRole("menuitem", { + name: "View Program Details", + }) + + // Should have product page URL as href + expect(viewDetailsItem).toHaveAttribute( + "href", + `/programs/${program.readable_id}`, + ) + }) + + test("Context menu for program enrollment uses constructed marketing URL when feature flag is disabled", async () => { + mockedUseFeatureFlagEnabled.mockReturnValue(false) + setupUserApis() + + const program = mitxonline.factories.programs.simpleProgram({ + readable_id: "test-program-123", + }) + const programEnrollment = + mitxonline.factories.enrollment.programEnrollmentV3({ + program, + }) + + renderWithProviders( + , + ) + + const card = getCard() + const contextMenuButton = within(card).getByRole("button", { + name: "More options", + }) + await user.click(contextMenuButton) + + const viewDetailsItem = screen.getByRole("menuitem", { + name: "View Program Details", + }) + + // Should have constructed marketing URL + expect(viewDetailsItem).toHaveAttribute( + "href", + "http://mitxonline.odl.local:8065/programs/test-program-123", + ) + }) + + test("Context menu for course enrollment without marketing URL shows View Details only when flag is enabled", async () => { + setupUserApis() + + const enrollment = mitxonline.factories.enrollment.courseEnrollment({ + grades: [mitxonline.factories.enrollment.grade({ passed: true })], + enrollment_mode: EnrollmentMode.Verified, + run: { + ...mitxonline.factories.courses.courseRun(), + course: { + ...mitxonline.factories.courses.course(), + include_in_learn_catalog: true, + }, + }, + }) + // Remove the page property to simulate a course without a marketing URL + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (enrollment.run.course as any).page + + // Test with flag disabled (no marketing URL, no View Details menu item) + mockedUseFeatureFlagEnabled.mockReturnValue(false) + renderWithProviders( + , + ) + + const card = getCard() + const contextMenuButton = within(card).getByRole("button", { + name: "More options", + }) + await user.click(contextMenuButton) + + // Should not have View Course Details when flag is off and no marketing URL + expect( + screen.queryByRole("menuitem", { name: "View Course Details" }), + ).not.toBeInTheDocument() + + // Should still have Email Settings and Unenroll + expect( + screen.getByRole("menuitem", { name: "Email Settings" }), + ).toBeInTheDocument() + expect( + screen.getByRole("menuitem", { name: "Unenroll" }), + ).toBeInTheDocument() + }) + + // Separate test for the flag enabled case + test("Context menu for course enrollment without marketing URL shows View Details when flag is enabled", async () => { + setupUserApis() + + const enrollment = mitxonline.factories.enrollment.courseEnrollment({ + grades: [mitxonline.factories.enrollment.grade({ passed: true })], + enrollment_mode: EnrollmentMode.Verified, + run: { + ...mitxonline.factories.courses.courseRun(), + course: { + ...mitxonline.factories.courses.course(), + include_in_learn_catalog: true, + }, + }, + }) + // Remove the page property to simulate a course without a marketing URL + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (enrollment.run.course as any).page + + // Test with flag enabled + mockedUseFeatureFlagEnabled.mockReturnValue(true) + renderWithProviders( + , + ) + + const card = getCard() + const contextMenuButton = within(card).getByRole("button", { + name: "More options", + }) + await user.click(contextMenuButton) + + // Should have View Course Details when flag is on (product pages always exist) + expect( + screen.getByRole("menuitem", { name: "View Course Details" }), + ).toBeInTheDocument() + }) + + test("Context menu does not show View Details for courses not in learn catalog", async () => { + setupUserApis() + mockedUseFeatureFlagEnabled.mockReturnValue(true) // Even with flag enabled + + const course = dashboardCourse({ + include_in_learn_catalog: false, // Key: not in catalog + page: { page_url: faker.internet.url() }, + }) + const run = course.courseruns[0] + const enrollment = mitxonline.factories.enrollment.courseEnrollment({ + grades: [mitxonline.factories.enrollment.grade({ passed: true })], + enrollment_mode: EnrollmentMode.Verified, + run: { + ...run, + course: course, + }, + }) + + renderWithProviders( + , + ) + + const card = getCard() + const contextMenuButton = within(card).getByRole("button", { + name: "More options", + }) + await user.click(contextMenuButton) + + // Should NOT have View Course Details when not in learn catalog + expect( + screen.queryByRole("menuitem", { name: "View Course Details" }), + ).not.toBeInTheDocument() + + // Should still have Email Settings and Unenroll + expect( + screen.getByRole("menuitem", { name: "Email Settings" }), + ).toBeInTheDocument() + expect( + screen.getByRole("menuitem", { name: "Unenroll" }), + ).toBeInTheDocument() + }) }) }) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx index ef6ba88a40..961ac6a487 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx @@ -17,6 +17,8 @@ import { RiAwardLine, } from "@remixicon/react" import { calendarDaysUntil, isInPast, NoSSR } from "ol-utilities" +import { useFeatureFlagEnabled } from "posthog-js/react" +import { FeatureFlags } from "@/common/feature_flags" import { EnrollmentStatusIndicator } from "./EnrollmentStatusIndicator" import { @@ -28,7 +30,8 @@ import NiceModal from "@ebay/nice-modal-react" import { useCreateB2bEnrollment } from "api/mitxonline-hooks/enrollment" import { mitxUserQueries } from "api/mitxonline-hooks/user" import { useQuery } from "@tanstack/react-query" -import { programView } from "@/common/urls" +import { coursePageView, programPageView, programView } from "@/common/urls" +import { mitxonlineUrl } from "@/common/mitxonline" import { useAddToBasket, useClearBasket } from "api/mitxonline-hooks/baskets" import { EnrollmentStatus, getBestRun, getEnrollmentStatus } from "./helpers" import { @@ -164,28 +167,69 @@ const MenuButton = styled(ActionButton)<{ }, ]) -const getDefaultContextMenuItems = ( +const getContextMenuItems = ( title: string, - enrollment: CourseRunEnrollmentRequestV2, + resource: DashboardResource, + useProductPages: boolean, + includeInLearnCatalog: boolean, + additionalItems: SimpleMenuItem[] = [], ) => { - return [ - { - className: "dashboard-card-menu-item", - key: "email-settings", - label: "Email Settings", - onClick: () => { - NiceModal.show(EmailSettingsDialog, { title, enrollment }) + const menuItems = [] + if (resource.type === DashboardType.ProgramEnrollment) { + const detailsUrl = useProductPages + ? programPageView(resource.data.program.readable_id) + : mitxonlineUrl(`/programs/${resource.data.program.readable_id}`) + + if (detailsUrl && includeInLearnCatalog) { + menuItems.push({ + className: "dashboard-card-menu-item", + key: "view-program-details", + label: "View Program Details", + href: detailsUrl, + }) + } + } + if (resource.type === DashboardType.CourseRunEnrollment) { + const detailsUrl = useProductPages + ? coursePageView(resource.data.run.course.readable_id) + : resource.data.run.course.page?.page_url + + const courseMenuItems = [] + + if (detailsUrl && includeInLearnCatalog) { + courseMenuItems.push({ + className: "dashboard-card-menu-item", + key: "view-course-details", + label: "View Course Details", + href: detailsUrl, + }) + } + + courseMenuItems.push( + { + className: "dashboard-card-menu-item", + key: "email-settings", + label: "Email Settings", + onClick: () => { + NiceModal.show(EmailSettingsDialog, { + title, + enrollment: resource.data, + }) + }, }, - }, - { - className: "dashboard-card-menu-item", - key: "unenroll", - label: "Unenroll", - onClick: () => { - NiceModal.show(UnenrollDialog, { title, enrollment }) + { + className: "dashboard-card-menu-item", + key: "unenroll", + label: "Unenroll", + onClick: () => { + NiceModal.show(UnenrollDialog, { title, enrollment: resource.data }) + }, }, - }, - ] + ) + + menuItems.push(...courseMenuItems) + } + return [...menuItems, ...additionalItems] } const useOneClickEnroll = () => { @@ -520,7 +564,6 @@ const CourseStartCountdown: React.FC<{ type DashboardCardProps = { resource: DashboardResource - titleAction?: "marketing" | "courseware" showNotComplete?: boolean offerUpgrade?: boolean noun?: string @@ -537,7 +580,6 @@ type DashboardCardProps = { const DashboardCard: React.FC = ({ resource, - titleAction = "courseware", showNotComplete = true, offerUpgrade = true, noun, @@ -553,6 +595,9 @@ const DashboardCard: React.FC = ({ }) => { const oneClickEnroll = useOneClickEnroll() const { data: user } = useQuery(mitxUserQueries.me()) + const useProductPages = useFeatureFlagEnabled( + FeatureFlags.MitxOnlineProductPages, + ) // Determine resource type from discriminated union const resourceIsCourse = resource.type === DashboardType.Course @@ -629,11 +674,6 @@ const DashboardCard: React.FC = ({ : EnrollmentStatus.Enrolled // URLs - const marketingUrl = resourceIsCourse - ? resource.data.page?.page_url - : resourceIsCourseRunEnrollment - ? resource.data.run.course.page?.page_url - : undefined const coursewareUrl = run?.courseware_url const hasEnrolled = isAnyCourse && enrollmentStatus !== EnrollmentStatus.NotEnrolled @@ -647,16 +687,13 @@ const DashboardCard: React.FC = ({ : true const disableEnrollment = resourceIsCourse && !hasEnrollableRuns - // Title link logic const titleHref = isAnyCourse ? hasEnrolled - ? titleAction === "marketing" - ? marketingUrl - : (coursewareUrl ?? marketingUrl) - : b2bContractId - ? (coursewareUrl ?? marketingUrl) - : undefined - : undefined + ? (buttonHref ?? coursewareUrl) + : undefined + : resourceIsProgramEnrollment + ? programView(resource.data.program.id) + : undefined const titleClick: React.MouseEventHandler | undefined = isAnyCourse && !hasEnrolled @@ -667,8 +704,9 @@ const DashboardCard: React.FC = ({ : resourceIsCourseRunEnrollment ? resource.data.run.courseware_id : undefined - if (!readableId || !coursewareUrl) return - handleEnrollment(coursewareUrl, readableId, !!b2bContractId) + const targetUrl = buttonHref ?? coursewareUrl + if (!readableId || !targetUrl) return + handleEnrollment(targetUrl, readableId, !!b2bContractId) } : undefined @@ -682,8 +720,9 @@ const DashboardCard: React.FC = ({ : resourceIsCourseRunEnrollment ? resource.data.run.courseware_id : undefined - if (!readableId || !coursewareUrl) return - handleEnrollment(coursewareUrl, readableId, !!b2bContractId) + const targetUrl = buttonHref ?? coursewareUrl + if (!readableId || !targetUrl) return + handleEnrollment(targetUrl, readableId, !!b2bContractId) } : buttonClick // Build sections @@ -787,10 +826,19 @@ const DashboardCard: React.FC = ({ ) : null - const menuItems = contextMenuItems.concat( - resource.type === DashboardType.CourseRunEnrollment - ? getDefaultContextMenuItems(title, resource.data) - : [], + const includeInLearnCatalog = resourceIsCourse + ? resource.data.include_in_learn_catalog + : resourceIsCourseRunEnrollment + ? resource.data.run.course.include_in_learn_catalog + : resourceIsProgramEnrollment + ? true + : false + const menuItems = getContextMenuItems( + title, + resource, + useProductPages ?? false, + includeInLearnCatalog ?? false, + contextMenuItems, ) const contextMenu = isLoading ? ( @@ -869,8 +917,4 @@ const DashboardCard: React.FC = ({ ) } -export { - DashboardCard, - CardRoot as DashboardCardRoot, - getDefaultContextMenuItems, -} +export { DashboardCard, CardRoot as DashboardCardRoot, getContextMenuItems } diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardDialogs.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardDialogs.test.tsx index 1a378398a3..394991e738 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardDialogs.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardDialogs.test.tsx @@ -213,10 +213,7 @@ describe("JustInTimeDialog", () => { const { course } = setupJustInTimeTest() renderWithProviders( - , + , ) const enrollButtons = await screen.findAllByTestId("courseware-button") @@ -254,7 +251,6 @@ describe("JustInTimeDialog", () => { const { course } = setupJustInTimeTest({ userOverrides }) renderWithProviders( , ) @@ -273,10 +269,7 @@ describe("JustInTimeDialog", () => { const { course } = setupJustInTimeTest() renderWithProviders( - , + , ) const enrollButtons = await screen.findAllByTestId("courseware-button") @@ -309,10 +302,7 @@ describe("JustInTimeDialog", () => { const { course } = setupJustInTimeTest() renderWithProviders( - , + , ) const enrollButtons = await screen.findAllByTestId("courseware-button") @@ -339,10 +329,7 @@ describe("JustInTimeDialog", () => { const { course, countries } = setupJustInTimeTest() renderWithProviders( - , + , ) const enrollButtons = await screen.findAllByTestId("courseware-button") @@ -367,10 +354,7 @@ describe("JustInTimeDialog", () => { const { course } = setupJustInTimeTest() renderWithProviders( - , + , ) const enrollButtons = await screen.findAllByTestId("courseware-button") @@ -400,10 +384,7 @@ describe("JustInTimeDialog", () => { }) renderWithProviders( - , + , ) const enrollButtons = await screen.findAllByTestId("courseware-button") await user.click(enrollButtons[0]) // Use the first (desktop) button diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx index 73c205d62a..80615d6342 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx @@ -185,7 +185,6 @@ const EnrollmentExpandCollapse: React.FC = ({ {shownCourseRunEnrollments.map((enrollment) => { return ( = ({ })} {programEnrollments?.map((program) => ( = ({ {hiddenCourseRunEnrollments.map((enrollment) => ( = ({ {section.courses.map((course) => ( Date: Wed, 11 Feb 2026 09:25:01 -0500 Subject: [PATCH 06/10] chore: Created an AGENTS.md file to help give context to agentic tools (#2944) --- AGENTS.md | 298 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 298 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..3c4bc670f8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,298 @@ +# MIT Learn - Agent Instructions + +This is a Django/Next.js application for browsing and searching MIT learning resources aggregated from multiple platforms (OCW, MITx, xPRO, etc.). + +## Architecture Overview + +MIT Learn is a **hybrid Django + Next.js monorepo** with these major components: + +### Backend (Django + Celery) + +- **Django REST API** using DRF + drf-spectacular for OpenAPI generation +- **ETL Pipelines** in `learning_resources/etl/` extract, transform, and load data from external sources (OCW, edX, xPRO, MITx Online, Canvas, YouTube, podcasts, etc.) +- **OpenSearch** for full-text search with embeddings and hybrid search support +- **Celery** for async task processing (ETL, indexing, notifications) +- **Vector Search** using Qdrant for AI-powered recommendations and semantic search +- **Keycloak + APISIX** for authentication (see README-keycloak.md) + +Key Django apps: + +- `learning_resources` - Core models (LearningResource, Course, Program, ContentFile, etc.) and ETL pipelines +- `learning_resources_search` - OpenSearch indexing and search API +- `channels` - Community channels/groups +- `articles` - User-created articles with CKEditor +- `authentication` - Auth middleware and user management +- `vector_search` - Qdrant integration for embeddings +- `webhooks` - External webhook handlers (OCW, YouTube, etc.) + +### Frontend (Next.js App Router) + +Located in `frontends/` as a **Yarn workspaces monorepo**: + +- `main/` - Next.js 14+ app using App Router (NOT pages router) +- `api/` - Generated TypeScript API client from OpenAPI spec +- `ol-components/` - Shared React components +- `ol-search-ui/`, `ol-forms/`, `ol-utilities/` - Reusable packages +- Uses `@mitodl/smoot-design` for design system components +- Root-relative imports via `@/` in the `main` workspace + +## Build, Test, and Lint Commands + +### Backend (Python) + +Run inside Docker containers with `docker compose`: + +```bash +# Run all tests (parallel) +docker compose run --rm web poetry run pytest -n logical + +# Run specific test file +docker compose run --rm web poetry run pytest learning_resources/models_test.py + +# Run specific test +docker compose run --rm web poetry run pytest learning_resources/models_test.py::test_name -v + +# Lint and format with ruff +docker compose run --rm web poetry run ruff format . +docker compose run --rm web poetry run ruff check . --fix + +# Run Django management commands +docker compose run --rm web python manage.py + +# Create migrations +docker compose run --rm web python manage.py makemigrations + +# Run migrations +docker compose run --rm web python manage.py migrate + +# Create superuser +docker compose run --rm web python manage.py createsuperuser +``` + +### Frontend (TypeScript/React) + +From project root (not `frontends/` directory): + +```bash +# Run all tests +yarn test + +# Run tests for specific file +yarn test path/to/file.test.tsx + +# Watch mode +yarn test-watch + +# Lint +yarn workspace frontends run lint-check +yarn workspace frontends run lint-fix + +# Type checking +yarn workspace frontends run typecheck + +# Style linting (CSS/SCSS) +yarn workspace frontends run style-lint + +# Format with Prettier +yarn workspace frontends run fmt-check +yarn workspace frontends run fmt-fix + +# Build frontend +yarn build + +# Run dev server (inside Docker) +docker compose up + +# Run dev server (on host) +yarn watch +``` + +### E2E Tests (Playwright) + +```bash +# Run Playwright tests +yarn playwright + +# UI mode +yarn playwright:ui + +# View report +yarn playwright:report +``` + +### Pre-commit Hooks + +```bash +# Install pre-commit +pip install pre-commit +pre-commit install + +# Run all checks +pre-commit run --all-files +``` + +## Code Generation + +### OpenAPI Client Generation + +The TypeScript API client in `frontends/api/src/generated/` is auto-generated from the Django API using drf-spectacular: + +```bash +# Regenerate OpenAPI spec and TypeScript client +./scripts/generate_openapi.sh +``` + +**CI will fail if generated code is out of sync** - always regenerate after changing Django views/serializers. + +## Key Conventions + +### Python/Django + +- Use **Ruff** for linting and formatting (configured in `pyproject.toml`) +- **Factory Boy** for test data - all factories in `/factories.py` +- **Named Enums** - use `named_enum.ExtendedEnum` for constants (see `learning_resources/constants.py`) +- **Serializers** - use DRF serializers with `COMMON_IGNORED_FIELDS` exclusion pattern +- **Permissions** - use Django Guardian for object-level permissions +- **Views** - use DRF ViewSets with drf-spectacular decorators (`@extend_schema`) +- **Tests** - pytest-django with `conftest.py`, fixture-based setup, auto-mock external requests +- **Migrations** - all migrations must be non-auto and tested (see `scripts/test/no_auto_migrations.sh`) +- Never import `django.contrib.auth.models.User` directly - use `get_user_model()` or `settings.AUTH_USER_MODEL` + +### TypeScript/React/Next.js + +- **Next.js App Router** (NOT pages router) - routes in `frontends/main/src/app/` +- **React Query** - API calls via generated hooks from `api/` package +- **Test factories** - mock API responses with `setMockResponse` from `api/test-utils` +- **Styling** - CSS Modules or components from `@mitodl/smoot-design` and `ol-components` +- **Root imports** - use `@/` prefix in `main` workspace (e.g., `@/components`, `@/test-utils`) +- **Yarn workspaces** - run commands with `yarn workspace run