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
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions env/frontend.env
Original file line number Diff line number Diff line change
Expand Up @@ -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_MITX_ONLINE_CSRF_COOKIE_NAME=csrftoken
NEXT_PUBLIC_MITOL_SUPPORT_EMAIL=${MITOL_SUPPORT_EMAIL}

NEXT_PUBLIC_POSTHOG_API_KEY=${POSTHOG_PROJECT_API_KEY}
Expand Down
2 changes: 1 addition & 1 deletion frontends/api/src/mitxonline/clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import axios from "axios"

const axiosInstance = axios.create({
baseURL: process.env.NEXT_PUBLIC_MITX_ONLINE_BASE_URL,
xsrfCookieName: process.env.NEXT_PUBLIC_CSRF_COOKIE_NAME,
xsrfCookieName: process.env.NEXT_PUBLIC_MITX_ONLINE_CSRF_COOKIE_NAME,
xsrfHeaderName: "X-CSRFToken",
withXSRFToken: true,
withCredentials:
Expand Down
18 changes: 16 additions & 2 deletions frontends/api/src/mitxonline/hooks/enrollment/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
import { enrollmentQueries } from "./queries"
import { enrollmentQueries, enrollmentKeys } from "./queries"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { enrollmentsApi } from "../../clients"

export { enrollmentQueries }
const useDestroyEnrollment = (enrollmentId: number) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => enrollmentsApi.enrollmentsDestroy({ id: enrollmentId }),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: enrollmentKeys.enrollmentsList(),
})
},
})
}

export { enrollmentQueries, enrollmentKeys, useDestroyEnrollment }
14 changes: 4 additions & 10 deletions frontends/api/src/mitxonline/hooks/enrollment/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,18 @@ import { queryOptions } from "@tanstack/react-query"
import type { CourseRunEnrollment } from "@mitodl/mitxonline-api-axios/v1"

import { enrollmentsApi } from "../../clients"
import { RawAxiosRequestConfig } from "axios"

const enrollmentKeys = {
root: ["mitxonline", "enrollments"],
enrollmentsList: (opts: RawAxiosRequestConfig) => [
...enrollmentKeys.root,
"programEnrollments",
"list",
opts,
],
enrollmentsList: () => [...enrollmentKeys.root, "programEnrollments", "list"],
}

const enrollmentQueries = {
enrollmentsList: (opts: RawAxiosRequestConfig) =>
enrollmentsList: () =>
queryOptions({
queryKey: enrollmentKeys.enrollmentsList(opts),
queryKey: enrollmentKeys.enrollmentsList(),
queryFn: async (): Promise<CourseRunEnrollment[]> => {
return enrollmentsApi.enrollmentsList(opts).then((res) => res.data)
return enrollmentsApi.enrollmentsList().then((res) => res.data)
},
}),
}
Expand Down
4 changes: 2 additions & 2 deletions frontends/api/src/mitxonline/test-utils/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ const currentUser = {
}

const enrollment = {
courseEnrollment: (opts?: RawAxiosRequestConfig) =>
`${API_BASE_URL}/api/v1/enrollments/${queryify(opts)}`,
courseEnrollment: (id?: number) =>
`${API_BASE_URL}/api/v1/enrollments/${id ? `${id}/` : ""}`,
}

