From c28215316f18aa2fe3a039d1bc1e1c3b416ed59b Mon Sep 17 00:00:00 2001 From: Carey P Gumaer Date: Fri, 27 Feb 2026 15:55:30 -0500 Subject: [PATCH 1/3] fix: handle only having expired enrollments / program enrollments (#2988) * handle additional sorting / filtering conditions for my learning, such as only having expired course run enrollments, only program enrollments, etc. * clarify test comment * remove redundant variable assignment * consolidate resources passed into enrollmentexpandcollapse as dashboardresources --- .../CoursewareDisplay/DashboardCard.tsx | 2 +- .../EnrollmentDisplay.test.tsx | 204 ++++++++++++++++-- .../CoursewareDisplay/EnrollmentDisplay.tsx | 142 +++++++----- 3 files changed, 271 insertions(+), 77 deletions(-) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx index 0ef868394b..f9eddc1a33 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx @@ -58,7 +58,7 @@ export const DashboardType = { } as const export type DashboardType = (typeof DashboardType)[keyof typeof DashboardType] -type DashboardResource = +export type DashboardResource = | { type: "course"; data: CourseWithCourseRunsSerializerV2 } | { type: "courserun-enrollment"; data: CourseRunEnrollmentRequestV2 } | { type: "program-enrollment"; data: V3UserProgramEnrollment } 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 0b04ee8d42..7ece4d2dc7 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx @@ -125,16 +125,7 @@ describe("EnrollmentDisplay", () => { }) mockedUseFeatureFlagEnabled.mockReturnValue(true) - // Need at least one course enrollment to render the wrapper - setMockResponse.get(mitxonline.urls.enrollment.enrollmentsListV2(), [ - mitxonline.factories.enrollment.courseEnrollment({ - b2b_contract_id: null, - run: { - ...mitxonline.factories.enrollment.courseEnrollment().run, - title: "Dummy Course", - }, - }), - ]) + setMockResponse.get(mitxonline.urls.enrollment.enrollmentsListV2(), []) setMockResponse.get( mitxonline.urls.programEnrollments.enrollmentsListV3(), [programEnrollment], @@ -223,6 +214,188 @@ describe("EnrollmentDisplay", () => { expect(cards).toHaveLength(0) }) + test("Shows My Learning when only program enrollments exist", async () => { + const mitxOnlineUser = mitxonline.factories.user.user() + setMockResponse.get(mitxonline.urls.userMe.get(), mitxOnlineUser) + + const programEnrollment = + mitxonline.factories.enrollment.programEnrollmentV3({ + program: { + ...mitxonline.factories.programs.simpleProgram(), + title: "Solo Program", + }, + }) + + mockedUseFeatureFlagEnabled.mockReturnValue(true) + setMockResponse.get(mitxonline.urls.enrollment.enrollmentsListV2(), []) + setMockResponse.get( + mitxonline.urls.programEnrollments.enrollmentsListV3(), + [programEnrollment], + ) + setMockResponse.get(mitxonline.urls.contracts.contractsList(), []) + + renderWithProviders() + + await screen.findByRole("heading", { name: "My Learning" }) + expect((await screen.findAllByText("Solo Program")).length).toBeGreaterThan( + 0, + ) + }) + + test("Shows My Learning when only expired enrollments exist", async () => { + const mitxOnlineUser = mitxonline.factories.user.user() + setMockResponse.get(mitxonline.urls.userMe.get(), mitxOnlineUser) + + const expiredEnrollment = mitxonline.factories.enrollment.courseEnrollment({ + b2b_contract_id: null, + run: { + title: "Expired Course", + end_date: faker.date.past().toISOString(), + start_date: faker.date.past().toISOString(), + }, + }) + + mockedUseFeatureFlagEnabled.mockReturnValue(true) + setMockResponse.get(mitxonline.urls.enrollment.enrollmentsListV2(), [ + expiredEnrollment, + ]) + setMockResponse.get( + mitxonline.urls.programEnrollments.enrollmentsListV3(), + [], + ) + setMockResponse.get(mitxonline.urls.contracts.contractsList(), []) + + renderWithProviders() + + await screen.findByRole("heading", { name: "My Learning" }) + expect( + (await screen.findAllByText("Expired Course")).length, + ).toBeGreaterThan(0) + }) + + test("Shows expired courses without Show all when total cards <= MIN_VISIBLE", async () => { + const mitxOnlineUser = mitxonline.factories.user.user() + setMockResponse.get(mitxonline.urls.userMe.get(), mitxOnlineUser) + + // 2 expired courses only — total = 2, under MIN_VISIBLE of 3 → all promoted, no toggle + const expiredEnrollments = [ + mitxonline.factories.enrollment.courseEnrollment({ + b2b_contract_id: null, + run: { + title: "Expired Alpha", + end_date: faker.date.past().toISOString(), + start_date: faker.date.past().toISOString(), + }, + }), + mitxonline.factories.enrollment.courseEnrollment({ + b2b_contract_id: null, + run: { + title: "Expired Beta", + end_date: faker.date.past().toISOString(), + start_date: faker.date.past().toISOString(), + }, + }), + ] + + mockedUseFeatureFlagEnabled.mockReturnValue(true) + setMockResponse.get( + mitxonline.urls.enrollment.enrollmentsListV2(), + expiredEnrollments, + ) + setMockResponse.get( + mitxonline.urls.programEnrollments.enrollmentsListV3(), + [], + ) + setMockResponse.get(mitxonline.urls.contracts.contractsList(), []) + + renderWithProviders() + + await screen.findByRole("heading", { name: "My Learning" }) + expect( + (await screen.findAllByText("Expired Alpha")).length, + ).toBeGreaterThan(0) + expect((await screen.findAllByText("Expired Beta")).length).toBeGreaterThan( + 0, + ) + expect(screen.queryByText("Show all")).not.toBeInTheDocument() + }) + + test("Hides all expired behind Show all when normally-shown enrollments exist", async () => { + const mitxOnlineUser = mitxonline.factories.user.user() + setMockResponse.get(mitxonline.urls.userMe.get(), mitxOnlineUser) + + // 1 started + 3 expired → started is present so all expired go behind "Show all" + const startedEnrollment = mitxonline.factories.enrollment.courseEnrollment({ + b2b_contract_id: null, + run: { + title: "Active Course", + start_date: faker.date.past().toISOString(), + end_date: null, + }, + }) + const expiredEnrollments = [ + mitxonline.factories.enrollment.courseEnrollment({ + b2b_contract_id: null, + run: { + title: "A Expired Course", + end_date: faker.date.past().toISOString(), + start_date: faker.date.past().toISOString(), + }, + }), + mitxonline.factories.enrollment.courseEnrollment({ + b2b_contract_id: null, + run: { + title: "B Expired Course", + end_date: faker.date.past().toISOString(), + start_date: faker.date.past().toISOString(), + }, + }), + mitxonline.factories.enrollment.courseEnrollment({ + b2b_contract_id: null, + run: { + title: "C Expired Course", + end_date: faker.date.past().toISOString(), + start_date: faker.date.past().toISOString(), + }, + }), + ] + + mockedUseFeatureFlagEnabled.mockReturnValue(true) + setMockResponse.get(mitxonline.urls.enrollment.enrollmentsListV2(), [ + startedEnrollment, + ...expiredEnrollments, + ]) + setMockResponse.get( + mitxonline.urls.programEnrollments.enrollmentsListV3(), + [], + ) + setMockResponse.get(mitxonline.urls.contracts.contractsList(), []) + + renderWithProviders() + + await screen.findByRole("heading", { name: "My Learning" }) + + // "Show all" toggle must exist (all 3 expired are hidden) + await screen.findByText("Show all") + + // Only the active course is visible before expanding + expect( + (await screen.findAllByText("Active Course")).length, + ).toBeGreaterThan(0) + + // After expanding, all expired courses appear + await user.click(screen.getByText("Show all")) + expect( + (await screen.findAllByText("A Expired Course")).length, + ).toBeGreaterThan(0) + expect( + (await screen.findAllByText("B Expired Course")).length, + ).toBeGreaterThan(0) + expect( + (await screen.findAllByText("C Expired Course")).length, + ).toBeGreaterThan(0) + }) + test("Filters B2B program enrollments from display", async () => { const mitxOnlineUser = mitxonline.factories.user.user() setMockResponse.get(mitxonline.urls.userMe.get(), mitxOnlineUser) @@ -244,16 +417,7 @@ describe("EnrollmentDisplay", () => { }) mockedUseFeatureFlagEnabled.mockReturnValue(true) - // Need at least one course enrollment to render the wrapper - setMockResponse.get(mitxonline.urls.enrollment.enrollmentsListV2(), [ - mitxonline.factories.enrollment.courseEnrollment({ - b2b_contract_id: null, - run: { - ...mitxonline.factories.enrollment.courseEnrollment().run, - title: "Dummy Course", - }, - }), - ]) + setMockResponse.get(mitxonline.urls.enrollment.enrollmentsListV2(), []) setMockResponse.get( mitxonline.urls.programEnrollments.enrollmentsListV3(), [b2bProgramEnrollment, nonB2BProgramEnrollment], diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx index 0328e9bdb4..05b6571f65 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx @@ -21,13 +21,16 @@ import { ResourceType, selectBestEnrollment, } from "./helpers" -import { DashboardCard, DashboardType } from "./DashboardCard" +import { + DashboardCard, + DashboardResource, + DashboardType, +} from "./DashboardCard" import { coursesQueries } from "api/mitxonline-hooks/courses" import { programsQueries } from "api/mitxonline-hooks/programs" import { CourseRunEnrollmentRequestV2, V2ProgramRequirement, - V3UserProgramEnrollment, } from "@mitodl/mitxonline-api-axios/v2" import { contractQueries } from "api/mitxonline-hooks/contracts" import NotFoundPage from "@/app-pages/ErrorPage/NotFoundPage" @@ -157,18 +160,33 @@ const sortEnrollments = (enrollments: CourseRunEnrollmentRequestV2[]) => { } } +const getResourceKey = (resource: DashboardResource): string => { + if (resource.type === DashboardType.ProgramEnrollment) { + return getKey({ + resourceType: ResourceType.Program, + id: resource.data.program.id, + }) + } + if (resource.type === DashboardType.CourseRunEnrollment) { + return getKey({ + resourceType: ResourceType.Course, + id: resource.data.run.course.id, + runId: resource.data.run.id, + }) + } + return getKey({ resourceType: ResourceType.Course, id: resource.data.id }) +} + interface EnrollmentExpandCollapseProps { - shownCourseRunEnrollments: CourseRunEnrollmentRequestV2[] - hiddenCourseRunEnrollments: CourseRunEnrollmentRequestV2[] - programEnrollments?: V3UserProgramEnrollment[] + normallyShown: DashboardResource[] + maybeShown: DashboardResource[] isLoading?: boolean onUpgradeError?: (error: string) => void } const EnrollmentExpandCollapse: React.FC = ({ - shownCourseRunEnrollments, - hiddenCourseRunEnrollments, - programEnrollments, + normallyShown, + maybeShown, isLoading, onUpgradeError, }) => { @@ -179,61 +197,36 @@ const EnrollmentExpandCollapse: React.FC = ({ setShown(!shown) } + const shownResources = normallyShown.length + ? normallyShown + : maybeShown.slice(0, MIN_VISIBLE) + const hiddenResources = normallyShown.length + ? maybeShown + : maybeShown.slice(MIN_VISIBLE) + return ( <> - {shownCourseRunEnrollments.map((enrollment) => { - return ( - - ) - })} - {programEnrollments?.map((program) => ( + {shownResources.map((resource) => ( ))} - {hiddenCourseRunEnrollments.length === 0 ? null : ( + {hiddenResources.length === 0 ? null : ( <> - {hiddenCourseRunEnrollments.map((enrollment) => ( + {hiddenResources.map((resource) => ( = ({ ) } +const MIN_VISIBLE = 3 + +/** + * Renders the "My Learning" section for non-B2B enrollments. + * + * Cards are ordered and grouped as follows: + * 1. Started courses (past start date, not expired, not completed) + * 2. Not-yet-started courses + * 3. Completed courses (any passing grade) + * 4. Program enrollments (excluding those covered by a B2B contract) + * 5. Expired courses (past end date, not completed) — hidden behind "Show all" + * + * Exception: if groups 1–4 are all empty, up to MIN_VISIBLE expired courses + * are shown directly so the section is never blank for an enrolled user. + * The section is hidden entirely only when there are no enrollments at all. + */ const AllEnrollmentsDisplay: React.FC = () => { const [upgradeError, setUpgradeError] = React.useState(null) const { data: enrolledCourses, isLoading: courseEnrollmentsLoading } = @@ -496,22 +505,44 @@ const AllEnrollmentsDisplay: React.FC = () => { ) const { data: programEnrollments, isLoading: programEnrollmentsLoading } = useQuery(enrollmentQueries.programEnrollmentsList()) - const filteredProgramEnrollments = programEnrollments?.filter( - (enrollment) => { + const filteredProgramEnrollments = + programEnrollments?.filter((enrollment) => { return !contracts?.some((contract) => contract.programs.includes(enrollment.program.id), ) - }, - ) + }) ?? [] const supportEmail = process.env.NEXT_PUBLIC_MITOL_SUPPORT_EMAIL || "" const { completed, expired, started, notStarted } = sortEnrollments( enrolledCourses || [], ) - const shownEnrollments = [...started, ...notStarted, ...completed] - return shownEnrollments.length > 0 ? ( + const normallyShown: DashboardResource[] = [ + ...started.map((data) => ({ + data, + type: DashboardType.CourseRunEnrollment, + })), + ...notStarted.map((data) => ({ + data, + type: DashboardType.CourseRunEnrollment, + })), + ...completed.map((data) => ({ + data, + type: DashboardType.CourseRunEnrollment, + })), + ...filteredProgramEnrollments.map((data) => ({ + data, + type: DashboardType.ProgramEnrollment, + })), + ] + const maybeShown: DashboardResource[] = expired.map((data) => ({ + data, + type: DashboardType.CourseRunEnrollment, + })) + const totalCards = normallyShown.length + maybeShown.length + + return totalCards > 0 ? ( My Learning @@ -530,9 +561,8 @@ const AllEnrollmentsDisplay: React.FC = () => { </AlertBanner> )} <EnrollmentExpandCollapse - shownCourseRunEnrollments={shownEnrollments} - hiddenCourseRunEnrollments={expired} - programEnrollments={filteredProgramEnrollments || []} + normallyShown={normallyShown} + maybeShown={maybeShown} isLoading={ courseEnrollmentsLoading || programEnrollmentsLoading || From 570b82048ec393a137c3422dc510b4522381eac1 Mon Sep 17 00:00:00 2001 From: Carey P Gumaer <gumaerc@mit.edu> Date: Mon, 2 Mar 2026 12:27:47 -0500 Subject: [PATCH 2/3] whitelist characters that don't need to be urlencoded (#2994) --- frontends/main/src/common/urls.test.ts | 34 +++++++++++++++++++++++++- frontends/main/src/common/urls.ts | 13 +++++++++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/frontends/main/src/common/urls.test.ts b/frontends/main/src/common/urls.test.ts index 37cb543bd1..89c5591617 100644 --- a/frontends/main/src/common/urls.test.ts +++ b/frontends/main/src/common/urls.test.ts @@ -1,4 +1,4 @@ -import { auth } from "./urls" +import { auth, coursePageView, programPageView } from "./urls" const MITOL_API_BASE_URL = process.env.NEXT_PUBLIC_MITOL_API_BASE_URL @@ -51,3 +51,35 @@ test.each([ expect(auth({ next: loginNext, signupNext })).toBe(expected) }, ) + +test.each([ + { + readableId: "course-v1:MITxT+10.50x", + expected: "/courses/course-v1:MITxT+10.50x", + }, + { + readableId: "some-plain-slug", + expected: "/courses/some-plain-slug", + }, +])( + "coursePageView does not encode RFC 3986 pchar characters", + ({ readableId, expected }) => { + expect(coursePageView(readableId)).toBe(expected) + }, +) + +test.each([ + { + readableId: "program-v1:MITxT+10.50x", + expected: "/programs/program-v1:MITxT+10.50x", + }, + { + readableId: "some-plain-slug", + expected: "/programs/some-plain-slug", + }, +])( + "programPageView does not encode RFC 3986 pchar characters", + ({ readableId, expected }) => { + expect(programPageView(readableId)).toBe(expected) + }, +) diff --git a/frontends/main/src/common/urls.ts b/frontends/main/src/common/urls.ts index 082564af73..f157fdb2eb 100644 --- a/frontends/main/src/common/urls.ts +++ b/frontends/main/src/common/urls.ts @@ -1,5 +1,16 @@ import invariant from "tiny-invariant" +// matches ! $ & ' ( ) * + , ; = : @ ~ +const SAFE_IN_PATH_SEGMENT = + /%21|%24|%26|%27|%28|%29|%2A|%2B|%2C|%3B|%3D|%3A|%40|%7E/gi + +const encodePathSegment = (pathSegment: string) => { + const overAggressive = encodeURIComponent(pathSegment) + return overAggressive.replace(SAFE_IN_PATH_SEGMENT, (match) => + decodeURIComponent(match), + ) +} + const generatePath = ( template: string, params: Record<string, string | number>, @@ -8,7 +19,7 @@ const generatePath = ( if (params[key] === undefined) { throw new Error(`Missing parameter '${key}'`) } - return encodeURIComponent(params[key] as string) + return encodePathSegment(String(params[key])) }) } From 3dfeb636333d8907a62efaa3575d236ac5a354f2 Mon Sep 17 00:00:00 2001 From: Doof <mitx-devops@mit.edu> Date: Mon, 2 Mar 2026 17:28:30 +0000 Subject: [PATCH 3/3] Release 0.56.0 --- RELEASE.rst | 6 ++++++ main/settings.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/RELEASE.rst b/RELEASE.rst index cf08689e9b..e2e665b87c 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,12 @@ Release Notes ============= +Version 0.56.0 +-------------- + +- whitelist characters that don't need to be urlencoded (#2994) +- fix: handle only having expired enrollments / program enrollments (#2988) + Version 0.55.8 (Released February 27, 2026) -------------- diff --git a/main/settings.py b/main/settings.py index 6c14163428..33737193b1 100644 --- a/main/settings.py +++ b/main/settings.py @@ -34,7 +34,7 @@ from main.settings_pluggy import * # noqa: F403 from openapi.settings_spectacular import open_spectacular_settings -VERSION = "0.55.8" +VERSION = "0.56.0" log = logging.getLogger()