Skip to content

dashboard unenroll dialog functionality #2303

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Jun 18, 2025
Merged

Conversation

gumaerc
Copy link
Contributor

@gumaerc gumaerc commented Jun 10, 2025

What are the relevant tickets?

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

Description (What does it do?)

This PR wires up the unenroll button in the unenroll dialog to actually call the mitxonline API and perform unenrollments.

How can this be tested?

In order to test this, you need a basic installation of mitxonline up and running with example data in it. You may be able to skip one or more steps if you have already done them:

  • Ensure you have local hosts redirects for the following domains, replacing the example IP with your local IP address (Google how to get this if unsure, mine is 192.168.1.50)
192.168.1.50 open.odl.local
192.168.1.50 api.open.odl.local
192.168.1.50 kc.ol.local
192.168.1.50 learn.odl.local
192.168.1.50 mitxonline.odl.local
COMPOSE_PROFILES=apisix
CELERY_TASK_ALWAYS_EAGER=True
DJANGO_LOG_LEVEL=INFO
LOG_LEVEL=INFO
SENTRY_LOG_LEVEL=ERROR
MAILGUN_KEY=fake
MAILGUN_URL=
MAILGUN_RECIPIENT_OVERRIDE=
MAILGUN_SENDER_DOMAIN=.odl.local
SECRET_KEY=
STATUS_TOKEN=
UWSGI_THREADS=5
SENTRY_DSN=
MITX_ONLINE_BASE_URL=http://learn.odl.local:8065/mitxonline
MITX_ONLINE_ADMIN_CLIENT_ID=refine-local-client-id
MITX_ONLINE_ADMIN_BASE_URL=http://mitxonline.odl.local:8016
POSTHOG_PROJECT_API_KEY=
POSTHOG_API_HOST=https://app.posthog.com/
HUBSPOT_HOME_PAGE_FORM_GUID=
HUBSPOT_PORTAL_ID=
APISIX_PORT=9080

# APISIX/Keycloak settings
APISIX_LOGOUT_URL=http://api.open.odl.local:8065/logout/
APISIX_SESSION_SECRET_KEY=supertopsecret1234
KC_SPI_THEME_WELCOME_THEME=scim
KC_SPI_REALM_RESTAPI_EXTENSION_SCIM_LICENSE_KEY=
KEYCLOAK_BASE_URL=http://kc.ol.local:8066
KEYCLOAK_CLIENT_ID=apisix
# This is not a secret. This is for the Keycloak container, only for local use.
KEYCLOAK_CLIENT_SECRET=HckCZXToXfaetbBx0Fo3xbjnC468oMi4 # pragma: allowlist-secret
KEYCLOAK_DISCOVERY_URL=http://kc.ol.local:8066/realms/ol-local/.well-known/openid-configuration
KEYCLOAK_REALM_NAME=ol-local
KEYCLOAK_SCOPES="openid profile ol-profile"
KEYCLOAK_SVC_KEYSTORE_PASSWORD=supertopsecret1234
KEYCLOAK_SVC_HOSTNAME=kc.ol.local
KEYCLOAK_SVC_ADMIN=admin
KEYCLOAK_SVC_ADMIN_PASSWORD=admin
AUTHORIZATION_URL=http://kc.ol.local:8066/realms/ol-local/protocol/openid-connect/auth
ACCESS_TOKEN_URL=http://kc.ol.local:8066/realms/ol-local/protocol/openid-connect/token
OIDC_ENDPOINT=http://kc.ol.local:8066/realms/ol-local
SOCIAL_AUTH_OL_OIDC_OIDC_ENDPOINT=http://kc.ol.local:8066/realms/ol-local
SOCIAL_AUTH_OL_OIDC_KEY=apisix
# This is not a secret. This is for the Keycloak container, only for local use.
SOCIAL_AUTH_OL_OIDC_SECRET=HckCZXToXfaetbBx0Fo3xbjnC468oMi4 # pragma: allowlist-secret
USERINFO_URL=http://kc.ol.local:8066/realms/ol-local/protocol/openid-connect/userinfo
MITOL_APIGATEWAY_DISABLE_MIDDLEWARE=False

FEATURE_IGNORE_EDX_FAILURES=True
OPENEDX_API_CLIENT_ID=fake
OPENEDX_API_CLIENT_SECRET=fake
OPENEDX_SERVICE_WORKER_API_TOKEN=fake