const programs = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,7 @@ describe.each([
async ({ contextMenuItems }) => {
const course = dashboardCourse()
course.enrollment = {
id: faker.number.int(),
status: EnrollmentStatus.Completed,
mode: EnrollmentMode.Verified,
}
Expand All @@ -366,7 +367,7 @@ describe.each([
await user.click(contextMenuButton)
const expectedMenuItems = [
...contextMenuItems,
...getDefaultContextMenuItems("Test Course"),
...getDefaultContextMenuItems("Test Course", course.enrollment.id),
]
const menuItems = screen.getAllByRole("menuitem")
for (let i = 0; i < expectedMenuItems.length; i++) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ const MenuButton = styled(ActionButton)<{
},
])

const getDefaultContextMenuItems = (title: string) => {
const getDefaultContextMenuItems = (title: string, enrollmentId: number) => {
return [
{
className: "dashboard-card-menu-item",
Expand All @@ -93,7 +93,7 @@ const getDefaultContextMenuItems = (title: string) => {
key: "unenroll",
label: "Unenroll",
onClick: () => {
NiceModal.show(UnenrollDialog, { title })
NiceModal.show(UnenrollDialog, { title, enrollmentId })
},
},
]
Expand Down Expand Up @@ -332,10 +332,12 @@ const DashboardCard: React.FC<DashboardCardProps> = ({
) : run.startDate ? (
<CourseStartCountdown startDate={run.startDate} />
) : null
const menuItems = contextMenuItems.concat(getDefaultContextMenuItems(title))
const menuItems = contextMenuItems.concat(
enrollment?.id ? getDefaultContextMenuItems(title, enrollment?.id) : [],
)
const contextMenu = isLoading ? (
<Skeleton variant="rectangular" width={12} height={24} />
) : (
) : menuItems.length > 0 ? (
<SimpleMenu
items={menuItems}
trigger={
Expand All @@ -349,7 +351,7 @@ const DashboardCard: React.FC<DashboardCardProps> = ({
</MenuButton>
}
/>
)
) : null
const desktopLayout = (
<CardRoot
screenSize="desktop"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import React from "react"
import {
renderWithProviders,
screen,
setMockResponse,
user,
within,
} from "@/test-utils"
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
.mocked(useFeatureFlagEnabled)
.mockImplementation(() => false)

describe("DashboardDialogs", () => {
const setupApis = (includeExpired: boolean = true) => {
const { enrollments, completed, expired, started, notStarted } =
setupEnrollments(includeExpired)

mockedUseFeatureFlagEnabled.mockReturnValue(true)
setMockResponse.get(
mitxonline.urls.enrollment.courseEnrollment(),
enrollments,
)

return { enrollments, completed, expired, started, notStarted }
}

test("Opening the unenroll dialog and confirming the unenroll fires the proper API call", async () => {
const { enrollments } = setupApis()
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 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", {
name: "Unenroll",
})
await user.click(unenrollButton)

const confirmButton = await screen.findByRole("button", {
name: "Unenroll",
})
expect(confirmButton).toBeEnabled()

await user.click(confirmButton)

expect(mockAxiosInstance.request).toHaveBeenCalledWith(
expect.objectContaining({
method: "DELETE",
url: mitxonline.urls.enrollment.courseEnrollment(enrollment.id),
}),
)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,25 @@ import {
FormDialog,
DialogActions,
Stack,
LoadingSpinner,
} from "ol-components"
import { Button, Checkbox, Alert } from "@mitodl/smoot-design"

import NiceModal, { muiDialogV5 } from "@ebay/nice-modal-react"
import { useFormik } from "formik"
import { useDestroyEnrollment } from "api/mitxonline-hooks/enrollment"

const BoldText = styled.span(({ theme }) => ({
...theme.typography.subtitle1,
}))

const SpinnerContainer = styled.div({
marginLeft: "8px",
})

type DashboardDialogProps = {
title: string
enrollmentId: number
}
const EmailSettingsDialogInner: React.FC<DashboardDialogProps> = ({
title,
Expand Down Expand Up @@ -77,15 +84,22 @@ const EmailSettingsDialogInner: React.FC<DashboardDialogProps> = ({
)
}

const UnenrollDialogInner: React.FC<DashboardDialogProps> = ({ title }) => {
const UnenrollDialogInner: React.FC<DashboardDialogProps> = ({
title,
enrollmentId,
}) => {
const modal = NiceModal.useModal()
const destroyEnrollment = useDestroyEnrollment(enrollmentId)
const formik = useFormik({
enableReinitialize: true,
validateOnChange: false,
validateOnBlur: false,
initialValues: {},
onSubmit: async () => {
// TODO: Handle form submission
await destroyEnrollment.mutateAsync()
if (!destroyEnrollment.isError) {
modal.hide()
}
},
})
return (
Expand All @@ -105,15 +119,33 @@ const UnenrollDialogInner: React.FC<DashboardDialogProps> = ({ title }) => {
>
Cancel
</Button>
<Button variant="primary" type="submit">
<Button
variant="primary"
type="submit"
disabled={destroyEnrollment.isPending}
>
Unenroll
{destroyEnrollment.isPending && (
<SpinnerContainer>
<LoadingSpinner
loading={destroyEnrollment.isPending}
size={16}
/>
</SpinnerContainer>
)}
</Button>
</DialogActions>
}
>
<Typography variant="body1">
Are you sure you want to unenroll from {title}?
</Typography>
{destroyEnrollment.isError && (
<Alert severity="error">
There was a problem unenrolling you from this course. Please try again
later.
</Alert>
)}
</FormDialog>
)
}
Expand Down
Loading
Loading