Skip to content

Conversation

@gumaerc
Copy link
Contributor

@gumaerc gumaerc commented Nov 22, 2025

What are the relevant tickets?

Closes https://github.com/mitodl/hq/issues/9106

Description (What does it do?)

This PR adds support for listing program enrollments in the "My Learning" section of the dashboard home page. A program detail view (ProgramContent.tsx) was added to the dashboard to handle displaying the courses from an individual program as a page in the dashboard. The EnrollmentDisplay component was modified to accept a programId argument. If this is passed, the component will render the courses / enrollments from that program, rather than the user's base enrollments which is what it displays by default. The program cards in the My Learning section link to this program dashboard page. The course cards on this program page currently just display an alert saying "Non-B2B course enrollment is not yet implemented." This is because the non-B2B ecommerce checkout dialogs are not ready to go here in MIT Learn, and this functionality is still feature flagged.

Screenshots (if appropriate):

image image image image

How can this be tested?

  • Make sure you have an instance of MITx Online set up and configured to use the same Keycloak / APISIX instances as your instance of MIT Learn
  • In the MITx Online Django admin, make sure you have at least one Program with some courses added to it as requirements
  • Create a ProgramEnrollment object tied to said program and your user
  • Load the Learn dashboard while logged in as said user
  • Make sure you see the My Learning section with a card for the program enrollment that says "View Program"
  • Click on the View Program button, and verify that you are taken to the dashboard program page, at /dashboard/program/1 or whatever number your program is
  • Verify that you see course cards for each course in the program
  • Verify that clicking either the title or the CTA button gives you an alert that says "Non-B2B course enrollment is not yet implemented."

@gumaerc gumaerc force-pushed the cg/individual-program-display branch from c4b6ac7 to 49f608d Compare November 24, 2025 17:15
@gumaerc gumaerc added Needs Review An open Pull Request that is ready for review and removed Work in Progress labels Nov 25, 2025
@gumaerc gumaerc requested a review from Copilot November 25, 2025 00:44
Copilot finished reviewing on behalf of gumaerc November 25, 2025 00:48
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements support for displaying program enrollments in the "My Learning" section and adds a dedicated program detail page to the dashboard. It updates the API client to version 2025.11.24 to support the new V2 program enrollment endpoints and adds the ability to view program requirements with course enrollment status.

Key Changes:

  • Added program enrollment display with cards linking to individual program pages
  • Created a dedicated program detail view showing requirement sections (core/electives) with completion tracking
  • Refactored DashboardCard to support both course and program resources with type guards
  • Filters out B2B program enrollments from personal "My Learning" section

Reviewed changes

Copilot reviewed 27 out of 28 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
yarn.lock Updates @mitodl/mitxonline-api-axios to version 2025.11.24 with exact version pinning
frontends/main/src/common/urls.ts Adds PROGRAM_VIEW route constants and programView helper function
frontends/main/src/app/dashboard/program/[id]/page.tsx New Next.js page component for program detail route with ID validation
frontends/main/src/app-pages/ProductPages/ProgramPage.tsx Returns Skeleton during loading instead of null for better UX
frontends/main/src/app-pages/ProductPages/ProductSummary.test.tsx Updates test factories to match new API schema with readable_id fields
frontends/main/src/app-pages/DashboardPage/ProgramContent.tsx New component that wraps EnrollmentDisplay with programId prop
frontends/main/src/app-pages/DashboardPage/ProgramContent.test.tsx Comprehensive tests for ProgramContent component with mocked dependencies
frontends/main/src/app-pages/DashboardPage/OrganizationContent.tsx Updates to use V2 program enrollment types and standardized certificate URLs
frontends/main/src/app-pages/DashboardPage/OrganizationContent.test.tsx Adds mocks for V2 program enrollment endpoints and updates certificate URL expectations
frontends/main/src/app-pages/DashboardPage/HomeContent.test.tsx Adds V2 program enrollments mock for test setup
frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/types.ts Adds DashboardProgram types with enrollment, requirement tree, and readableId support
frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/transform.ts Implements program enrollment transformations, B2B filtering, and requirement tree parsing
frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/transform.test.tsx Extensive test coverage for new transformation functions with edge cases
frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/test-utils.ts Adds dashboardProgram factory and V2 program enrollment mock setup
frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx Adds ProgramEnrollmentDisplay component with requirement sections and stacked card container
frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx Comprehensive tests for program filtering, B2B exclusion, and requirement display
frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardDialogs.test.tsx Updates tests to include B2B contract IDs in course enrollment mocks
frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx Major refactor to support programs with type guards, stacked variant, and improved B2B handling
frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.test.tsx Extensive new tests for program cards, stacked variant, and B2B enrollment flows
frontends/main/package.json Pins @mitodl/mitxonline-api-axios to exact version 2025.11.24
frontends/jest-shared-setup.ts Adds NEXT_PUBLIC_MITX_ONLINE_DOMAIN environment variable for tests
frontends/api/src/mitxonline/test-utils/urls.ts Adds enrollmentsListV2 URL helper for program enrollments
frontends/api/src/mitxonline/test-utils/factories/programs.ts Updates program factory with readable_id fields in requirements
frontends/api/src/mitxonline/test-utils/factories/pages.ts Updates page item factory with readable_id fields in course requirements
frontends/api/src/mitxonline/test-utils/factories/enrollment.ts Adds programEnrollmentV2 factory for V2 program enrollment structure
frontends/api/src/mitxonline/test-utils/factories/contracts.ts Adds programs field to contract factory
frontends/api/src/mitxonline/hooks/enrollment/queries.ts Updates programEnrollmentsList to use V2 endpoint and types
frontends/api/package.json Pins @mitodl/mitxonline-api-axios to exact version 2025.11.24

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@gumaerc gumaerc force-pushed the cg/individual-program-display branch 2 times, most recently from 211a770 to 172c0f2 Compare November 25, 2025 16:17
Copy link
Contributor