CSRF_COOKIE_DOMAIN=.odl.local
CORS_ALLOWED_ORIGINS=http://mitxonline.odl.local:8065, http://open.odl.local:8062, http://api.open.odl.local:8065
CSRF_TRUSTED_ORIGINS=http://mitxonline.odl.local:8065, http://open.odl.local:8062, http://api.open.odl.local:8065
  • Spin up mitxonline with docker compose up --build -d
  • Promote the admin user with docker compose exec web ./manage.py promote_user --promote --superuser [email protected]
  • Populate test course data with docker compose exec web ./manage.py populate_course_data
  • Generate docs with pants docs ::
  • In dist/sphinx/index.html, read the section on generating a B2B organization / contract and create one, adding some of the test courses to it
  • In Django admin, create a Program and add some courses to the program that are included in your B2B org, making sure to mark the program as "live"
  • In the MITx Online Dashboard, enroll the test admin user in the courses you put in your B2B org, making sure you select the course runs generated by the org
  • Make sure you have a personal Posthog project configured and have the API key at the ready
  • Before we spin up mit-learn, we need to set some env variables (note that we are intentionally misconfiguring the cookie domain):
.env
...
MITX_ONLINE_UPSTREAM=mitxonline.odl.local:8013
MITX_ONLINE_DOMAIN=mitxonline.odl.local
MITX_ONLINE_BASE_URL=http://mitxonline.odl.local:8065
POSTHOG_ENABLED=True
CSRF_COOKIE_DOMAIN=.odl.local

shared.local.env
POSTHOG_PROJECT_API_KEY=YOUR_API_KEY_HERE
POSTHOG_PROJECT_ID=YOUR_PROJECT_ID_HERE
POSTHOG_TIMEOUT_MS=1500
  • In your Posthog project, enable the enrollment-dashboard and mitlearn-organization-dashboard feature flags for all users
  • Spin up MIT Learn
  • Log in with the [email protected] test user
  • Navigate to the Dashboard
  • You should see several enrollments pop up under the "My Learning" section
  • Click the three dot menu on one of the enrollments, and click Unenroll
  • Click the Unenroll button in the dialog to confirm
  • The enrollment should disappear from the My Learning section, refresh the page to confirm it is gone
  • Browse to the MITx Online dashboard and confirm the enrollment is also no longer in the "My Courses" section

@gumaerc gumaerc added the Needs Review An open Pull Request that is ready for review label Jun 10, 2025
@gumaerc gumaerc force-pushed the cg/dashboard-unenroll-dialog branch from f2031ef to 0400775 Compare June 11, 2025 17:28
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.

Thanks for all the help getting this integrated with mitxonline. It's looking good though I did notice a few things:

Dialog not closing, stale data

Noticed an issue with the modal not closing after deleting enrollments. See video—also left a comment about this.

unenroll_issue.mov

CSRFTokens

Currently the axios instance for Learn API endpoints and MITxOnline endpoints both use NEXT_PUBLIC_CSRF_COOKIE_NAME for the csrf cookie name.

In practice, the cookie names are different... Looks like in learn it's learn_rc_csrftoken.

Screenshot 2025-06-12 at 4 05 05 PM

I'm not 100% sure why the csrftoken name was customized; Asked about it in slack here https://mitodl.slack.com/archives/C03K5HYGPT9/p1749760933111239

Probably we need separate env vars for the two csrftoken names, though.

Or we can hardcode the mitxonline one... the csrftoken in mitxonline is hardcoded to csrftoken anyway.

Copy link
Contributor

Choose a reason for hiding this comment

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

Should put the mutations here or remove the file.

Comment on lines 103 to 102
mutate()
if (isSuccess) {
queryClient.invalidateQueries({
queryKey: enrollmentKeys.enrollmentsList(),
})
modal.hide()
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Request: Change destroyEnrollment to a custom hook useDestroyEnrollment and define the invalidation there.

I think I've mentioned in the past that queryOptions removes, IMO, the need for custom query hooks. But custom mutation hooks still seem very useful. (See TanStack/query#6096 (comment) for why there is no mutationOptions helper; TkDodo is one of the maintainers.)

Note: The code as written does not work, but

await mutateAsync()
queryClient.invalidateQueries({
  queryKey: enrollmentKeys.enrollmentsList(),
})
modal.hide()

probably would.

  • mutate doesn't wait for the mutation to finish. It fires the mutation (here, an API request) and returns immediately. And since isSuccess was defined above BEFORE the mutation fired, it is false (no mutation run yet) when the click handler runs. And the click handler isn't going to re-run when the api request finishes.
  • With mutateAsync, you don't need the if (success) ... since it'll throw an error if there wasn't a success.

Suggestion: Maybe also worth rendering an alert if an error occurs.

@gumaerc
Copy link
Contributor Author

gumaerc commented Jun 16, 2025

@ChristopherChudzicki This is ready for another look, thanks. The only thing I left out for now is any kind of alerting, success or not. It looks like we aren't using a Snackbar component or something similar for displaying notifications? MITx Online displays a "toast" type notification like that based on the response. Do you think we should implement something?

@ChristopherChudzicki
Copy link
Contributor

ChristopherChudzicki commented Jun 17, 2025

@ChristopherChudzicki This is ready for another look, thanks. The only thing I left out for now is any kind of alerting, success or not. It looks like we aren't using a Snackbar component or something similar for displaying notifications? MITx Online displays a "toast" type notification like that based on the response. Do you think we should implement something?

ManageListDialog does

        {mutation.isError && !formik.isSubmitting && (
          <Alert severity="error">
            There was a problem saving your list. Please try again later.
          </Alert>
        )}

Something similar would probably be good here.

Screenshot 2025-06-17 at 10 07 43 AM

Re toasts / snackbar: I'd want to check with @steven-hatch / @mbilalmughal re toasts...

  • if we toast on success, that's visible to everyone, so it's a more significant change
  • IMO they aren't as good as a normal alert, since they usually disappear automatically.

IMO it's OK (and very desirable) to just add something simple like above w/o designs...It's using our smoot component and 99.9% of real users will never see it. (I do expect devs might see it more often, especially with something like this feature where it requires the mitxonline integration)

@gumaerc gumaerc force-pushed the cg/dashboard-unenroll-dialog branch from 64f892c to d1153c6 Compare June 17, 2025 15:34
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.

Going to finish looking over this on the bus in the morning, but left a few comments.

Additionally: Not really a change for this PR, but what we discussed in Slack: I think we need to change MITxOnline so that we can set CSRF_COOKIE_DOMAIN:

  • Django won't automatically read CSRF_COOKIE_DOMAIN from the env.
  • In order for the unenrollment requests to work, I manually added CSRF_COOKIE_DOMAIN = ".odl.local" to MITxOnline.

Without CSRF_COOKIE_DOMAIN = ".odl.local" (or the appropriate env-dependent setting), the frontend JS can't read the CSRF cookie. And, as ChatGPT reminded me:

CSRF Protection in Django (DRF): Cross-Origin DELETE Requests

✅ What Django Requires for CSRF Protection

To satisfy Django’s CSRF protection on unsafe HTTP methods (like DELETE, POST, PUT, PATCH), your frontend must provide both of the following:

  1. ✅ A csrftoken cookie sent with the request.
  2. ✅ The CSRF token value in the X-CSRFToken request header.

❌ Common Misconception

“I only need to send X-CSRFToken, not the cookie.”

That’s not correct. Django requires the csrftoken cookie to be present because it validates that the header's value matches the cookie’s value.

env/frontend.env Outdated
@@ -6,6 +6,7 @@ SENTRY_ENV=dev # Re-enable sentry
NEXT_PUBLIC_ORIGIN=${MITOL_APP_BASE_URL}
NEXT_PUBLIC_MITOL_API_BASE_URL=${MITOL_API_BASE_URL}
NEXT_PUBLIC_CSRF_COOKIE_NAME=${CSRF_COOKIE_NAME}
NEXT_PUBLIC_MITXONLINE_CSRF_COOKIE_NAME=${MITXONLINE_CSRF_COOKIE_NAME}
Copy link
Contributor

Choose a reason for hiding this comment

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

A lot of the frontend settings are set up this way (NEXT_PUBLIC_XYZ=${XYZ}) because they are used on the backend, too, and we want them to be the same.

That's not the case for NEXT_PUBLIC_MITXONLINE_CSRF_COOKIE_NAME... I'd default it to csrftoken since that is what mitxonline uses. Then if someone wants to override it, they can set NEXT_PUBLIC_MITXONLINE_CSRF_COOKIE_NAME in frontend.local.env.

Copy link
Contributor

Choose a reason for hiding this comment

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

I think you can just delete this file now that the hook exists...the hook does enrollmentsApi.enrollmentsDestroy directly

setMockResponse.delete(
mitxonline.urls.enrollment.courseEnrollment(enrollment.id),
() => {
deleteCalls++
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggestion: instead of this, you could do const handleDelete = jest.fn(), and check expect(handleDelete).toHaveBeenCalledTimes(1).

Alternatively: I thought it was odd that we set up the mock response for every card, but only delete one of them.

I was curious what this would look like if we only set up the mock for a randomly chosen card. Here's what I came up with, which I think is also a good alternative:

index 228081f15..5db053086 100644
--- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardDialogs.test.tsx
+++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardDialogs.test.tsx
@@ -10,6 +10,9 @@ import { EnrollmentDisplay } from "./EnrollmentDisplay"
 import * as mitxonline from "api/mitxonline-test-utils"
 import { useFeatureFlagEnabled } from "posthog-js/react"
 import { setupEnrollments } from "./test-utils"
+import { faker } from "@faker-js/faker/locale/en"
+import { mockAxiosInstance } from "api/test-utils"
+import invariant from "tiny-invariant"

 jest.mock("posthog-js/react")
 const mockedUseFeatureFlagEnabled = jest
@@ -30,27 +33,26 @@ describe("DashboardDialogs", () => {
     return { enrollments, completed, expired, started, notStarted }
   }

-  test("Opening the unenroll dialog and confirming the unenroll fires the proper API call", async () => {
+  test.only("Opening the unenroll dialog and confirming the unenroll fires the proper API call", async () => {
     const { enrollments } = setupApis()
-    let deleteCalls = 0
-    for (const enrollment of enrollments) {
-      setMockResponse.delete(
-        mitxonline.urls.enrollment.courseEnrollment(enrollment.id),
-        () => {
-          deleteCalls++
-        },
-      )
-    }
+    const enrollment = faker.helpers.arrayElement(enrollments)
+
+    setMockResponse.delete(
+      mitxonline.urls.enrollment.courseEnrollment(enrollment.id),
+      null,
+    )
     renderWithProviders(<EnrollmentDisplay />)

     await screen.findByRole("heading", { name: "My Learning" })

     const cards = await screen.findAllByTestId("enrollment-card-desktop")
     expect(cards.length).toBe(enrollments.length)
-
-    const contextMenuButton = await within(cards[0]).findByLabelText(
-      "More options",
+    const card = cards.find(
+      (c) => !!within(c).queryByText(enrollment.run.title),
     )
+    invariant(card)
+
+    const contextMenuButton = await within(card).findByLabelText("More options")
     await user.click(contextMenuButton)

     const unenrollButton = await screen.findByRole("menuitem", {
@@ -65,6 +67,11 @@ describe("DashboardDialogs", () => {

     await user.click(confirmButton)

-    expect(deleteCalls).toBe(1)
+    expect(mockAxiosInstance.request).toHaveBeenCalledWith(
+      expect.objectContaining({
+        method: "DELETE",
+        url: mitxonline.urls.enrollment.courseEnrollment(enrollment.id),
+      }),
+    )
   })
 })

It's a bit awkward: Testing library doesn't have a great way to combine selectors like "Find an element with test ID XYZ that also satifises this other property (namely, has text...)". Playwright has a much more sophisticated (but natural-seeming) way to combine selectors like that. Oh well.

@gumaerc
Copy link
Contributor Author

gumaerc commented Jun 18, 2025

@ChristopherChudzicki Thanks, I implemented your suggestions.

@gumaerc gumaerc force-pushed the cg/dashboard-unenroll-dialog branch from ea5648d to 782dd65 Compare June 18, 2025 15:05
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 works for me locally as described.

I think we will have some trouble in RC with cookie domains, but I believe that is expected and part of the purpose of this PR is to demonstrate the issue with that (feature-flagged) content.

@gumaerc gumaerc merged commit 314c559 into main Jun 18, 2025
13 checks passed
@gumaerc gumaerc deleted the cg/dashboard-unenroll-dialog branch June 18, 2025 17:28
This was referenced Jun 18, 2025
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.

2 participants