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/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 = () => {
)}
{
+ 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,
@@ -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]))
})
}
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()