@ChristopherChudzicki ChristopherChudzicki left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is looking good. I did notice some issues with elective display.

I also think it would be great if we could better share the req_tree analysis between this and product pages.

}
}

const transformProgramEnrollmentToDashboard = (
Copy link
Contributor

@ChristopherChudzicki ChristopherChudzicki Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this just camelcase things? If yes, IMO we should just leave it snakey.

Edit: this comment was supposed to be about transformProgramRequirement

"http://api.test.learn.odl.local:8063"
process.env.NEXT_PUBLIC_MITX_ONLINE_BASE_URL =
"http://api.test.mitxonline.odl.local:8053"
process.env.NEXT_PUBLIC_MITX_ONLINE_DOMAIN = "mitxonline.mit.edu"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason to use the actual domain?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clarifying... IMO, better to use something fake lest we actually hit the real API unintentionally.

(I think jest disables network calls? but i'm not really sure.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This wasn't even supposed to be here. At one point I had functionality in here to do the redirect thing we talked about but nixed it in favor of the no-op that I'm assuming is the correct move here.

useQuery(coursesQueries.coursesList({ id: program?.courseIds }))

// Build sections from requirement tree
const requirementSections =
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice. In #2728, I have it hardcoded to at most 1 required courses subsection and 1 elective courses subsection, copied from mitxonline behavior. That's silly though, your approach is better.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, now I remember. Other portions of the API response that I am using (Namely, the requiremenets object) are hard coded to expect at most 1 required and 1 elective section. https://mitxonline.mit.edu/api/v2/programs/5/

)
const program = rawProgram ? mitxonlineProgram(rawProgram) : undefined
const { data: rawProgramCourses, isLoading: programCoursesLoading } =
useQuery(coursesQueries.coursesList({ id: program?.courseIds }))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now when I load http://open.odl.local:8062/dashboard/program/1, I see

  1. an api call to http://api.open.odl.local:8065/mitxonline/api/v2/courses/
  2. then another one to http://api.open.odl.local:8065/mitxonline/api/v2/courses/?id=11%2C12%2C13%2C4%2C1%2C2

I think (1) is coming from this query when program?.courseIds is undefined.

Suggestion: Add enabled: !!program?.courseIds?.length.

variant="body2"
color={theme.custom.colors.silverGrayDark}
>
Completed {sectionCompletedCount} of {section.courses.length}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this display is quite right for electives nodes.

My program has:

{
  "data": {
    "node_type": "operator",
    "operator": "min_number_of",
    "operator_value": "1",
    "program": 1,
    "course": null,
    "required_program": null,
    "title": "Electives!",
    "elective_flag": true
  },
  "id": 18,
  "children": [/* two child course nodes */]
}

I think it should display like this (note "0 of 1" not "0 of 2", since you only need to take 1 elective).

Image

data: V2UserProgramEnrollmentDetail[],
): DashboardProgram[] => {
// Filter out program enrollments where any course enrollment is tied to a B2B contract
const nonB2BProgramEnrollments = data.filter((programEnrollment) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment: The following is a scenario we do not handle well. I am not sure how it should behave, or if it is just something we say "Don't set up the data this way".

  • Program 1 contains Courses A, B, C
  • Program 2 contains Courses X, Y, and A

If a user is enrolled in Program 1 and an org that has Program 2, then as soon as they enroll in Course A via the org, then Program 1 disappears from their main dashboard.

Question: contracts can contain programs. Should we base this on "is program in contract" rather than "does program have course that is in contract"?

Not sure if this entirely solves the issue above, though.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In theory, if you're creating your B2B programs using the management commands in MITx Online, separate course runs / enrollments are created for that specific program tied to the B2B contract. So, not only is this a "don't set up the data that way" situation, MITx Online doesn't really do that unless you are a developer that is manually wiring up test data and taking shortcuts. It would take someone manually adding a B2B course run to their non-B2B program.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MITx Online doesn't really do that unless you are a developer that is manually wiring up test data and taking shortcuts.

Based on my testing, I don't think this is accurate. It's true that separate runs are created for b2b courses, but in this scenario

  • Program 1 contains Courses A, B, C ... enrolled in Program 1 normally
  • Program 2 contains Courses X, Y, and A ... enrolled in Course A b2b run via org

the api/v2/program_enrollments response includes b2b runs under both programs:

[
  {
    program: { title: "Program 1" },
    enrollments: [
      { run: { title: 'Course B' }, b2b_contract_id: null }, // normal enrollment
      { run: { title: 'Course A' }, b2b_contract_id: 1 } // b2b enrollment via Program 2
    ]
  },
    {
    program: { title: "Program 2" },
    enrollments: [
      { run: { title: 'Course X' }, b2b_contract_id: 1 }, // b2b enrollment via Program 2
      { run: { title: 'Course A' }, b2b_contract_id: 1 } // b2b enrollment via Program 2
    ]
  }
]

You can see the relevant code here; it just checks that "run is in program's course".


Suggestion: I do think the above situation is handled better by deciding b2b vs non-b2b at the program level:

  • Program is b2b: shows up under relevant contract page
    • "program is b2b" = belongs to one of your org contracts, I guess
    • probably only show b2b courserun enrollments for that contract
  • Program is non-b2b: shows up under dashboard home.
    • probably only show non-b2b enrollments?


test("transforms requirement tree correctly", () => {
const program = factories.programs.program({
req_tree: [
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might be useful for some of the tests

@gumaerc gumaerc force-pushed the cg/individual-program-display branch from dc317fd to 9523114 Compare November 26, 2025 15:18
Copy link
Contributor

@ChristopherChudzicki ChristopherChudzicki left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good, particularly with the change we discussed in Slack.

The only issue I notice is the missing text below. (Which is a bit redundant, i guess, but parallels the display on product pages.)

Image

Comment on lines +219 to +221
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 100))
})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wish we had a better abstraction for this. For testing the presence of something, findBy is better than waiting for queries to resolve. But I don't see any way to test for absence other than what you've done.

If you wanted to avoid the explicit numeric timeout, I think

await waitFor(()= > { expect(queryClient.isFetching()).toBe(0) })

would work, though I haven't tried it. (queryClient.isFetching() returns the number of queries that are currently fetching; queryClient is returned by renderWithProviders.)

Comment on lines +377 to +383
await waitFor(
() => {
const coreCourses = screen.queryByText("Core Courses")
expect(coreCourses).toBeInTheDocument()
},
{ timeout: 2000 },
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be await screen.findByText("Core Courses", {}, { timeout: 2000 }) (The second arg is options related to the selector, the third arg waitForOptions.

Comment on lines +386 to +393
if (
status === 403 &&
err.response?.data?.detail ===
"Authentication credentials were not provided."
) {
// For now, we don't want to throw an error if the user is not authenticated.
return false
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this happening to you on RC/prod/local?

User is authenticated on MIT Learn, but not on MITxOnline?

Must be a race condition?

…t in a b2b contract, not the presence of a b2b_contract_id on one of the enrollments
Copy link
Contributor

@ChristopherChudzicki ChristopherChudzicki left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

noticed two more accessibility things

Comment on lines 316 to 318
<Typography variant="h3" paddingBottom="32px">
{program?.title}
</Typography>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request: I think this should be component="h1".

Comment on lines 348 to 350
<Typography variant="subtitle2" color={theme.custom.colors.red}>
{section.title}
</Typography>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request: Let's make these component="h2".

: section.courses.length

return (
<React.Fragment key={index}>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm sure it doesn't matter much, but I'd add a key: id prop to the requirementSections (from the req_tree node id) and use that instead.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Needs Review An open Pull Request that is ready for review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants