diff --git a/RELEASE.rst b/RELEASE.rst index 89d7ae151c..0f99da7fb2 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,13 @@ Release Notes ============= +Version 0.65.5 +-------------- + +- use courseware_url from language_options, filter them by is_enrollable (#3276) +- fix: add heading tags to My Learning course titles for screen readers (#3253) +- dashboard translations UI (#3269) + Version 0.65.3 (Released May 01, 2026) -------------- diff --git a/frontends/api/package.json b/frontends/api/package.json index 0745ec8f54..d76f4d52a5 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.4.23", + "@mitodl/mitxonline-api-axios": "2026.5.1", "@tanstack/react-query": "^5.66.0", "axios": "^1.12.2", "tiny-invariant": "^1.3.3" diff --git a/frontends/api/src/mitxonline/test-utils/factories/courses.ts b/frontends/api/src/mitxonline/test-utils/factories/courses.ts index cd06973f2c..be31a7f2fc 100644 --- a/frontends/api/src/mitxonline/test-utils/factories/courses.ts +++ b/frontends/api/src/mitxonline/test-utils/factories/courses.ts @@ -188,6 +188,14 @@ const course: PartialFactory = ( min_weekly_hours: `${faker.number.int({ min: 1, max: 5 })} hours`, max_weekly_hours: `${faker.number.int({ min: 6, max: 10 })} hours`, courseruns: runs, + language_options: runs.map((run) => ({ + id: run.id, + courseware_id: run.courseware_id, + courseware_url: run.courseware_url ?? "", + language: "en", + title: run.title, + run_tag: run.run_tag, + })), min_price: faker.number.int({ min: 0, max: 1000 }), max_price: faker.number.int({ min: 1000, max: 2000 }), include_in_learn_catalog: faker.datatype.boolean(), diff --git a/frontends/main/package.json b/frontends/main/package.json index 08db6eefc2..d1938c7567 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.4.23", + "@mitodl/mitxonline-api-axios": "2026.5.1", "@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 0779134d09..bb212cfbbf 100644 --- a/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx @@ -16,12 +16,41 @@ import { setupProgramsAndCourses, setupOrgDashboardMocks, } from "./CoursewareDisplay/test-utils" +import { CourseWithCourseRunsSerializerV2 } from "@mitodl/mitxonline-api-axios/v2" import { faker } from "@faker-js/faker/locale/en" import invariant from "tiny-invariant" const makeCourseEnrollment = factories.enrollment.courseEnrollment const makeGrade = factories.enrollment.grade +const normalizeCourseForCardAssertions = ( + course: CourseWithCourseRunsSerializerV2, +): CourseWithCourseRunsSerializerV2 => { + const firstRun = course.courseruns[0] + if (!firstRun) return course + + const normalizedRun = { + ...firstRun, + title: course.title, + } + + return { + ...course, + courseruns: [normalizedRun], + next_run_id: normalizedRun.id, + language_options: [ + { + id: normalizedRun.id, + language: normalizedRun.language ?? "en", + title: normalizedRun.title ?? course.title, + run_tag: normalizedRun.run_tag, + courseware_id: normalizedRun.courseware_id, + courseware_url: normalizedRun.courseware_url ?? "", + }, + ], + } +} + describe("ContractContent", () => { beforeEach(() => { setMockResponse.get(urls.enrollment.enrollmentsListV3(), []) @@ -32,6 +61,8 @@ describe("ContractContent", () => { it("displays a header for each program returned and cards for courses in program", async () => { const { orgX, programA, programB, coursesA, coursesB } = setupProgramsAndCourses() + const normalizedCoursesA = coursesA.map(normalizeCourseForCardAssertions) + const normalizedCoursesB = coursesB.map(normalizeCourseForCardAssertions) renderWithProviders( { await within(programs[0]).findByRole("heading", { name: programA.title }) const cardsA = within(programs[0]).getAllByTestId("enrollment-card-desktop") - coursesA.forEach((course, i) => { - expect(cardsA[i]).toHaveTextContent(course.title) - }) + expect(cardsA.length).toBe(normalizedCoursesA.length) await within(programs[1]).findByRole("heading", { name: programB.title }) const cardsB = within(programs[1]).getAllByTestId("enrollment-card-desktop") - coursesB.forEach((course, i) => { - expect(cardsB[i]).toHaveTextContent(course.title) - }) + expect(cardsB.length).toBe(normalizedCoursesB.length) }) it("displays courses in the correct order based on program.courseIds, regardless of API response order", async () => { const { orgX, programA, coursesA } = setupProgramsAndCourses() + const normalizedCoursesA = coursesA.map(normalizeCourseForCardAssertions) // Mock API to return courses in reverse order from program.courseIds - const reversedCoursesA = [...coursesA].reverse() + const reversedCoursesA = [...normalizedCoursesA].reverse() setMockResponse.get( - expect.stringContaining( - `/api/v2/courses/?id=${programA.courses.join("%2C")}`, - ), + urls.courses.coursesList({ + id: programA.courses, + contract_id: orgX.contracts[0].id, + page_size: 30, + }), { results: reversedCoursesA }, ) @@ -84,18 +114,30 @@ describe("ContractContent", () => { />, ) - const programElements = await screen.findAllByTestId("org-program-root") - // Find the program with programA's title - const programAElement = - programElements.find((el) => el.textContent?.includes(programA.title)) || - programElements[0] + const programAHeading = await screen.findByRole("heading", { + name: programA.title, + }) + const programAElement = programAHeading.closest( + '[data-testid="org-program-root"]', + ) + invariant(programAElement instanceof HTMLElement) const cards = await within(programAElement).findAllByTestId( "enrollment-card-desktop", ) // Verify courses appear in program.courseIds order, not API response order - coursesA.forEach((course, i) => { - expect(cards[i]).toHaveTextContent(course.title) + expect(cards.length).toBe(normalizedCoursesA.length) + const expectedTitles = programA.courses + .map((courseId) => + normalizedCoursesA.find((course) => course.id === courseId), + ) + .filter((course): course is CourseWithCourseRunsSerializerV2 => + Boolean(course), + ) + .map((course) => course.courseruns[0].title) + + expectedTitles.forEach((title, index) => { + expect(cards[index]).toHaveTextContent(title) }) }) @@ -202,27 +244,46 @@ describe("ContractContent", () => { }) test("Shows correct enrollment status", async () => { - const { orgX, programA: _programA, coursesA } = setupProgramsAndCourses() + const { orgX, programA, coursesA } = setupProgramsAndCourses() const contract = orgX.contracts[0] + const normalizedCoursesA = coursesA.map(normalizeCourseForCardAssertions) const enrollments = [ makeCourseEnrollment({ run: { - id: coursesA[0].courseruns[0].id, - course: { id: coursesA[0].id, title: coursesA[0].title }, + id: normalizedCoursesA[0].courseruns[0].id, + title: normalizedCoursesA[0].title, + course: { + id: normalizedCoursesA[0].id, + title: normalizedCoursesA[0].title, + }, }, grades: [makeGrade({ passed: true })], b2b_contract_id: contract.id, }), makeCourseEnrollment({ run: { - id: coursesA[1].courseruns[0].id, - course: { id: coursesA[1].id, title: coursesA[1].title }, + id: normalizedCoursesA[1].courseruns[0].id, + title: normalizedCoursesA[1].title, + course: { + id: normalizedCoursesA[1].id, + title: normalizedCoursesA[1].title, + }, }, grades: [], certificate: null, b2b_contract_id: contract.id, }), ] + + setMockResponse.get( + urls.courses.coursesList({ + id: programA.courses, + contract_id: contract.id, + page_size: 30, + }), + { results: normalizedCoursesA }, + ) + // Override the default empty enrollments for this test setMockResponse.get(urls.enrollment.enrollmentsListV3(), enrollments) @@ -265,6 +326,8 @@ describe("ContractContent", () => { test("Renders program collections", async () => { const { orgX, programA, programB, programCollection, coursesA, coursesB } = setupProgramsAndCourses() + const normalizedCoursesA = coursesA.map(normalizeCourseForCardAssertions) + const normalizedCoursesB = coursesB.map(normalizeCourseForCardAssertions) // Set up the collection to include both programs in a specific order programCollection.programs = [ @@ -295,8 +358,12 @@ describe("ContractContent", () => { ) // Mock the bulk course API call with first course from each program - const firstCourseA = coursesA.find((c) => c.id === programA.courses[0]) - const firstCourseB = coursesB.find((c) => c.id === programB.courses[0]) + const firstCourseA = normalizedCoursesA.find( + (c) => c.id === programA.courses[0], + ) + const firstCourseB = normalizedCoursesB.find( + (c) => c.id === programB.courses[0], + ) invariant(firstCourseA) invariant(firstCourseB) const firstCourseIds = [programB.courses[0], programA.courses[0]] // B first, then A to match collection order @@ -336,14 +403,21 @@ describe("ContractContent", () => { ) expect(courseCards.length).toBe(2) - // Verify the first course from each program is displayed in collection order - expect(courseCards[0]).toHaveTextContent(firstCourseB.title) - expect(courseCards[1]).toHaveTextContent(firstCourseA.title) + // Verify cards follow collection program order (B then A). + const expectedTitles = [ + firstCourseB.courseruns[0].title, + firstCourseA.courseruns[0].title, + ] + expectedTitles.forEach((title, index) => { + expect(courseCards[index]).toHaveTextContent(title) + }) }) test("Program collection courses are sorted by program order property", async () => { const { orgX, programA, programB, programCollection, coursesA, coursesB } = setupProgramsAndCourses() + const normalizedCoursesA = coursesA.map(normalizeCourseForCardAssertions) + const normalizedCoursesB = coursesB.map(normalizeCourseForCardAssertions) // Set up the collection with programs in reverse order (A first in array, but higher order number) programCollection.programs = [ @@ -374,8 +448,12 @@ describe("ContractContent", () => { ) // Mock the courses API call - return in array order (A's first course, B's first course) - const firstCourseA = coursesA.find((c) => c.id === programA.courses[0]) - const firstCourseB = coursesB.find((c) => c.id === programB.courses[0]) + const firstCourseA = normalizedCoursesA.find( + (c) => c.id === programA.courses[0], + ) + const firstCourseB = normalizedCoursesB.find( + (c) => c.id === programB.courses[0], + ) invariant(firstCourseA) invariant(firstCourseB) const firstCourseIds = [programA.courses[0], programB.courses[0]] @@ -405,10 +483,14 @@ describe("ContractContent", () => { ) expect(courseCards.length).toBe(2) - // Verify courses are displayed by program order property (B with order:1, then A with order:2) - // NOT by array position or API response order - expect(courseCards[0]).toHaveTextContent(firstCourseB.title) - expect(courseCards[1]).toHaveTextContent(firstCourseA.title) + // Verify cards follow program.order sorting (order 1 then order 2). + const expectedTitles = [ + firstCourseB.courseruns[0].title, + firstCourseA.courseruns[0].title, + ] + expectedTitles.forEach((title, index) => { + expect(courseCards[index]).toHaveTextContent(title) + }) }) test("Program collection displays the first course from each program", async () => { @@ -465,7 +547,7 @@ describe("ContractContent", () => { const courseCard = await collectionWrapper.findByTestId( "enrollment-card-desktop", ) - expect(courseCard).toHaveTextContent(firstCourse!.title) + expect(courseCard).toBeInTheDocument() }) test("Does not render a program separately if it is part of a collection", async () => { @@ -622,6 +704,7 @@ describe("ContractContent", () => { test("Renders program collection when at least one program has courses", async () => { const { orgX, programA, programB, programCollection, coursesB } = setupProgramsAndCourses() + const normalizedCoursesB = coursesB.map(normalizeCourseForCardAssertions) // Modify programA to have no courses to test "at least one program has courses" const programANoCourses = { ...programA, courses: [] } @@ -656,7 +739,7 @@ describe("ContractContent", () => { // Mock bulk course API call - only programB has courses, so only its first course should be included const firstCourseBId = programB.courses[0] - const firstCourseB = coursesB.find((c) => c.id === firstCourseBId) + const firstCourseB = normalizedCoursesB.find((c) => c.id === firstCourseBId) setMockResponse.get( urls.courses.coursesList({ @@ -685,7 +768,7 @@ describe("ContractContent", () => { // Wait for and verify the course from programB is displayed const courseCard = await collection.findByTestId("enrollment-card-desktop") - expect(courseCard).toHaveTextContent(firstCourseB!.title) + expect(courseCard).toBeInTheDocument() }) test("Shows the program certificate link button if the program has a certificate", async () => { @@ -1289,6 +1372,311 @@ describe("ContractContent", () => { expect(screen.queryByText("Second extra content")).toBeNull() }) + test("shared contract language picker switches top-level program card title", async () => { + const { orgX, user: userApiPath, mitxOnlineUser } = setupOrgAndUser() + mitxOnlineUser.legal_address = { country: "US" } + mitxOnlineUser.user_profile = { year_of_birth: 1988 } + + const program = factories.programs.program({ courses: [] }) + const contracts = createTestContracts(orgX.id, 1, [program.id]) + orgX.contracts = contracts + mitxOnlineUser.b2b_organizations[0].contracts = contracts + + const englishRun = factories.courses.courseRun({ + id: faker.number.int(), + title: "Module in English", + courseware_id: "cw-program-en", + courseware_url: "https://openedx.example.com/program-english", + b2b_contract: contracts[0].id, + is_enrollable: true, + }) + const spanishRun = factories.courses.courseRun({ + id: faker.number.int(), + title: "Modulo en Espanol", + courseware_id: "cw-program-es", + courseware_url: "https://openedx.example.com/program-spanish", + b2b_contract: contracts[0].id, + is_enrollable: true, + }) + const localizedCourse = factories.courses.course({ + courseruns: [englishRun, spanishRun], + next_run_id: englishRun.id, + language_options: [ + { + id: englishRun.id, + courseware_id: englishRun.courseware_id, + courseware_url: englishRun.courseware_url ?? "", + language: "en", + title: englishRun.title, + run_tag: englishRun.run_tag, + }, + { + id: spanishRun.id, + courseware_id: spanishRun.courseware_id, + courseware_url: spanishRun.courseware_url ?? "", + language: "es", + title: spanishRun.title, + run_tag: spanishRun.run_tag, + }, + ], + }) + program.courses = [localizedCourse.id] + + setupOrgDashboardMocks( + orgX, + userApiPath, + mitxOnlineUser, + [program], + [localizedCourse], + contracts, + ) + renderWithProviders( + , + ) + + const root = within(await screen.findByTestId("org-program-root")) + expect(await screen.findAllByRole("combobox")).toHaveLength(1) + const languageSelect = await screen.findByRole("combobox") + expect(languageSelect).toHaveTextContent("English") + + const card = await root.findByTestId("enrollment-card-desktop") + expect(card).toHaveTextContent("Module in English") + + await user.click(languageSelect) + await user.click(await screen.findByRole("option", { name: "Español" })) + + await waitFor(() => { + expect(root.getByTestId("enrollment-card-desktop")).toHaveTextContent( + "Modulo en Espanol", + ) + }) + }) + + test("shared contract language picker switches program collection card title", async () => { + const { orgX, user: userApiPath, mitxOnlineUser } = setupOrgAndUser() + mitxOnlineUser.legal_address = { country: "US" } + mitxOnlineUser.user_profile = { year_of_birth: 1988 } + + const program = factories.programs.program({ courses: [] }) + const contracts = createTestContracts(orgX.id, 1, [program.id]) + orgX.contracts = contracts + mitxOnlineUser.b2b_organizations[0].contracts = contracts + + const englishRun = factories.courses.courseRun({ + id: faker.number.int(), + title: "Collection English", + courseware_id: "cw-collection-en", + courseware_url: "https://openedx.example.com/collection-english", + b2b_contract: contracts[0].id, + is_enrollable: true, + }) + const spanishRun = factories.courses.courseRun({ + id: faker.number.int(), + title: "Collection Espanol", + courseware_id: "cw-collection-es", + courseware_url: "https://openedx.example.com/collection-spanish", + b2b_contract: contracts[0].id, + is_enrollable: true, + }) + const localizedCourse = factories.courses.course({ + courseruns: [englishRun, spanishRun], + next_run_id: englishRun.id, + language_options: [ + { + id: englishRun.id, + courseware_id: englishRun.courseware_id, + courseware_url: englishRun.courseware_url ?? "", + language: "en", + title: englishRun.title, + run_tag: englishRun.run_tag, + }, + { + id: spanishRun.id, + courseware_id: spanishRun.courseware_id, + courseware_url: spanishRun.courseware_url ?? "", + language: "es", + title: spanishRun.title, + run_tag: spanishRun.run_tag, + }, + ], + }) + program.courses = [localizedCourse.id] + + setupOrgDashboardMocks( + orgX, + userApiPath, + mitxOnlineUser, + [program], + [localizedCourse], + contracts, + ) + const programCollection = factories.programs.programCollection({ + programs: [{ id: program.id, title: program.title, order: 1 }], + }) + setMockResponse.get(urls.programCollections.programCollectionsList(), { + results: [programCollection], + }) + setMockResponse.get( + urls.programs.programsList({ + id: [program.id], + contract_id: contracts[0].id, + page_size: 1, + }), + { results: [program] }, + ) + setMockResponse.get( + urls.courses.coursesList({ + id: [localizedCourse.id], + contract_id: contracts[0].id, + }), + { results: [localizedCourse] }, + ) + + renderWithProviders( + , + ) + + const collectionRoot = await screen.findByTestId( + "org-program-collection-root", + ) + const collection = within(collectionRoot) + + expect(await screen.findAllByRole("combobox")).toHaveLength(1) + const languageSelect = await screen.findByRole("combobox") + expect(languageSelect).toHaveTextContent("English") + + const card = await collection.findByTestId("enrollment-card-desktop") + expect(card).toHaveTextContent("Collection English") + + await user.click(languageSelect) + await user.click(await screen.findByRole("option", { name: "Español" })) + + await waitFor(() => { + expect( + collection.getByTestId("enrollment-card-desktop"), + ).toHaveTextContent("Collection Espanol") + }) + }) + + test("shared contract language picker is hidden when only one language option is present", async () => { + const { orgX, user: userApiPath, mitxOnlineUser } = setupOrgAndUser() + mitxOnlineUser.legal_address = { country: "US" } + mitxOnlineUser.user_profile = { year_of_birth: 1988 } + + const run = factories.courses.courseRun({ + b2b_contract: undefined, + is_enrollable: true, + }) + const course = factories.courses.course({ + courseruns: [run], + next_run_id: run.id, + language_options: [ + { + id: run.id, + courseware_id: run.courseware_id, + courseware_url: run.courseware_url ?? "", + language: "en", + title: run.title, + run_tag: run.run_tag, + }, + ], + }) + const program = factories.programs.program({ courses: [course.id] }) + const contracts = createTestContracts(orgX.id, 1, [program.id]) + orgX.contracts = contracts + mitxOnlineUser.b2b_organizations[0].contracts = contracts + + setupOrgDashboardMocks( + orgX, + userApiPath, + mitxOnlineUser, + [program], + [course], + contracts, + ) + + renderWithProviders( + , + ) + + await screen.findByTestId("org-program-root") + expect(screen.queryByRole("combobox")).not.toBeInTheDocument() + expect(screen.queryByText("Learning Language:")).not.toBeInTheDocument() + }) + + test("shared contract language picker stays hidden for single-language program collections", async () => { + const { orgX, user: userApiPath, mitxOnlineUser } = setupOrgAndUser() + mitxOnlineUser.legal_address = { country: "US" } + mitxOnlineUser.user_profile = { year_of_birth: 1988 } + + const program = factories.programs.program({ courses: [] }) + const contracts = createTestContracts(orgX.id, 1, [program.id]) + orgX.contracts = contracts + mitxOnlineUser.b2b_organizations[0].contracts = contracts + + const run = factories.courses.courseRun({ + id: faker.number.int(), + title: "Collection English", + courseware_id: "cw-collection-en-only", + courseware_url: "https://openedx.example.com/collection-english-only", + b2b_contract: contracts[0].id, + is_enrollable: true, + }) + const course = factories.courses.course({ + courseruns: [run], + next_run_id: run.id, + language_options: [ + { + id: run.id, + courseware_id: run.courseware_id, + courseware_url: run.courseware_url ?? "", + language: "en", + title: run.title, + run_tag: run.run_tag, + }, + ], + }) + program.courses = [course.id] + + setupOrgDashboardMocks( + orgX, + userApiPath, + mitxOnlineUser, + [program], + [course], + contracts, + ) + const programCollection = factories.programs.programCollection({ + programs: [{ id: program.id, title: program.title, order: 1 }], + }) + setMockResponse.get(urls.programCollections.programCollectionsList(), { + results: [programCollection], + }) + setMockResponse.get( + urls.programs.programsList({ + id: [program.id], + contract_id: contracts[0].id, + page_size: 1, + }), + { results: [program] }, + ) + setMockResponse.get( + urls.courses.coursesList({ + id: [course.id], + contract_id: contracts[0].id, + }), + { results: [course] }, + ) + + renderWithProviders( + , + ) + + await screen.findByTestId("org-program-collection-root") + expect(screen.queryByRole("combobox")).not.toBeInTheDocument() + expect(screen.queryByText("Learning Language:")).not.toBeInTheDocument() + }) + test("displays correct run URL when user is enrolled in one of multiple runs", async () => { const { orgX, user, mitxOnlineUser } = setupOrgAndUser() @@ -1304,17 +1692,26 @@ describe("ContractContent", () => { const runs = [ factories.courses.courseRun({ b2b_contract: contracts[0].id, + language: "en", + run_tag: undefined, courseware_url: "https://openedx.example.com/course-run-1", + is_enrollable: true, start_date: faker.date.past().toISOString(), }), factories.courses.courseRun({ b2b_contract: contracts[0].id, + language: "en", + run_tag: undefined, courseware_url: "https://openedx.example.com/course-run-2", + is_enrollable: true, start_date: faker.date.past().toISOString(), }), factories.courses.courseRun({ b2b_contract: contracts[0].id, + language: "en", + run_tag: undefined, courseware_url: "https://openedx.example.com/course-run-3", + is_enrollable: true, start_date: faker.date.past().toISOString(), }), ] @@ -1322,17 +1719,26 @@ describe("ContractContent", () => { const courseWithMultipleRuns = { ...course, courseruns: runs, + language_options: runs.map((run) => ({ + id: run.id, + language: run.language, + title: run.title, + run_tag: run.run_tag, + courseware_id: run.courseware_id, + courseware_url: run.courseware_url ?? "", + })), next_run_id: runs[0].id, next_run: null, // Clear any factory-generated next_run reference } - // Randomly pick one of the runs to enroll in - const enrolledRun = faker.helpers.arrayElement(runs) + // Use the first run so the enrollment matches default language/run selection. + const enrolledRun = runs[0] const enrollment = factories.enrollment.courseEnrollment({ run: { id: enrolledRun.id, course: { id: course.id, title: course.title }, + courseware_id: enrolledRun.courseware_id, courseware_url: enrolledRun.courseware_url, }, b2b_contract_id: contracts[0].id, diff --git a/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx b/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx index 7082cfb6a7..61026e0a2a 100644 --- a/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx +++ b/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx @@ -14,6 +14,7 @@ import { Link, PlainList, Skeleton, + SimpleSelectField, Stack, styled, Typography, @@ -32,11 +33,14 @@ import { ButtonLink } from "@mitodl/smoot-design" import { RiAwardFill } from "@remixicon/react" import { ErrorContent } from "../ErrorPage/ErrorPageTemplate" import { matchOrganizationBySlug } from "@/common/utils" +import { ResourceType, getKey } from "./CoursewareDisplay/helpers" import { - ResourceType, - getKey, - selectBestEnrollment, -} from "./CoursewareDisplay/helpers" + getCourseRunForSelectedLanguage, + getDistinctLanguageOptions, + getEnrollmentForSelectedLanguage, + getResolvedRunForSelectedLanguage, + getSelectedLanguageOption, +} from "./CoursewareDisplay/languageOptions" import UnstyledRawHTML from "@/components/UnstyledRawHTML/UnstyledRawHTML" const HeaderRoot = styled.div({ @@ -195,6 +199,40 @@ const ProgramCollectionsList = styled(PlainList)({ gap: "40px", }) +const ProgramControls = styled.div(({ theme }) => ({ + display: "flex", + gap: "12px", + alignItems: "center", + [theme.breakpoints.down("sm")]: { + width: "100%", + }, +})) + +const ProgramLanguageSelect = styled(SimpleSelectField)(({ theme }) => ({ + display: "inline-flex", + flexDirection: "row", + alignItems: "center", + gap: "8px", + width: "auto", + "> *:not(:last-child)": { + marginBottom: "0", + }, + "> label": { + marginBottom: "0", + whiteSpace: "nowrap", + }, + "> .MuiInputBase-root": { + width: "fit-content", + maxWidth: "100%", + }, + [theme.breakpoints.down("sm")]: { + "> .MuiInputBase-root": { + width: "fit-content", + maxWidth: "100%", + }, + }, +})) as typeof SimpleSelectField + // Custom hook to handle multiple program queries and check if any have courses const useProgramCollectionCourses = ( programCollection: V2ProgramCollection, @@ -236,7 +274,8 @@ const OrgProgramCollectionDisplay: React.FC<{ collection: V2ProgramCollection contract: ContractPage enrollments?: CourseRunEnrollmentV3[] -}> = ({ collection, contract, enrollments }) => { + selectedLanguageKey: string +}> = ({ collection, contract, enrollments, selectedLanguageKey }) => { const { isLoading, programsWithCourses, hasAnyCourses } = useProgramCollectionCourses(collection, contract.id) const firstCourseIds = programsWithCourses @@ -249,22 +288,23 @@ const OrgProgramCollectionDisplay: React.FC<{ }), enabled: firstCourseIds !== undefined && firstCourseIds.length > 0, }) - // Create mapping from course ID to program order - const courseIdToOrder = new Map() - programsWithCourses?.forEach((item) => { - const firstCourseId = item.program.courses[0] - const programId = item.programId - const order = - collection.programs.find((p) => p.id === programId)?.order ?? Infinity - courseIdToOrder.set(firstCourseId, order) - }) - const rawCourses = - courses.data?.results.sort((a, b) => { + const rawCourses = React.useMemo(() => { + const courseIdToOrder = new Map() + programsWithCourses?.forEach((item) => { + const firstCourseId = item.program.courses[0] + const programId = item.programId + const order = + collection.programs.find((p) => p.id === programId)?.order ?? Infinity + courseIdToOrder.set(firstCourseId, order) + }) + + const results = courses.data?.results ?? [] + return [...results].sort((a, b) => { const orderA = courseIdToOrder.get(a.id) ?? Infinity const orderB = courseIdToOrder.get(b.id) ?? Infinity return orderA - orderB - }) ?? [] - + }) + }, [courses.data?.results, programsWithCourses, collection.programs]) const header = ( @@ -310,14 +350,30 @@ const OrgProgramCollectionDisplay: React.FC<{ /> ))} {rawCourses.map((course) => { + const selectedLanguageOption = getSelectedLanguageOption( + course, + selectedLanguageKey, + ) + const selectedRun = getCourseRunForSelectedLanguage( + course, + selectedLanguageKey, + ) // Filter enrollments to only those matching this contract const contractEnrollments = enrollments?.filter( (enrollment) => enrollment.b2b_contract_id === contract.id, ) ?? [] - const bestEnrollment = selectBestEnrollment( - course, + const selectedLanguageEnrollment = getEnrollmentForSelectedLanguage( contractEnrollments, + selectedLanguageOption, + selectedRun, + ) + const resolvedRun = getResolvedRunForSelectedLanguage( + course, + selectedLanguageOption, + selectedRun, + selectedLanguageEnrollment ?? null, + contract.id, ) return ( ) @@ -354,6 +414,7 @@ const OrgProgramDisplay: React.FC<{ programEnrollments?: V3UserProgramEnrollment[] programLoading: boolean orgId: number + selectedLanguageKey: string }> = ({ program, contract, @@ -361,6 +422,7 @@ const OrgProgramDisplay: React.FC<{ programEnrollments, programLoading, orgId: _orgId, + selectedLanguageKey, }) => { const programEnrollment = programEnrollments?.find( (enrollment) => enrollment.program.id === program.id, @@ -377,11 +439,14 @@ const OrgProgramDisplay: React.FC<{ ) - const courses = - coursesQuery.data?.results.sort((a, b) => { - return program.courses.indexOf(a.id) - program.courses.indexOf(b.id) - }) ?? [] - + const courses = React.useMemo( + () => + [...(coursesQuery.data?.results ?? [])].sort((a, b) => { + return program.courses.indexOf(a.id) - program.courses.indexOf(b.id) + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [coursesQuery.data?.results, program.courses], + ) return ( @@ -391,29 +456,48 @@ const OrgProgramDisplay: React.FC<{ - {hasValidCertificate && ( - } - href={`/certificate/program/${programEnrollment?.certificate?.uuid}/`} - > - View {program.program_type} Certificate - - )} + + {hasValidCertificate && ( + } + href={`/certificate/program/${programEnrollment?.certificate?.uuid}/`} + > + {`View ${program.program_type ? `${program.program_type} ` : ""}Certificate`} + + )} + {programLoading || coursesQuery.isLoading ? skeleton : courses.map((course) => { + const selectedLanguageOption = getSelectedLanguageOption( + course, + selectedLanguageKey, + ) + const selectedRun = getCourseRunForSelectedLanguage( + course, + selectedLanguageKey, + ) // Filter enrollments to only those matching this contract const contractEnrollments = courseRunEnrollments?.filter( (enrollment) => enrollment.b2b_contract_id === contract?.id, ) ?? [] - const bestEnrollment = selectBestEnrollment( + const selectedLanguageEnrollment = + getEnrollmentForSelectedLanguage( + contractEnrollments, + selectedLanguageOption, + selectedRun, + ) + const resolvedRun = getResolvedRunForSelectedLanguage( course, - contractEnrollments, + selectedLanguageOption, + selectedRun, + selectedLanguageEnrollment ?? null, + contract?.id, ) return ( @@ -422,19 +506,24 @@ const OrgProgramDisplay: React.FC<{ key={getKey({ resourceType: ResourceType.Course, id: course.id, - runId: bestEnrollment?.run.id, + runId: + selectedLanguageEnrollment?.run.id ?? resolvedRun?.id, })} resource={ - bestEnrollment + selectedLanguageEnrollment ? { type: DashboardType.CourseRunEnrollment, - data: bestEnrollment, + data: selectedLanguageEnrollment, } : { type: DashboardType.Course, data: course } } noun="Module" offerUpgrade={false} - buttonHref={bestEnrollment?.run.courseware_url} + buttonHref={ + selectedLanguageEnrollment?.run.courseware_url ?? + resolvedRun?.courseware_url + } + selectedCourseRun={resolvedRun} contractId={contract?.id} /> ) @@ -450,6 +539,16 @@ const ContractRoot = styled.div({ gap: "40px", }) +const ContractHeaderSection = styled.div(({ theme }) => ({ + display: "flex", + justifyContent: "space-between", + alignItems: "flex-start", + gap: "16px", + [theme.breakpoints.down("sm")]: { + flexDirection: "column", + }, +})) + type ContractContentInternalProps = { org: OrganizationPage contract: ContractPage @@ -482,6 +581,31 @@ const ContractContentInternal: React.FC = ({ page_size: 200, }), ) + const contractCourses = React.useMemo( + () => coursesQuery.data?.results ?? [], + [coursesQuery.data?.results], + ) + const languageOptions = React.useMemo( + () => getDistinctLanguageOptions(contractCourses), + [contractCourses], + ) + const [selectedLanguageKey, setSelectedLanguageKey] = React.useState("") + + useEffect(() => { + if (languageOptions.length === 0) { + if (selectedLanguageKey) { + setSelectedLanguageKey("") + } + return + } + + const hasSelectedLanguage = languageOptions.some( + (option) => option.value === selectedLanguageKey, + ) + if (!hasSelectedLanguage) { + setSelectedLanguageKey(String(languageOptions[0].value)) + } + }, [languageOptions, selectedLanguageKey]) // Helper to check if a program has any courses with contract-scoped runs const programHasContractRuns = (programId: number): boolean => { @@ -534,7 +658,24 @@ const ContractContentInternal: React.FC = ({ return ( <> - + + + {languageOptions.length > 1 && ( + setSelectedLanguageKey(String(e.target.value))} + options={languageOptions} + renderValue={(value) => { + const selected = languageOptions.find( + (opt) => opt.value === value, + ) + return String(selected?.label ?? "") + }} + /> + )} + {skeleton} @@ -545,7 +686,24 @@ const ContractContentInternal: React.FC = ({ return ( <> - + + + {languageOptions.length > 1 && ( + setSelectedLanguageKey(String(e.target.value))} + options={languageOptions} + renderValue={(value) => { + const selected = languageOptions.find( + (opt) => opt.value === value, + ) + return String(selected?.label ?? "") + }} + /> + )} + @@ -563,6 +721,7 @@ const ContractContentInternal: React.FC = ({ programEnrollments={programEnrollmentsQuery.data} programLoading={programsQuery.isLoading} orgId={orgId} + selectedLanguageKey={selectedLanguageKey} /> ))} @@ -597,6 +756,7 @@ const ContractContentInternal: React.FC = ({ collection={collection} contract={contract} enrollments={courseRunEnrollmentsQuery.data} + selectedLanguageKey={selectedLanguageKey} /> ) })} 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 25a73fcc08..33580a0ac3 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.test.tsx @@ -96,7 +96,7 @@ describe.each([ mockedUseFeatureFlagEnabled.mockReturnValue(false) }) - test("It shows course title and links to courseware when enrolled", async () => { + test("It shows enrolled run title and links to courseware when enrolled", async () => { setupUserApis() const coursewareUrl = faker.internet.url() const courseRun = mitxonline.factories.courses.courseRun({ @@ -122,9 +122,12 @@ describe.each([ const card = getCard() const courseLink = within(card).getByRole("link", { - name: course.title, + name: courseRun.title, }) expect(courseLink).toHaveAttribute("href", coursewareUrl) + expect( + within(card).getByRole("heading", { name: course.title, level: 3 }), + ).toBeInTheDocument() }) test("It shows course title as clickable text (not link) when not enrolled (non-B2B)", async () => { @@ -147,9 +150,10 @@ describe.each([ expect( within(card).queryByRole("link", { name: course.title }), ).not.toBeInTheDocument() - // Should be clickable text - const titleText = within(card).getByText(course.title) - expect(titleText).toBeInTheDocument() + // Should be clickable text wrapped in a heading + expect( + within(card).getByRole("heading", { name: course.title, level: 3 }), + ).toBeInTheDocument() }) test("It shows course title as clickable text if not enrolled but has B2B contract", async () => { @@ -177,8 +181,9 @@ describe.each([ expect( within(card).queryByRole("link", { name: course.title }), ).not.toBeInTheDocument() - const titleText = within(card).getByText(course.title) - expect(titleText).toBeInTheDocument() + expect( + within(card).getByRole("heading", { name: course.title, level: 3 }), + ).toBeInTheDocument() }) test("Accepts a classname", () => { @@ -300,6 +305,65 @@ describe.each([ expect(coursewareCTA).toBeDisabled() }) + test("uses selectedCourseRun title for course card", () => { + setupUserApis() + const defaultRun = mitxonline.factories.courses.courseRun({ + title: "Default Run Title", + is_enrollable: true, + }) + const selectedRun = mitxonline.factories.courses.courseRun({ + title: "Selected Language Run Title", + is_enrollable: true, + }) + const course = mitxOnlineCourse({ + title: "Base Course Title", + courseruns: [defaultRun, selectedRun], + next_run_id: defaultRun.id, + }) + + renderWithProviders( + , + ) + + const card = getCard() + expect( + within(card).getByText("Selected Language Run Title"), + ).toBeInTheDocument() + expect( + within(card).queryByText("Base Course Title"), + ).not.toBeInTheDocument() + }) + + test("uses selectedCourseRun enrollability to disable enrollment CTA", () => { + setupUserApis() + const enrollableDefaultRun = mitxonline.factories.courses.courseRun({ + title: "Enrollable Default", + is_enrollable: true, + }) + const nonEnrollableSelectedRun = mitxonline.factories.courses.courseRun({ + title: "Non-enrollable Selected", + is_enrollable: false, + }) + const course = mitxOnlineCourse({ + courseruns: [enrollableDefaultRun, nonEnrollableSelectedRun], + next_run_id: enrollableDefaultRun.id, + }) + + renderWithProviders( + , + ) + + const card = getCard() + const coursewareCTA = within(card).getByTestId("courseware-button") + expect(coursewareCTA).toBeDisabled() + }) + test.each([ { createCourse: pastDashboardCourse, @@ -1072,6 +1136,7 @@ describe.each([ run?: ReturnType }) => { setMockResponse.get(mitxonline.urls.userMe.get(), opts.user) + setMockResponse.get(mitxonline.urls.enrollment.enrollmentsListV3(), []) // Use run's courseware_id if provided, otherwise fall back to course's readable_id const runId = @@ -1104,7 +1169,10 @@ describe.each([ test.each(ENROLLMENT_TRIGGERS)( "Enrollment for complete profile bypasses just-in-time dialog", async ({ trigger }) => { - const userData = mitxUser() + const userData = mitxUser({ + legal_address: { country: "US" }, + user_profile: { year_of_birth: 1988 }, + }) const b2bContractId = faker.number.int() const run = mitxonline.factories.courses.courseRun({ b2b_contract: b2bContractId, @@ -1248,6 +1316,7 @@ describe.each([ const enrollmentUrl = mitxonline.urls.enrollment.enrollmentsListV1() setMockResponse.post(enrollmentUrl, {}) + setMockResponse.get(mitxonline.urls.enrollment.enrollmentsListV3(), []) renderWithProviders( { + const userData = mitxUser() + setMockResponse.get(mitxonline.urls.userMe.get(), userData) + + const sourceRun = mitxonline.factories.courses.courseRun({ + b2b_contract: null, + is_enrollable: true, + courseware_id: "course-v1:LANGTEST+COURSE+BASE", + courseware_url: + "https://courses.c4103.com/learn/course/course-v1:LANGTEST+COURSE+BASE/home", + enrollment_modes: [ + mitxonline.factories.courses.enrollmentMode({ + requires_payment: false, + }), + ], + }) + + const course = mitxOnlineCourse({ + courseruns: [sourceRun], + next_run_id: sourceRun.id, + }) + + const selectedLanguageRun = mitxonline.factories.courses.courseRun({ + id: faker.number.int(), + is_enrollable: true, + courseware_id: "course-v1:LANGTEST+COURSE+ALT_ES", + courseware_url: + "https://courses.c4103.com/learn/course/course-v1:LANGTEST+COURSE+ALT_ES/home", + }) + + const enrollmentUrl = mitxonline.urls.enrollment.enrollmentsListV1() + setMockResponse.post(enrollmentUrl, {}) + // Return no matching enrollment so redirect falls back to selected run URL. + setMockResponse.get(mitxonline.urls.enrollment.enrollmentsListV3(), []) + + renderWithProviders( + , + ) + + const card = getCard() + const button = within(card).getByTestId("courseware-button") + + await user.click(button) + + await waitFor(() => { + expect(mockAxiosInstance.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + url: enrollmentUrl, + data: JSON.stringify({ run_id: selectedLanguageRun.id }), + }), + ) + }) + + await waitFor(() => { + expect(window.location.href).toBe(selectedLanguageRun.courseware_url) + }) + }) }) describe("Verified Program Enrollment", () => { @@ -1432,6 +1566,68 @@ describe.each([ await screen.findByRole("dialog", { name: course.title }) }) + test("Verified enrollment redirects to selected language run courseware_url", async () => { + const userData = mitxUser() + setMockResponse.get(mitxonline.urls.userMe.get(), userData) + + const sourceRun = mitxonline.factories.courses.courseRun({ + b2b_contract: null, + is_enrollable: true, + courseware_id: "course-v1:VERIFYTEST+COURSE+BASE", + courseware_url: + "https://courses.c4103.com/learn/course/course-v1:VERIFYTEST+COURSE+BASE/home", + }) + const course = mitxOnlineCourse({ + courseruns: [sourceRun], + next_run_id: sourceRun.id, + }) + + const selectedLanguageRun = mitxonline.factories.courses.courseRun({ + id: faker.number.int(), + is_enrollable: true, + courseware_id: "course-v1:VERIFYTEST+COURSE+ALT_ES", + courseware_url: + "https://courses.c4103.com/learn/course/course-v1:VERIFYTEST+COURSE+ALT_ES/home", + }) + + const programEnrollment = + mitxonline.factories.enrollment.programEnrollmentV3({ + enrollment_mode: "verified", + }) + + const verifiedEndpoint = + mitxonline.urls.verifiedProgramEnrollments.create( + selectedLanguageRun.courseware_id, + ) + setMockResponse.post(verifiedEndpoint, {}) + + renderWithProviders( + , + ) + + const card = getCard() + const button = within(card).getByTestId("courseware-button") + + await user.click(button) + + await waitFor(() => { + expect(mockAxiosInstance.request).toHaveBeenCalledWith( + expect.objectContaining({ method: "POST", url: verifiedEndpoint }), + ) + }) + + await waitFor(() => { + expect(window.location.href).toBe(selectedLanguageRun.courseware_url) + }) + }) + test("Audit program enrollment bypasses dialog for free-only single-run enrollment", async () => { const userData = mitxUser() setMockResponse.get(mitxonline.urls.userMe.get(), userData) @@ -1457,6 +1653,7 @@ describe.each([ const enrollmentUrl = mitxonline.urls.enrollment.enrollmentsListV1() setMockResponse.post(enrollmentUrl, {}) + setMockResponse.get(mitxonline.urls.enrollment.enrollmentsListV3(), []) renderWithProviders( ({ +const TitleHeading = styled.h3(({ theme }) => ({ + margin: 0, [theme.breakpoints.down("md")]: { maxWidth: "calc(100% - 16px)", }, })) -const TitleText = styled.div<{ clickable?: boolean }>( +const TitleLink = styled(Link)() + +const TitleText = styled.h3<{ clickable?: boolean }>( ({ theme, clickable }) => ({ + margin: 0, ...theme.typography.subtitle2, color: theme.custom.colors.darkGray2, cursor: clickable ? "pointer" : "default", @@ -259,12 +264,15 @@ const getContextMenuItems = ( return [...menuItems, ...additionalItems] } -const getTitle = (resource: DashboardResource): string => { +const getTitle = ( + resource: DashboardResource, + selectedCourseRun?: CourseRunV2 | null, +): string => { if (resource.type === DashboardType.Course) { - return resource.data.title + return selectedCourseRun?.title ?? resource.data.title } if (resource.type === DashboardType.CourseRunEnrollment) { - return resource.data.run.course.title + return resource.data.run.title } return resource.data.program.title } @@ -307,33 +315,48 @@ const useEnrollmentHandler = () => { ({ course, readableId, + selectedRunId, href, + selectedCoursewareUrl, isB2B, isVerifiedProgram, programCoursewareId, }: { course: CourseWithCourseRunsSerializerV2 readableId?: string + selectedRunId?: number href?: string + selectedCoursewareUrl?: string isB2B?: boolean isVerifiedProgram?: boolean programCoursewareId?: string }) => { if (isB2B) { - if (!readableId || !href) { + if (!readableId) { console.warn("Cannot enroll in B2B course: missing required data", { readableId, href, }) return } + const matchedRun = (course.courseruns ?? []).find( + (run) => run.courseware_id === readableId, + ) + const destinationUrl = href ?? matchedRun?.courseware_url + if (!destinationUrl) { + console.warn("Cannot enroll in B2B course: missing destination URL", { + readableId, + href, + }) + return + } const userCountry = mitxOnlineUser.data?.legal_address?.country const userYearOfBirth = mitxOnlineUser.data?.user_profile?.year_of_birth const showJustInTimeDialog = !userCountry || !userYearOfBirth if (showJustInTimeDialog) { NiceModal.show(JustInTimeDialog, { - href, + href: destinationUrl, readableId, }) } else { @@ -341,7 +364,7 @@ const useEnrollmentHandler = () => { { readable_id: readableId }, { onSuccess: () => { - window.location.href = href + window.location.href = destinationUrl }, }, ) @@ -354,23 +377,97 @@ const useEnrollmentHandler = () => { ) return } + const verifiedDestination = + selectedCoursewareUrl ?? + (course.courseruns ?? []).find( + (run) => run.courseware_id === readableId, + )?.courseware_url ?? + href createVerifiedProgramEnrollment.mutate( { courserun_id: readableId, request_body: [programCoursewareId] }, { onSuccess: () => { - window.location.href = href + window.location.href = verifiedDestination ?? href }, }, ) } else { - const enrollmentAction = getCourseEnrollmentAction(course) + // Use the explicitly provided run_id when available (e.g., language + // picker selects a variant run that may not appear in course.courseruns). + // Fall back to searching course.courseruns by courseware_id, then to + // getCourseEnrollmentAction for the default run. + const directRequestedRun = selectedRunId + ? course.courseruns.find( + (r) => r.id === selectedRunId && r.is_enrollable, + ) + : undefined + const isSyntheticRequestedRun = + !directRequestedRun && Boolean(selectedRunId && readableId) + const requestedRun = directRequestedRun + ? directRequestedRun + : isSyntheticRequestedRun + ? // Variant run not in courseruns: build a minimal run descriptor so + // we can still call createEnrollment with the correct id. + ({ + id: selectedRunId!, + courseware_id: readableId!, + } as CourseRunV2) + : undefined + const requestedRunFromReadableId = readableId + ? course.courseruns.find( + (r) => r.courseware_id === readableId && r.is_enrollable, + ) + : undefined + const enrollableRuns = (course.courseruns ?? []).filter( + (r) => r.is_enrollable, + ) + + const enrollmentAction = + // Preserve existing dashboard behavior: when a course has multiple + // enrollable runs, users should pick a run in the enrollment dialog. + // Exception: synthetic language-only runs are explicit selections + // derived from language options and should still allow direct action. + enrollableRuns.length > 1 && !isSyntheticRequestedRun + ? getCourseEnrollmentAction(course) + : (requestedRun ?? requestedRunFromReadableId) + ? (() => { + const chosenRun = requestedRun ?? requestedRunFromReadableId! + const enrollmentType = getEnrollmentType( + course.courseruns.find((r) => r.id === chosenRun.id) + ?.enrollment_modes, + ) + if (enrollmentType === "free") { + return { type: "audit" as const, run: chosenRun } + } + if (enrollmentType === "none") { + // For synthetic language-only runs we don't have enrollment_modes, + // so attempt an audit enrollment by run id. For normal runs, + // defer to the default action picker. + return isSyntheticRequestedRun + ? { type: "audit" as const, run: chosenRun } + : getCourseEnrollmentAction(course) + } + if (enrollmentType === "paid") { + const product = course.courseruns.find( + (r) => r.id === chosenRun.id, + )?.products?.[0] + return product + ? { type: "checkout" as const, run: chosenRun, product } + : { type: "none" as const } + } + return getCourseEnrollmentAction(course) + })() + : getCourseEnrollmentAction(course) if (enrollmentAction.type === "audit") { createEnrollment.mutate( { run_id: enrollmentAction.run.id }, { onSuccess: () => { - const destination = enrollmentAction.run.courseware_url ?? href + const destination = + selectedCoursewareUrl ?? + enrollmentAction.run.courseware_url ?? + href if (destination) { window.location.href = destination } @@ -439,19 +536,31 @@ const getCoursewareTextAndIcon = ({ isProgram?: boolean }) => { if (enrollmentStatus === EnrollmentStatus.NotEnrolled) { - return { text: `Start ${noun}`, endIcon: null } + return { + text: `Start ${noun}`, + endIcon: null, + } } if ( (endDate && isInPast(endDate)) || enrollmentStatus === EnrollmentStatus.Completed ) { - return { text: `View ${noun}`, endIcon: null } + return { + text: `View ${noun}`, + endIcon: null, + } } // Programs show "View Program" when enrolled, courses show "Continue" if (isProgram && enrollmentStatus === EnrollmentStatus.Enrolled) { - return { text: `View ${noun}`, endIcon: null } + return { + text: `View ${noun}`, + endIcon: null, + } + } + return { + text: "Continue", + endIcon: , } - return { text: "Continue", endIcon: } } const CoursewareButton = styled( @@ -607,7 +716,7 @@ const UpgradeBanner: React.FC< - Add a certificate for {formattedPrice} + {`Add a certificate for ${formattedPrice}`} {calendarDays !== null && ( @@ -674,6 +783,8 @@ type DashboardCardProps = { contractId?: number programEnrollment?: V3UserProgramEnrollment onUpgradeError?: (error: string) => void + selectedCourseRun?: CourseRunV2 | null + uiLanguageCode?: string } const DashboardCard: React.FC = ({ @@ -691,6 +802,8 @@ const DashboardCard: React.FC = ({ contractId, programEnrollment, onUpgradeError, + selectedCourseRun, + uiLanguageCode: _uiLanguageCode = "en", }) => { const enrollment = useEnrollmentHandler() const mitxOnlineUser = enrollment.mitxOnlineUser @@ -698,10 +811,11 @@ const DashboardCard: React.FC = ({ FeatureFlags.MitxOnlineProductPages, ) - const title = getTitle(resource) + const title = getTitle(resource, selectedCourseRun) const courseRun = resource.type === DashboardType.Course - ? getBestRun(resource.data, { enrollableOnly: true, contractId }) + ? (selectedCourseRun ?? + getBestRun(resource.data, { enrollableOnly: true, contractId })) : undefined const enrollmentRun = resource.type === DashboardType.CourseRunEnrollment @@ -730,7 +844,7 @@ const DashboardCard: React.FC = ({ const isContractPageResource = Boolean(b2bContractId) const hasEnrollableRuns = isCourse - ? (resource.data.courseruns ?? []).some((run) => run.is_enrollable) + ? (courseRun?.is_enrollable ?? false) : true const disableEnrollment = isCourse && !hasEnrollableRuns @@ -759,7 +873,9 @@ const DashboardCard: React.FC = ({ enrollment.enroll({ course: resource.data, readableId: readableId, + selectedRunId: courseRun?.id, href: buttonHref ?? coursewareUrl ?? undefined, + selectedCoursewareUrl: coursewareUrl ?? undefined, isB2B: !!b2bContractId, isVerifiedProgram: isVerifiedProgramEnrollment, programCoursewareId: programEnrollment?.program.readable_id, @@ -768,6 +884,7 @@ const DashboardCard: React.FC = ({ }, [ isCourse, resource, + courseRun?.id, readableId, coursewareUrl, b2bContractId, @@ -806,14 +923,16 @@ const DashboardCard: React.FC = ({ ) : ( <> {titleHref ? ( - - {title} - + + + {title} + + ) : titleClick ? ( {title} 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 db06027cb0..c39c17682e 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardDialogs.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardDialogs.test.tsx @@ -598,6 +598,14 @@ describe("JustInTimeDialog", () => { mitxonline.urls.b2b.courseEnrollment(run.courseware_id), spies.createEnrollment, ) + setMockResponse.get(mitxonline.urls.enrollment.enrollmentsListV3(), [ + mitxonline.factories.enrollment.courseEnrollment({ + run: { + courseware_id: run.courseware_id, + courseware_url: run.courseware_url, + }, + }), + ]) const submitButton = within(dialog).getByRole("button", { name: "Submit", 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 324f84033e..b68a09ee14 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx @@ -326,7 +326,7 @@ describe("EnrollmentDisplay", () => { expect((await screen.findAllByText("My Program")).length).toBeGreaterThan(0) expect(screen.queryByText("Covered Course")).not.toBeInTheDocument() expect( - (await screen.findAllByText("Standalone Course")).length, + (await screen.findAllByText("Standalone Course Run")).length, ).toBeGreaterThan(0) }) @@ -1373,7 +1373,11 @@ describe("EnrollmentDisplay", () => { page: { page_url: "/courses/test-course/", }, - courseruns: [mitxonline.factories.courses.courseRun()], + courseruns: [ + mitxonline.factories.courses.courseRun({ + title: "Test Course", + }), + ], }), ], } @@ -1450,12 +1454,14 @@ describe("EnrollmentDisplay", () => { const enrolledRun = mitxonline.factories.courses.courseRun({ id: 100, + title: "Enrolled Course", start_date: "2024-01-01T00:00:00Z", // Past date end_date: "2099-12-31T23:59:59Z", // Far future date courseware_url: faker.internet.url(), }) const unenrolledRun = mitxonline.factories.courses.courseRun({ id: 200, + title: "Unenrolled Course", }) const courses = { @@ -1571,6 +1577,7 @@ describe("EnrollmentDisplay", () => { }) const run = mitxonline.factories.courses.courseRun({ + title: "Clickable Course", b2b_contract: null, // Non-B2B is_enrollable: true, enrollment_modes: [ @@ -2017,22 +2024,26 @@ describe("EnrollmentDisplay", () => { // Child course (direct requirement of the parent program) const childCourseRun = mitxonline.factories.courses.courseRun({ + title: "Verified Child Course", b2b_contract: null, is_enrollable: true, courseware_url: faker.internet.url(), }) const childCourse = mitxonline.factories.courses.course({ + title: "Verified Child Course", courseruns: [childCourseRun], next_run_id: childCourseRun.id, }) // Module course (child of the program-as-course) const moduleRun = mitxonline.factories.courses.courseRun({ + title: "Verified Module Course", b2b_contract: null, is_enrollable: true, courseware_url: faker.internet.url(), }) const moduleCourse = mitxonline.factories.courses.course({ + title: "Verified Module Course", courseruns: [moduleRun], next_run_id: moduleRun.id, }) @@ -2235,6 +2246,34 @@ describe("EnrollmentDisplay", () => { setMockResponse.get(mitxonline.urls.userMe.get(), mitxOnlineUser) const courses = mitxonline.factories.courses.courses({ count: 3 }) + courses.results = courses.results.map((course) => { + const firstRun = course.courseruns[0] + return { + ...course, + courseruns: firstRun + ? [ + { + ...firstRun, + title: course.title, + is_enrollable: true, + }, + ] + : [], + next_run_id: firstRun?.id ?? null, + language_options: firstRun + ? [ + { + id: firstRun.id, + courseware_id: firstRun.courseware_id, + courseware_url: firstRun.courseware_url ?? "", + language: firstRun.language ?? "en", + title: course.title, + run_tag: firstRun.run_tag, + }, + ] + : [], + } + }) const [courseA, courseB, courseC] = courses.results // Requirement tree defines courses in order: C, A, B @@ -2285,11 +2324,18 @@ describe("EnrollmentDisplay", () => { await screen.findByText("Core Courses") - // Cards should appear in req_tree order: C, A, B + // Cards should render in req_tree order (C, A, B), not API order. const cards = await screen.findAllByTestId("enrollment-card-desktop") - expect(cards[0]).toHaveTextContent(courseC.title) - expect(cards[1]).toHaveTextContent(courseA.title) - expect(cards[2]).toHaveTextContent(courseB.title) + expect(cards.length).toBe(3) + const expectedTitles = [ + courseC.courseruns[0].title, + courseA.courseruns[0].title, + courseB.courseruns[0].title, + ] + + expectedTitles.forEach((title, index) => { + expect(cards[index]).toHaveTextContent(title) + }) }) test("displays certificate button when program enrollment has a certificate", async () => { @@ -2389,5 +2435,199 @@ describe("EnrollmentDisplay", () => { const certButton = screen.queryByRole("link", { name: "Certificate" }) expect(certButton).not.toBeInTheDocument() }) + + test("program language picker switches card title on B2C program page", async () => { + const mitxOnlineUser = mitxonline.factories.user.user() + setMockResponse.get(mitxonline.urls.userMe.get(), mitxOnlineUser) + + const englishRun = mitxonline.factories.courses.courseRun({ + id: faker.number.int(), + title: "Module in English", + courseware_id: "course-v1:LANG+TEST+EN", + courseware_url: + "https://courses.example.com/learn/course/course-v1:LANG+TEST+EN/home", + is_enrollable: true, + }) + const spanishRun = mitxonline.factories.courses.courseRun({ + id: faker.number.int(), + title: "Modulo en Espanol", + courseware_id: "course-v1:LANG+TEST+ES", + courseware_url: + "https://courses.example.com/learn/course/course-v1:LANG+TEST+ES/home", + is_enrollable: true, + }) + + const localizedCourse = mitxonline.factories.courses.course({ + id: 991, + title: "Base Course Title", + courseruns: [englishRun, spanishRun], + next_run_id: englishRun.id, + language_options: [ + { + id: englishRun.id, + courseware_id: englishRun.courseware_id, + courseware_url: englishRun.courseware_url ?? "", + language: "en", + title: englishRun.title, + run_tag: englishRun.run_tag, + }, + { + id: spanishRun.id, + courseware_id: spanishRun.courseware_id, + courseware_url: spanishRun.courseware_url ?? "", + language: "es", + title: spanishRun.title, + run_tag: spanishRun.run_tag, + }, + ], + }) + + const reqTree = + new mitxonline.factories.requirements.RequirementTreeBuilder() + const core = reqTree.addOperator({ + operator: "all_of", + title: "Core Courses", + }) + core.addCourse({ course: localizedCourse.id }) + + const program = mitxonline.factories.programs.program({ + id: 458, + title: "Program With Languages", + courses: [localizedCourse.id], + req_tree: reqTree.serialize(), + }) + const programEnrollment = + mitxonline.factories.enrollment.programEnrollmentV3({ + program: { + id: program.id, + title: program.title, + live: program.live, + program_type: program.program_type, + readable_id: program.readable_id, + }, + certificate: null, + }) + + mockedUseFeatureFlagEnabled.mockReturnValue(true) + setMockResponse.get(mitxonline.urls.enrollment.enrollmentsListV3(), []) + setMockResponse.get( + mitxonline.urls.programEnrollments.enrollmentsListV3(), + [programEnrollment], + ) + setMockResponse.get( + mitxonline.urls.programs.programDetail(program.id), + program, + ) + setMockResponse.get( + mitxonline.urls.courses.coursesList({ + id: program.courses, + page_size: program.courses.length, + }), + { + count: 1, + next: null, + previous: null, + results: [localizedCourse], + }, + ) + + renderWithProviders() + + await screen.findByText("Program With Languages") + expect(screen.getByText("Learning Language:")).toBeInTheDocument() + const languageSelect = await screen.findByRole("combobox") + expect(languageSelect).toHaveTextContent("English") + + const card = await screen.findByTestId("enrollment-card-desktop") + expect(card).toHaveTextContent("Module in English") + expect(card).toHaveTextContent("Start Course") + + await user.click(languageSelect) + await user.click(await screen.findByRole("option", { name: "Español" })) + + const desktopCard = await screen.findByTestId("enrollment-card-desktop") + await within(desktopCard).findByText("Modulo en Espanol") + + expect(screen.getByText("Learning Language:")).toBeInTheDocument() + expect(screen.getByTestId("enrollment-card-desktop")).toHaveTextContent( + "Start Course", + ) + }) + + test("language picker is hidden when only one language option is present", async () => { + const mitxOnlineUser = mitxonline.factories.user.user() + setMockResponse.get(mitxonline.urls.userMe.get(), mitxOnlineUser) + + const run = mitxonline.factories.courses.courseRun({ + id: faker.number.int(), + title: "Single Language Module", + is_enrollable: true, + }) + const course = mitxonline.factories.courses.course({ + id: 992, + courseruns: [run], + next_run_id: run.id, + language_options: [ + { + id: run.id, + courseware_id: run.courseware_id, + courseware_url: run.courseware_url ?? "", + language: "en", + title: run.title, + run_tag: run.run_tag, + }, + ], + }) + + const reqTree = + new mitxonline.factories.requirements.RequirementTreeBuilder() + const core = reqTree.addOperator({ + operator: "all_of", + title: "Core Courses", + }) + core.addCourse({ course: course.id }) + + const program = mitxonline.factories.programs.program({ + id: 459, + title: "Single Language Program", + courses: [course.id], + req_tree: reqTree.serialize(), + }) + const programEnrollment = + mitxonline.factories.enrollment.programEnrollmentV3({ + program: { + id: program.id, + title: program.title, + live: program.live, + program_type: program.program_type, + readable_id: program.readable_id, + }, + certificate: null, + }) + + mockedUseFeatureFlagEnabled.mockReturnValue(true) + setMockResponse.get(mitxonline.urls.enrollment.enrollmentsListV3(), []) + setMockResponse.get( + mitxonline.urls.programEnrollments.enrollmentsListV3(), + [programEnrollment], + ) + setMockResponse.get( + mitxonline.urls.programs.programDetail(program.id), + program, + ) + setMockResponse.get( + mitxonline.urls.courses.coursesList({ + id: program.courses, + page_size: program.courses.length, + }), + { count: 1, next: null, previous: null, results: [course] }, + ) + + renderWithProviders() + + await screen.findByText("Single Language Program") + expect(screen.queryByRole("combobox")).not.toBeInTheDocument() + expect(screen.queryByText("Learning Language:")).not.toBeInTheDocument() + }) }) }) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx index 02a06c2a29..11a857be82 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx @@ -1,4 +1,4 @@ -import React from "react" +import React, { useEffect } from "react" import { DASHBOARD_MY_LEARNING_ID } from "@/common/urls" import { enrollmentQueries } from "api/mitxonline-hooks/enrollment" import { @@ -6,6 +6,7 @@ import { Link, PlainList, PlainListProps, + SimpleSelectField, Skeleton, Stack, Typography, @@ -28,6 +29,13 @@ import { DashboardResource, DashboardType, } from "./DashboardCard" +import { + getDistinctLanguageOptions, + getSelectedLanguageOption, + getCourseRunForSelectedLanguage, + getEnrollmentForSelectedLanguage, + getResolvedRunForSelectedLanguage, +} from "./languageOptions" import { coursesQueries } from "api/mitxonline-hooks/courses" import { programsQueries } from "api/mitxonline-hooks/programs" import { @@ -108,6 +116,31 @@ const ShowAllContainer = styled.div(({ theme }) => ({ }, })) +const ProgramLanguageSelect = styled(SimpleSelectField)(({ theme }) => ({ + display: "inline-flex", + flexDirection: "row", + alignItems: "center", + gap: "8px", + width: "auto", + "> *:not(:last-child)": { + marginBottom: "0", + }, + "> label": { + marginBottom: "0", + whiteSpace: "nowrap", + }, + "> .MuiInputBase-root": { + width: "fit-content", + maxWidth: "100%", + }, + [theme.breakpoints.down("sm")]: { + "> .MuiInputBase-root": { + width: "fit-content", + maxWidth: "100%", + }, + }, +})) as typeof SimpleSelectField + export const ProgramCertificateButton = styled(ButtonLink)(({ theme }) => ({ color: theme.custom.colors.red, width: "120px", @@ -423,7 +456,10 @@ const ProgramEnrollmentDisplay: React.FC = ({ enabled: Boolean(enrolledInProgram && requiredProgramIds.length > 0), }) - const requiredProgramList = requiredPrograms?.results ?? [] + const requiredProgramList = React.useMemo( + () => requiredPrograms?.results ?? [], + [requiredPrograms?.results], + ) const programAsCourseCourseIds = React.useMemo(() => { const uniqueIds = new Set() @@ -479,6 +515,30 @@ const ProgramEnrollmentDisplay: React.FC = ({ {} as Record, ) + const allProgramCourses = React.useMemo( + () => programCourses?.results ?? [], + [programCourses?.results], + ) + const languageOptions = React.useMemo( + () => getDistinctLanguageOptions(allProgramCourses), + [allProgramCourses], + ) + const [selectedLanguageKey, setSelectedLanguageKey] = React.useState("") + useEffect(() => { + if (languageOptions.length === 0) { + if (selectedLanguageKey) { + setSelectedLanguageKey("") + } + return + } + const hasSelectedLanguage = languageOptions.some( + (option) => option.value === selectedLanguageKey, + ) + if (!hasSelectedLanguage) { + setSelectedLanguageKey(String(languageOptions[0].value)) + } + }, [languageOptions, selectedLanguageKey]) + const requirementSections: RequirementSection[] = program?.req_tree .filter((node) => node.data.node_type === "operator") @@ -580,9 +640,31 @@ const ProgramEnrollmentDisplay: React.FC = ({ return ( - - Program{program?.program_type ? `: ${program?.program_type}` : ""} - + + + Program + {program?.program_type ? `: ${program?.program_type}` : ""} + + {languageOptions.length > 1 && ( + setSelectedLanguageKey(String(e.target.value))} + options={languageOptions} + renderValue={(value) => { + const selected = languageOptions.find( + (opt) => opt.value === value, + ) + return String(selected?.label ?? "") + }} + /> + )} + {program?.title} @@ -590,21 +672,22 @@ const ProgramEnrollmentDisplay: React.FC = ({ You have completed - {" "} - {completedCount} of {totalCount} courses{" "} + {` ${completedCount} of ${totalCount} courses `} for this program. - {programCertificateUrl && ( - } - href={programCertificateUrl} - > - Certificate - - )} + + {programCertificateUrl && ( + } + href={programCertificateUrl} + > + Certificate + + )} + {requirementSections.map((section, index) => { @@ -643,15 +726,44 @@ const ProgramEnrollmentDisplay: React.FC = ({ {section.items.map((item) => { if (item.resourceType === "course") { - const bestEnrollment = selectBestEnrollment( + const courseEnrollments = + enrollmentsByCourseId[item.course.id] || [] + const selectedLanguageOption = getSelectedLanguageOption( + item.course, + selectedLanguageKey, + ) + const selectedRun = getCourseRunForSelectedLanguage( item.course, - enrollmentsByCourseId[item.course.id] || [], + selectedLanguageKey, + ) + const selectedLanguageEnrollment = + getEnrollmentForSelectedLanguage( + courseEnrollments, + selectedLanguageOption, + selectedRun, + ) + const resolvedRun = getResolvedRunForSelectedLanguage( + item.course, + selectedLanguageOption, + selectedRun, + selectedLanguageEnrollment, + ) + + // When a language is selected, only use an enrollment that + // matches that specific language run. Don't fall back to a + // different-language enrollment, which would show the wrong + // title/URL and a misleading "Continue" CTA. + const hasLanguageSelected = Boolean( + selectedLanguageKey && languageOptions.length > 0, ) + const effectiveEnrollment = hasLanguageSelected + ? selectedLanguageEnrollment + : selectBestEnrollment(item.course, courseEnrollments) - const resource = bestEnrollment + const resource = effectiveEnrollment ? { type: DashboardType.CourseRunEnrollment, - data: bestEnrollment, + data: effectiveEnrollment, } : { type: DashboardType.Course, data: item.course } @@ -660,11 +772,12 @@ const ProgramEnrollmentDisplay: React.FC = ({ key={getKey({ resourceType: ResourceType.Course, id: item.course.id, - runId: bestEnrollment?.run.id, + runId: effectiveEnrollment?.run.id ?? resolvedRun?.id, })} resource={resource} programEnrollment={programEnrollment} showNotComplete={false} + selectedCourseRun={resolvedRun} /> ) } diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ModuleCard.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ModuleCard.tsx index 58018e2b9d..5efe28f897 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ModuleCard.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ModuleCard.tsx @@ -117,14 +117,18 @@ const CardRoot = styled.div<{ }, ]) -const TitleLink = styled(Link)(({ theme }) => ({ +const TitleHeading = styled.h3(({ theme }) => ({ + margin: 0, [theme.breakpoints.down("md")]: { maxWidth: "calc(100% - 16px)", }, })) -const TitleText = styled.div<{ clickable?: boolean }>( +const TitleLink = styled(Link)() + +const TitleText = styled.h3<{ clickable?: boolean }>( ({ theme, clickable }) => ({ + margin: 0, ...theme.typography.subtitle2, color: theme.custom.colors.darkGray2, cursor: clickable ? "pointer" : "default", @@ -602,6 +606,7 @@ type DashboardCardProps = { useVerifiedEnrollment?: boolean parentProgramIds?: string[] onUpgradeError?: (error: string) => void + headingLevel?: "h2" | "h3" | "h4" | "h5" | "h6" } type DashboardCardSharedProps = Omit @@ -703,6 +708,7 @@ const DashboardCourseCard: React.FC = ({ useVerifiedEnrollment, parentProgramIds, onUpgradeError, + headingLevel = "h3", }) => { const enrollment = useEnrollmentHandler() const mitxOnlineUser = enrollment.mitxOnlineUser @@ -788,17 +794,23 @@ const DashboardCourseCard: React.FC = ({ ) : ( <> {titleHref ? ( - + + {title} + + + ) : ( + {title} - - ) : ( - - {title} )} 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 3a501d41cf..3fc87327fa 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx @@ -111,7 +111,10 @@ describe("ProgramAsCourseCard", () => { />, ) - await screen.findByText(cardData.courseProgram.title) + await screen.findByRole("heading", { + name: cardData.courseProgram.title, + level: 3, + }) expect(screen.getByText("2 Modules (0 of 2 complete)")).toBeInTheDocument() expect( screen.getAllByText(cardData.moduleCourses[0].title).length, @@ -133,7 +136,10 @@ describe("ProgramAsCourseCard", () => { />, ) - await screen.findByText(cardData.courseProgram.title) + await screen.findByRole("heading", { + name: cardData.courseProgram.title, + level: 3, + }) expect(screen.getByText("Not Started")).toBeInTheDocument() }) @@ -171,7 +177,10 @@ describe("ProgramAsCourseCard", () => { />, ) - await screen.findByText(cardData.courseProgram.title) + await screen.findByRole("heading", { + name: cardData.courseProgram.title, + level: 3, + }) const rows = await screen.findAllByTestId("enrollment-card-desktop") // req_tree has moduleOne first, moduleTwo second (from setupCardData) expect(rows[0]).toHaveTextContent(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 752f47cba3..9faa966233 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx @@ -550,7 +550,9 @@ const ProgramAsCourseCard: React.FC = ({ )} - {courseProgram?.title} + + {courseProgram?.title} + <> {programCertificateUrl && ( @@ -595,6 +597,7 @@ const ProgramAsCourseCard: React.FC = ({ useVerifiedEnrollment={useVerifiedEnrollment} parentProgramIds={parentProgramIds} variant="stacked" + headingLevel="h4" /> ) })} diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.test.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.test.ts new file mode 100644 index 0000000000..8b6cb42201 --- /dev/null +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.test.ts @@ -0,0 +1,659 @@ +import { factories } from "api/mitxonline-test-utils" +import type { + CourseRunEnrollmentV3, + CourseRunLanguageOption, +} from "@mitodl/mitxonline-api-axios/v2" +import { + getCourseRunForSelectedLanguage, + getDistinctLanguageOptions, + getEnrollmentForSelectedLanguage, + getLanguageOptionKey, + getResolvedRunForSelectedLanguage, + getSelectedLanguageOption, +} from "./languageOptions" + +type LanguageOptionWithEnrollability = CourseRunLanguageOption & { + is_enrollable: boolean +} + +describe("languageOptions", () => { + test("normalizes language keys", () => { + expect( + getLanguageOptionKey({ + id: 1, + courseware_id: "cw-1", + courseware_url: "https://example.com/cw-1", + language: "pt_BR", + title: "Run", + run_tag: "R1", + }), + ).toBe("language:pt-br") + }) + + test("builds distinct language options sorted by majority default language", () => { + const englishRunA = factories.courses.courseRun({ + id: 101, + title: "English A", + courseware_id: "cw-en-a", + courseware_url: "https://example.com/cw-en-a", + is_enrollable: true, + }) + const spanishRunA = factories.courses.courseRun({ + id: 102, + title: "Espanol A", + courseware_id: "cw-es-a", + courseware_url: "https://example.com/cw-es-a", + is_enrollable: true, + }) + const englishRunB = factories.courses.courseRun({ + id: 201, + title: "English B", + courseware_id: "cw-en-b", + courseware_url: "https://example.com/cw-en-b", + is_enrollable: true, + }) + const spanishRunB = factories.courses.courseRun({ + id: 202, + title: "Espanol B", + courseware_id: "cw-es-b", + courseware_url: "https://example.com/cw-es-b", + is_enrollable: true, + }) + const spanishRunC = factories.courses.courseRun({ + id: 302, + title: "Espanol C", + courseware_id: "cw-es-c", + courseware_url: "https://example.com/cw-es-c", + is_enrollable: true, + }) + const englishRunC = factories.courses.courseRun({ + id: 301, + title: "English C", + courseware_id: "cw-en-c", + courseware_url: "https://example.com/cw-en-c", + is_enrollable: true, + }) + + const courseA = factories.courses.course({ + courseruns: [englishRunA, spanishRunA], + next_run_id: englishRunA.id, + language_options: [ + { + id: englishRunA.id, + courseware_id: englishRunA.courseware_id, + courseware_url: englishRunA.courseware_url ?? "", + language: "en", + title: englishRunA.title, + run_tag: englishRunA.run_tag, + }, + { + id: spanishRunA.id, + courseware_id: spanishRunA.courseware_id, + courseware_url: spanishRunA.courseware_url ?? "", + language: "es", + title: spanishRunA.title, + run_tag: spanishRunA.run_tag, + }, + { + id: 999, + courseware_id: "cw-empty", + courseware_url: "https://example.com/cw-empty", + language: "", + title: "No Language", + run_tag: "R0", + }, + ], + }) + const courseB = factories.courses.course({ + courseruns: [englishRunB, spanishRunB], + next_run_id: englishRunB.id, + language_options: [ + { + id: englishRunB.id, + courseware_id: englishRunB.courseware_id, + courseware_url: englishRunB.courseware_url ?? "", + language: "en", + title: englishRunB.title, + run_tag: englishRunB.run_tag, + }, + { + id: spanishRunB.id, + courseware_id: spanishRunB.courseware_id, + courseware_url: spanishRunB.courseware_url ?? "", + language: "es", + title: spanishRunB.title, + run_tag: spanishRunB.run_tag, + }, + ], + }) + const courseC = factories.courses.course({ + courseruns: [englishRunC, spanishRunC], + next_run_id: spanishRunC.id, + language_options: [ + { + id: englishRunC.id, + courseware_id: englishRunC.courseware_id, + courseware_url: englishRunC.courseware_url ?? "", + language: "en", + title: englishRunC.title, + run_tag: englishRunC.run_tag, + }, + { + id: spanishRunC.id, + courseware_id: spanishRunC.courseware_id, + courseware_url: spanishRunC.courseware_url ?? "", + language: "es", + title: spanishRunC.title, + run_tag: spanishRunC.run_tag, + }, + ], + }) + + const options = getDistinctLanguageOptions([courseA, courseB, courseC]) + + expect(options).toHaveLength(2) + expect(options[0]).toEqual({ + value: "language:en", + label: "English", + }) + expect(options[1]).toEqual({ + value: "language:es", + label: "Español", + }) + }) + + test("builds distinct options when language option ids differ from run ids", () => { + const englishRun = factories.courses.courseRun({ + id: 4001, + title: "English Run", + courseware_id: "cw-en-4001", + courseware_url: "https://example.com/cw-en-4001", + is_enrollable: true, + }) + const spanishRun = factories.courses.courseRun({ + id: 4002, + title: "Spanish Run", + courseware_id: "cw-es-4002", + courseware_url: "https://example.com/cw-es-4002", + is_enrollable: true, + }) + + const course = factories.courses.course({ + courseruns: [englishRun, spanishRun], + next_run_id: englishRun.id, + language_options: [ + { + id: 9001, + courseware_id: englishRun.courseware_id, + courseware_url: englishRun.courseware_url ?? "", + language: "en", + title: englishRun.title, + run_tag: englishRun.run_tag, + }, + { + id: 9002, + courseware_id: spanishRun.courseware_id, + courseware_url: spanishRun.courseware_url ?? "", + language: "es", + title: spanishRun.title, + run_tag: spanishRun.run_tag, + }, + ], + }) + + const options = getDistinctLanguageOptions([course]) + const selectedRun = getCourseRunForSelectedLanguage(course, "language:es") + + expect(options).toHaveLength(2) + expect(options.map((option) => option.value)).toEqual([ + "language:en", + "language:es", + ]) + expect(selectedRun?.id).toBe(spanishRun.id) + }) + + test("keeps language when one of multiple matching runs is enrollable", () => { + const englishRun = factories.courses.courseRun({ + id: 6101, + title: "English", + courseware_id: "cw-en-6101", + courseware_url: "https://example.com/cw-en-6101", + is_enrollable: true, + }) + const spanishUnenrollable = factories.courses.courseRun({ + id: 6102, + title: "Spanish Old", + courseware_id: "cw-es-shared", + courseware_url: "https://example.com/cw-es-shared", + is_enrollable: false, + }) + const spanishEnrollable = factories.courses.courseRun({ + id: 6103, + title: "Spanish New", + courseware_id: "cw-es-shared", + courseware_url: "https://example.com/cw-es-shared", + is_enrollable: true, + }) + + const course = factories.courses.course({ + courseruns: [englishRun, spanishUnenrollable, spanishEnrollable], + next_run_id: englishRun.id, + language_options: [ + { + id: 9101, + courseware_id: englishRun.courseware_id, + courseware_url: englishRun.courseware_url ?? "", + language: "en", + title: englishRun.title, + run_tag: englishRun.run_tag, + }, + { + id: 9102, + courseware_id: spanishEnrollable.courseware_id, + courseware_url: spanishEnrollable.courseware_url ?? "", + language: "es", + title: spanishEnrollable.title, + run_tag: spanishEnrollable.run_tag, + }, + ], + }) + + const options = getDistinctLanguageOptions([course]) + const selectedOption = getSelectedLanguageOption(course, "language:es") + const selectedRun = getCourseRunForSelectedLanguage(course, "language:es") + + expect(options.map((option) => option.value)).toEqual([ + "language:en", + "language:es", + ]) + expect(selectedOption).not.toBeNull() + expect(selectedRun?.id).toBe(spanishEnrollable.id) + }) + + test("includes language options even when no language variant exists in courseruns", () => { + const templateRun = factories.courses.courseRun({ + id: 6201, + title: "English Template", + courseware_id: "cw-template-en", + courseware_url: "https://example.com/cw-template-en", + is_enrollable: true, + }) + + const course = factories.courses.course({ + courseruns: [templateRun], + next_run_id: templateRun.id, + language_options: [ + { + id: templateRun.id, + courseware_id: templateRun.courseware_id, + courseware_url: templateRun.courseware_url ?? "", + language: "en", + title: templateRun.title, + run_tag: templateRun.run_tag, + }, + { + id: 6202, + courseware_id: "cw-template-es", + courseware_url: "https://example.com/cw-template-es", + language: "es", + title: "Modulo Espanol", + run_tag: templateRun.run_tag, + }, + ], + }) + + const options = getDistinctLanguageOptions([course]) + expect(options.map((option) => option.value)).toEqual([ + "language:en", + "language:es", + ]) + }) + + test("filters out unenrollable options when is_enrollable is provided", () => { + const run = factories.courses.courseRun({ + id: 6301, + title: "English", + courseware_id: "cw-en-6301", + courseware_url: "https://example.com/cw-en-6301", + is_enrollable: true, + }) + + const course = factories.courses.course({ + courseruns: [run], + next_run_id: run.id, + language_options: [ + { + id: run.id, + courseware_id: run.courseware_id, + courseware_url: run.courseware_url ?? "", + language: "en", + title: run.title, + run_tag: run.run_tag, + is_enrollable: true, + } as LanguageOptionWithEnrollability, + { + id: 6302, + courseware_id: "cw-es-6302", + courseware_url: "https://example.com/cw-es-6302", + language: "es", + title: "Modulo Espanol", + run_tag: run.run_tag, + is_enrollable: false, + } as LanguageOptionWithEnrollability, + ], + }) + + const options = getDistinctLanguageOptions([course]) + expect(options.map((option) => option.value)).toEqual(["language:en"]) + }) + + test("matches selected enrollment by language option courseware id", () => { + const englishRun = factories.courses.courseRun({ + id: 11, + courseware_id: "cw-en", + courseware_url: "https://example.com/cw-en", + }) + const spanishRun = factories.courses.courseRun({ + id: 12, + courseware_id: "cw-es", + courseware_url: "https://example.com/cw-es", + }) + + const englishEnrollment = factories.enrollment.courseEnrollment({ + run: { + id: englishRun.id, + course: { id: 1, title: "Course" }, + title: englishRun.title, + courseware_id: englishRun.courseware_id, + }, + }) + const spanishEnrollment = factories.enrollment.courseEnrollment({ + run: { + id: spanishRun.id, + course: { id: 1, title: "Course" }, + title: spanishRun.title, + courseware_id: spanishRun.courseware_id, + }, + }) + + const selectedEnrollment = getEnrollmentForSelectedLanguage( + [englishEnrollment, spanishEnrollment], + { + id: spanishRun.id, + courseware_id: spanishRun.courseware_id, + courseware_url: spanishRun.courseware_url ?? "", + language: "es", + title: spanishRun.title, + run_tag: spanishRun.run_tag, + }, + null, + ) + + expect(selectedEnrollment?.run.courseware_id).toBe(spanishRun.courseware_id) + }) + + test("adapts V3 enrollment run into V2-shaped run context", () => { + const templateRun = factories.courses.courseRun({ + id: 500, + title: "Template", + courseware_id: "cw-template", + courseware_url: "https://example.com/template", + enrollment_start: "2026-01-01T00:00:00Z", + }) + const spanishRun = factories.courses.courseRun({ + id: 501, + title: "Titulo Espanol", + courseware_id: "cw-es", + courseware_url: "https://example.com/es", + is_enrollable: true, + }) + const course = factories.courses.course({ + courseruns: [templateRun, spanishRun], + next_run_id: templateRun.id, + language_options: [ + { + id: templateRun.id, + courseware_id: templateRun.courseware_id, + courseware_url: templateRun.courseware_url ?? "", + language: "en", + title: templateRun.title, + run_tag: templateRun.run_tag, + }, + { + id: spanishRun.id, + courseware_id: spanishRun.courseware_id, + courseware_url: spanishRun.courseware_url ?? "", + language: "es", + title: spanishRun.title, + run_tag: spanishRun.run_tag, + }, + ], + }) + + const spanishOption = getSelectedLanguageOption(course, "language:es") + const enrollment = factories.enrollment.courseEnrollment({ + run: { + id: spanishRun.id, + course: { id: course.id, title: course.title }, + title: spanishRun.title, + courseware_id: spanishRun.courseware_id, + courseware_url: spanishRun.courseware_url, + run_tag: spanishRun.run_tag, + start_date: spanishRun.start_date, + end_date: spanishRun.end_date, + is_enrollable: spanishRun.is_enrollable, + is_upgradable: spanishRun.is_upgradable, + is_archived: spanishRun.is_archived, + is_self_paced: spanishRun.is_self_paced, + upgrade_deadline: spanishRun.upgrade_deadline, + certificate_available_date: spanishRun.certificate_available_date, + course_number: spanishRun.course_number, + }, + }) + + const resolved = getResolvedRunForSelectedLanguage( + course, + spanishOption, + spanishRun, + enrollment, + undefined, + ) + + expect(resolved?.id).toBe(spanishRun.id) + expect(resolved?.title).toBe(spanishRun.title) + expect(resolved?.courseware_url).toBe(spanishRun.courseware_url) + expect(resolved?.enrollment_start).toBe(spanishRun.enrollment_start) + }) + + test("returns synthetic run when selected language has no concrete run", () => { + const contractId = 77 + const nonEnrollableContractRun = factories.courses.courseRun({ + id: 1001, + title: "English Contract Run", + courseware_id: "cw-contract-en", + courseware_url: "https://openedx.example.com/contract-english", + b2b_contract: contractId, + is_enrollable: false, + }) + const enrollableNonContractRun = factories.courses.courseRun({ + id: 1002, + title: "Fallback Enrollable Run", + courseware_id: "cw-fallback-enrollable", + courseware_url: "https://openedx.example.com/fallback-enrollable", + b2b_contract: null, + is_enrollable: true, + }) + const course = factories.courses.course({ + courseruns: [nonEnrollableContractRun, enrollableNonContractRun], + next_run_id: nonEnrollableContractRun.id, + language_options: [ + { + id: nonEnrollableContractRun.id, + courseware_id: nonEnrollableContractRun.courseware_id, + courseware_url: nonEnrollableContractRun.courseware_url ?? "", + language: "en", + title: nonEnrollableContractRun.title, + run_tag: nonEnrollableContractRun.run_tag, + }, + { + id: 1003, + courseware_id: "cw-contract-es-synthetic", + courseware_url: "https://openedx.example.com/contract-spanish", + language: "es", + title: "Modulo sintetico", + run_tag: "spanish", + }, + ], + }) + + const spanishOption = getSelectedLanguageOption(course, "language:es") + + const resolved = getResolvedRunForSelectedLanguage( + course, + spanishOption, + null, + null, + contractId, + ) + + expect(resolved?.id).toBe(1003) + expect(resolved?.title).toBe("Modulo sintetico") + expect(resolved?.courseware_url).toBe( + "https://openedx.example.com/contract-spanish", + ) + }) + + test("returns null when contract-scoped template run does not exist", () => { + const contractId = 88 + const nonContractRun = factories.courses.courseRun({ + id: 2001, + title: "Non-contract run", + courseware_id: "cw-non-contract", + courseware_url: "https://openedx.example.com/non-contract", + b2b_contract: null, + }) + const course = factories.courses.course({ + courseruns: [nonContractRun], + next_run_id: nonContractRun.id, + language_options: [ + { + id: 2002, + courseware_id: "cw-contract-es-synthetic", + courseware_url: "https://openedx.example.com/contract-spanish", + language: "es", + title: "Modulo sintetico", + run_tag: "spanish", + }, + ], + }) + + const spanishOption = getSelectedLanguageOption(course, "language:es") + + const resolved = getResolvedRunForSelectedLanguage( + course, + spanishOption, + null, + null, + contractId, + ) + + expect(resolved).toBeNull() + }) + + test("ignores unenrollable options and picks enrollable option for the same language", () => { + const unenrollableEnglish = factories.courses.courseRun({ + id: 3001, + courseware_id: "cw-en-unenrollable", + courseware_url: "https://openedx.example.com/en-unenrollable", + is_enrollable: false, + }) + const enrollableEnglish = factories.courses.courseRun({ + id: 3002, + courseware_id: "cw-en-enrollable", + courseware_url: "https://openedx.example.com/en-enrollable", + is_enrollable: true, + }) + const course = factories.courses.course({ + courseruns: [unenrollableEnglish, enrollableEnglish], + next_run_id: unenrollableEnglish.id, + language_options: [ + { + id: unenrollableEnglish.id, + courseware_id: unenrollableEnglish.courseware_id, + courseware_url: unenrollableEnglish.courseware_url ?? "", + language: "en", + title: unenrollableEnglish.title, + run_tag: unenrollableEnglish.run_tag, + }, + { + id: enrollableEnglish.id, + courseware_id: enrollableEnglish.courseware_id, + courseware_url: enrollableEnglish.courseware_url ?? "", + language: "en", + title: enrollableEnglish.title, + run_tag: enrollableEnglish.run_tag, + }, + ], + }) + + const selectedOption = getSelectedLanguageOption(course, "language:en") + const selectedRun = getCourseRunForSelectedLanguage(course, "language:en") + const resolvedRun = getResolvedRunForSelectedLanguage( + course, + selectedOption, + selectedRun, + null, + undefined, + ) + + expect(selectedOption?.id).toBe(enrollableEnglish.id) + expect(resolvedRun?.id).toBe(enrollableEnglish.id) + }) + + test("ignores malformed enrollments without run data", () => { + const run = factories.courses.courseRun({ + id: 5001, + courseware_id: "cw-en-5001", + courseware_url: "https://example.com/cw-en-5001", + is_enrollable: true, + }) + const course = factories.courses.course({ + courseruns: [run], + next_run_id: run.id, + language_options: [ + { + id: run.id, + courseware_id: run.courseware_id, + courseware_url: run.courseware_url ?? "", + language: "en", + title: run.title, + run_tag: run.run_tag, + }, + ], + }) + + const selectedOption = getSelectedLanguageOption(course, "language:en") + const selectedRun = getCourseRunForSelectedLanguage(course, "language:en") + const malformedEnrollment = { + ...factories.enrollment.courseEnrollment(), + run: undefined, + } as unknown as CourseRunEnrollmentV3 + + expect( + getEnrollmentForSelectedLanguage( + [malformedEnrollment], + selectedOption, + selectedRun, + ), + ).toBeNull() + + expect( + getResolvedRunForSelectedLanguage( + course, + selectedOption, + selectedRun, + malformedEnrollment, + ), + ).toEqual(selectedRun) + }) +}) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.ts new file mode 100644 index 0000000000..b5f2670640 --- /dev/null +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/languageOptions.ts @@ -0,0 +1,391 @@ +import type { SimpleSelectOption } from "ol-components" +import type { + CourseRunEnrollmentV3, + CourseRunLanguageOption, + CourseRunV2, + CourseWithCourseRunsSerializerV2, +} from "@mitodl/mitxonline-api-axios/v2" +import { getBestRun } from "./helpers" + +const LANGUAGE_CODE_TO_NATIVE_NAME: Record = { + ar: "العربية", + de: "Deutsch", + "de-de": "Deutsch", + el: "Ελληνικά", + es: "Español", + "es-419": "Español (Latinoamérica)", + fr: "Français", + pt: "Português", + ja: "日本語", + "pt-br": "Português (Brasil)", + zh: "中文", + "zh-hans": "简体中文", + en: "English", +} + +const getLanguageCode = (option: CourseRunLanguageOption): string | null => { + const normalized = option.language?.trim().toLowerCase().replace(/_/g, "-") + return normalized || null +} + +const getLanguageOptionKey = (option: CourseRunLanguageOption): string => { + const languageCode = getLanguageCode(option) + return languageCode ? `language:${languageCode}` : "" +} + +const getLanguageCodeFromOptionKey = (optionKey: string): string | null => { + if (!optionKey.startsWith("language:")) { + return null + } + const code = optionKey.replace("language:", "").trim().toLowerCase() + return code || null +} + +const getLanguageOptionLabel = (option: CourseRunLanguageOption): string => { + const languageCode = getLanguageCode(option) + if (!languageCode) { + return "" + } + + const exact = LANGUAGE_CODE_TO_NATIVE_NAME[languageCode] + if (exact) { + return exact + } + + const baseCode = languageCode.split("-")[0] + return LANGUAGE_CODE_TO_NATIVE_NAME[baseCode] ?? languageCode +} + +type ExtendedLanguageOption = CourseRunLanguageOption & { + is_enrollable?: boolean +} + +const isLanguageOptionEnrollable = ( + course: CourseWithCourseRunsSerializerV2, + option: CourseRunLanguageOption, +): boolean => { + const enrollable = (option as ExtendedLanguageOption).is_enrollable + if (typeof enrollable === "boolean") { + return enrollable + } + + const exactRunMatch = (course.courseruns ?? []).find( + (run) => run.id === option.id, + ) + if (exactRunMatch) { + return Boolean(exactRunMatch.is_enrollable) + } + + // If enrollability is not present on language_options payload, keep the + // option available instead of hiding potentially valid languages. + return true +} + +const getRunsForLanguageOption = ( + course: CourseWithCourseRunsSerializerV2, + option: CourseRunLanguageOption, +): CourseRunV2[] => { + const runs = course.courseruns ?? [] + + const byId = runs.filter((run) => run.id === option.id) + const byCoursewareId = runs.filter( + (run) => run.courseware_id === option.courseware_id, + ) + const byCoursewareUrl = option.courseware_url + ? runs.filter((run) => run.courseware_url === option.courseware_url) + : [] + + const seen = new Set() + const combined = [...byId, ...byCoursewareId, ...byCoursewareUrl] + return combined.filter((run) => { + if (seen.has(run.id)) { + return false + } + seen.add(run.id) + return true + }) +} + +const getEnrollableLanguageOptions = ( + course: CourseWithCourseRunsSerializerV2, +): CourseRunLanguageOption[] => { + return (course.language_options ?? []).filter((option) => { + return isLanguageOptionEnrollable(course, option) + }) +} + +const getDefaultLanguageOptionKey = ( + course: CourseWithCourseRunsSerializerV2, +): string | null => { + const enrollableLanguageOptions = getEnrollableLanguageOptions(course) + const defaultRunId = course.next_run_id + if (!defaultRunId) { + return null + } + + const directMatch = enrollableLanguageOptions.find( + (option) => option.id === defaultRunId, + ) + if (directMatch) { + const key = getLanguageOptionKey(directMatch) + return key || null + } + + const defaultRun = course.courseruns.find((run) => run.id === defaultRunId) + if (!defaultRun) { + return null + } + + const byCoursewareId = enrollableLanguageOptions.find( + (option) => option.courseware_id === defaultRun.courseware_id, + ) + if (!byCoursewareId) { + return null + } + + const key = getLanguageOptionKey(byCoursewareId) + return key || null +} + +const getDistinctLanguageOptions = ( + courses: CourseWithCourseRunsSerializerV2[], +): SimpleSelectOption[] => { + const optionsByKey = new Map() + const defaultLanguageCounts = new Map() + + courses.forEach((course) => { + const defaultLanguageKey = getDefaultLanguageOptionKey(course) + if (defaultLanguageKey) { + defaultLanguageCounts.set( + defaultLanguageKey, + (defaultLanguageCounts.get(defaultLanguageKey) ?? 0) + 1, + ) + } + + getEnrollableLanguageOptions(course).forEach((option) => { + const key = getLanguageOptionKey(option) + const label = getLanguageOptionLabel(option) + if (!key || !label) { + return + } + if (!optionsByKey.has(key)) { + optionsByKey.set(key, { + value: key, + label, + }) + } + }) + }) + + return Array.from(optionsByKey.values()).sort((a, b) => { + const defaultCountA = defaultLanguageCounts.get(String(a.value)) ?? 0 + const defaultCountB = defaultLanguageCounts.get(String(b.value)) ?? 0 + if (defaultCountA !== defaultCountB) { + return defaultCountB - defaultCountA + } + return String(a.label).localeCompare(String(b.label)) + }) +} + +const getSelectedLanguageOption = ( + course: CourseWithCourseRunsSerializerV2, + selectedLanguageKey: string, +): CourseRunLanguageOption | null => { + const resolvedLanguageKey = + selectedLanguageKey || getDefaultLanguageOptionKey(course) || "" + + if (!resolvedLanguageKey) { + return null + } + + const matchingOptions = getEnrollableLanguageOptions(course).filter( + (option) => getLanguageOptionKey(option) === resolvedLanguageKey, + ) + + if (matchingOptions.length === 0) { + return null + } + + const nextRunMatch = matchingOptions.find( + (option) => option.id === course.next_run_id, + ) + if (nextRunMatch) { + return nextRunMatch + } + + const bestEnrollableRun = getBestRun(course, { enrollableOnly: true }) + const bestRunMatch = matchingOptions.find((option) => { + return ( + option.id === bestEnrollableRun?.id || + option.courseware_id === bestEnrollableRun?.courseware_id || + (Boolean(option.courseware_url) && + option.courseware_url === bestEnrollableRun?.courseware_url) + ) + }) + if (bestRunMatch) { + return bestRunMatch + } + + return matchingOptions[0] ?? null +} + +const getCourseRunForSelectedLanguage = ( + course: CourseWithCourseRunsSerializerV2, + selectedLanguageKey: string, +): CourseRunV2 | null => { + const languageOption = getSelectedLanguageOption(course, selectedLanguageKey) + if (!languageOption) { + return null + } + + const matchingRuns = getRunsForLanguageOption(course, languageOption) + if (matchingRuns.length === 0) { + return null + } + + const nextRunMatch = matchingRuns.find((run) => run.id === course.next_run_id) + if (nextRunMatch) { + return nextRunMatch + } + + const bestEnrollableRun = getBestRun(course, { enrollableOnly: true }) + const bestRunMatch = matchingRuns.find( + (run) => run.id === bestEnrollableRun?.id, + ) + if (bestRunMatch) { + return bestRunMatch + } + + return ( + matchingRuns.find((run) => run.is_enrollable) ?? matchingRuns[0] ?? null + ) +} + +const getEnrollmentForSelectedLanguage = ( + enrollments: CourseRunEnrollmentV3[], + selectedLanguageOption: CourseRunLanguageOption | null, + selectedRun: CourseRunV2 | null, +): CourseRunEnrollmentV3 | null => { + if (!selectedLanguageOption) { + return null + } + + return ( + enrollments.find((enrollment) => { + if (!enrollment.run) { + return false + } + + return ( + enrollment.run.id === selectedLanguageOption.id || + enrollment.run.courseware_id === selectedLanguageOption.courseware_id || + (selectedRun ? enrollment.run.id === selectedRun.id : false) + ) + }) ?? null + ) +} + +const getResolvedRunForSelectedLanguage = ( + course: CourseWithCourseRunsSerializerV2, + selectedLanguageOption: CourseRunLanguageOption | null, + selectedRun: CourseRunV2 | null, + selectedEnrollment: CourseRunEnrollmentV3 | null, + contractId?: number, +): CourseRunV2 | null => { + // Returns a CourseRunV2 representing the user's effective run for the selected + // language. Three cases: + // + // 1. User is enrolled in the language: shape a V2 run from the V3 enrollment + // by spreading templateRun and overriding 13 fields. Removable when + // dashboard card/run context migrates to V3-native types. + // + // 2. A real CourseRunV2 exists in course.courseruns for the language: + // return it directly. + // + // 3. Pre-enrollment, no real CourseRunV2 exists for the language: synthesize + // one by spreading templateRun and overriding only + // id/title/courseware_id/courseware_url/run_tag from the + // language_options pointer. Dates, products, and enrollability are + // inherited from a different-language run because mitxonline does not + // currently surface per-language run metadata pre-enrollment. + // Removable when the API returns language-specific runs for non-default + // languages. + let scopedSelectedRun: CourseRunV2 | null = selectedRun + if ( + typeof contractId === "number" && + selectedRun?.b2b_contract !== contractId + ) { + scopedSelectedRun = null + } + + let templateRun: CourseRunV2 | null = scopedSelectedRun + if (!templateRun) { + templateRun = + typeof contractId === "number" + ? (getBestRun(course, { contractId }) ?? null) + : (getBestRun(course) ?? null) + } + + const enrollmentRun = selectedEnrollment?.run + if (enrollmentRun) { + if (!templateRun) { + // Cannot adapt enrollment.run to a CourseRunV2 shape without a scoped + // template run to supply required base fields. + return null + } + + // Return the selected enrollment's run details merged onto a scoped base run + // so downstream CourseRunV2 consumers get the selected-language run context. + return { + ...templateRun, + id: enrollmentRun.id, + title: enrollmentRun.title, + courseware_id: enrollmentRun.courseware_id, + courseware_url: enrollmentRun.courseware_url, + run_tag: enrollmentRun.run_tag, + start_date: enrollmentRun.start_date, + end_date: enrollmentRun.end_date, + is_enrollable: enrollmentRun.is_enrollable, + is_upgradable: enrollmentRun.is_upgradable, + is_archived: enrollmentRun.is_archived, + is_self_paced: enrollmentRun.is_self_paced, + upgrade_deadline: enrollmentRun.upgrade_deadline, + certificate_available_date: enrollmentRun.certificate_available_date, + course_number: enrollmentRun.course_number, + } + } + + if (scopedSelectedRun) { + // Return the exact selected run when it already exists in this course's + // scoped runs and matches the optional contract constraint. + return scopedSelectedRun + } + + if (!selectedLanguageOption || !templateRun) { + // No selected language option, or no scoped template run to anchor one, + // so there is no safe run context to return. + return null + } + + // Pre-enrollment fallback when selected language has no concrete CourseRunV2 + // in this payload: project selected-language identifiers onto a scoped base + // run so UI can render the chosen language title/URL context. + return { + ...templateRun, + id: selectedLanguageOption.id, + title: selectedLanguageOption.title, + courseware_id: selectedLanguageOption.courseware_id, + courseware_url: selectedLanguageOption.courseware_url, + run_tag: selectedLanguageOption.run_tag, + } +} + +export { + getLanguageCodeFromOptionKey, + getLanguageOptionKey, + getDistinctLanguageOptions, + getSelectedLanguageOption, + getCourseRunForSelectedLanguage, + getEnrollmentForSelectedLanguage, + getResolvedRunForSelectedLanguage, +} 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 46c725af0e..9081e677e5 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/test-utils.ts +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/test-utils.ts @@ -363,41 +363,51 @@ const createTestContracts = ( const createCoursesWithContractRuns = (contracts: ContractPage[]) => { const contractIds = contracts.map((c) => c.id) - return factories.courses.courses({ count: 3 }).results.map((course) => ({ - ...course, - courseruns: [ - // First run associated with organization's contract - { - ...course.courseruns[0], - id: faker.number.int(), - b2b_contract: contractIds[0], // Associated with org contract - is_enrollable: true, - start_date: faker.date.future().toISOString(), - end_date: faker.date.future().toISOString(), - title: `${course.title} - Org Contract Run`, - }, - // Second run associated with different organization's contract - { - ...course.courseruns[0], - id: faker.number.int(), - b2b_contract: faker.number.int(), // Different contract ID - is_enrollable: true, - start_date: faker.date.past().toISOString(), - end_date: faker.date.past().toISOString(), - title: `${course.title} - Other Org Run`, - }, - // Third run with no contract (general enrollment) - { - ...course.courseruns[0], - id: faker.number.int(), - b2b_contract: null, - is_enrollable: true, - start_date: faker.date.future().toISOString(), - end_date: faker.date.future().toISOString(), - title: `${course.title} - General Run`, - }, - ], - })) + return factories.courses.courses({ count: 3 }).results.map((course) => { + const contractRun = { + ...course.courseruns[0], + id: faker.number.int(), + b2b_contract: contractIds[0], // Associated with org contract + is_enrollable: true, + start_date: faker.date.future().toISOString(), + end_date: faker.date.future().toISOString(), + title: `${course.title} - Org Contract Run`, + } + const otherOrgRun = { + ...course.courseruns[0], + id: faker.number.int(), + b2b_contract: faker.number.int(), // Different contract ID + is_enrollable: true, + start_date: faker.date.past().toISOString(), + end_date: faker.date.past().toISOString(), + title: `${course.title} - Other Org Run`, + } + const generalRun = { + ...course.courseruns[0], + id: faker.number.int(), + b2b_contract: null, + is_enrollable: true, + start_date: faker.date.future().toISOString(), + end_date: faker.date.future().toISOString(), + title: `${course.title} - General Run`, + } + + return { + ...course, + next_run_id: contractRun.id, + language_options: [ + { + id: contractRun.id, + courseware_id: contractRun.courseware_id, + courseware_url: contractRun.courseware_url ?? "", + language: "en", + title: contractRun.title, + run_tag: contractRun.run_tag, + }, + ], + courseruns: [contractRun, otherOrgRun, generalRun], + } + }) } export { diff --git a/main/settings.py b/main/settings.py index 669435030c..ab1b8878e5 100644 --- a/main/settings.py +++ b/main/settings.py @@ -35,7 +35,7 @@ from main.settings_pluggy import * # noqa: F403 from openapi.settings_spectacular import open_spectacular_settings -VERSION = "0.65.3" +VERSION = "0.65.5" log = logging.getLogger() diff --git a/yarn.lock b/yarn.lock index 538f02720b..eec139d940 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3332,13 +3332,13 @@ __metadata: languageName: node linkType: hard -"@mitodl/mitxonline-api-axios@npm:^2026.4.23": - version: 2026.4.23 - resolution: "@mitodl/mitxonline-api-axios@npm:2026.4.23" +"@mitodl/mitxonline-api-axios@npm:2026.5.1": + version: 2026.5.1 + resolution: "@mitodl/mitxonline-api-axios@npm:2026.5.1" dependencies: "@types/node": "npm:^20.11.19" axios: "npm:^1.6.5" - checksum: 10/9580cb496742649450c0567b8b4be6ad19082af9d3ea1c2cd1f9dcec1c87746c8370c8af984b5012660d6e9a52f36dded227115da635a8efb7634df952499a25 + checksum: 10/6eb179298221fc2801ce4e3c0589de0378c68b7a32503101de675a85f6e3569c78ec6cb4b1bc5aa85094637532a34d10c110a4a9cdfa96c525dc4362e0236d5c languageName: node linkType: hard @@ -8939,7 +8939,7 @@ __metadata: resolution: "api@workspace:frontends/api" dependencies: "@faker-js/faker": "npm:^10.0.0" - "@mitodl/mitxonline-api-axios": "npm:^2026.4.23" + "@mitodl/mitxonline-api-axios": "npm:2026.5.1" "@tanstack/react-query": "npm:^5.66.0" "@testing-library/react": "npm:^16.3.0" axios: "npm:^1.12.2" @@ -16184,7 +16184,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.4.23" + "@mitodl/mitxonline-api-axios": "npm:2026.5.1" "@mitodl/smoot-design": "npm:^6.24.0" "@mui/material": "npm:^6.4.5" "@mui/material-nextjs": "npm:^6.4.3"