Skip to content

Commit 314c559

Browse files
authored
dashboard unenroll dialog functionality (#2303)
* make the unenroll dialog actually work * don't log unenroll errors directly to the console * fix existing tests that use enrollments to properly set the enrollment id, and add a new test for the unenroll dialog * set CSRF token in mitxonline axios api in the frontend explicitly * move mutations to its own file * it would help if I checked in the file * add useDestroyEnrollment hook * Add a loading state to the dialog as well as an alert if there was an error * fix issues after rebase * fix the loading spinner * default mitx online csrf cookie name to csrftoken and fix naming * remove unnecessary mutations file * test improvements
1 parent bf6d93a commit 314c559

File tree

19 files changed

+263
-106
lines changed

19 files changed

+263
-106
lines changed

env/frontend.env

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ SENTRY_ENV=dev # Re-enable sentry
66
NEXT_PUBLIC_ORIGIN=${MITOL_APP_BASE_URL}
77
NEXT_PUBLIC_MITOL_API_BASE_URL=${MITOL_API_BASE_URL}
88
NEXT_PUBLIC_CSRF_COOKIE_NAME=${CSRF_COOKIE_NAME}
9+
NEXT_PUBLIC_MITX_ONLINE_CSRF_COOKIE_NAME=csrftoken
910
NEXT_PUBLIC_MITOL_SUPPORT_EMAIL=${MITOL_SUPPORT_EMAIL}
1011

1112
NEXT_PUBLIC_POSTHOG_API_KEY=${POSTHOG_PROJECT_API_KEY}

frontends/api/src/mitxonline/clients.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import axios from "axios"
99

1010
const axiosInstance = axios.create({
1111
baseURL: process.env.NEXT_PUBLIC_MITX_ONLINE_BASE_URL,
12-
xsrfCookieName: process.env.NEXT_PUBLIC_CSRF_COOKIE_NAME,
12+
xsrfCookieName: process.env.NEXT_PUBLIC_MITX_ONLINE_CSRF_COOKIE_NAME,
1313
xsrfHeaderName: "X-CSRFToken",
1414
withXSRFToken: true,
1515
withCredentials:
Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,17 @@
1-
import { enrollmentQueries } from "./queries"
1+
import { enrollmentQueries, enrollmentKeys } from "./queries"
2+
import { useMutation, useQueryClient } from "@tanstack/react-query"
3+
import { enrollmentsApi } from "../../clients"
24

3-
export { enrollmentQueries }
5+
const useDestroyEnrollment = (enrollmentId: number) => {
6+
const queryClient = useQueryClient()
7+
return useMutation({
8+
mutationFn: () => enrollmentsApi.enrollmentsDestroy({ id: enrollmentId }),
9+
onSuccess: () => {
10+
queryClient.invalidateQueries({
11+
queryKey: enrollmentKeys.enrollmentsList(),
12+
})
13+
},
14+
})
15+
}
16+
17+
export { enrollmentQueries, enrollmentKeys, useDestroyEnrollment }

frontends/api/src/mitxonline/hooks/enrollment/queries.ts

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,18 @@ import { queryOptions } from "@tanstack/react-query"
22
import type { CourseRunEnrollment } from "@mitodl/mitxonline-api-axios/v1"
33

44
import { enrollmentsApi } from "../../clients"
5-
import { RawAxiosRequestConfig } from "axios"
65

76
const enrollmentKeys = {
87
root: ["mitxonline", "enrollments"],
9-
enrollmentsList: (opts: RawAxiosRequestConfig) => [
10-
...enrollmentKeys.root,
11-
"programEnrollments",
12-
"list",
13-
opts,
14-
],
8+
enrollmentsList: () => [...enrollmentKeys.root, "programEnrollments", "list"],
159
}
1610

1711
const enrollmentQueries = {
18-
enrollmentsList: (opts: RawAxiosRequestConfig) =>
12+
enrollmentsList: () =>
1913
queryOptions({
20-
queryKey: enrollmentKeys.enrollmentsList(opts),
14+
queryKey: enrollmentKeys.enrollmentsList(),
2115
queryFn: async (): Promise<CourseRunEnrollment[]> => {
22-
return enrollmentsApi.enrollmentsList(opts).then((res) => res.data)
16+
return enrollmentsApi.enrollmentsList().then((res) => res.data)
2317
},
2418
}),
2519
}

frontends/api/src/mitxonline/test-utils/urls.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ const currentUser = {
1313
}
1414

1515
const enrollment = {
16-
courseEnrollment: (opts?: RawAxiosRequestConfig) =>
17-
`${API_BASE_URL}/api/v1/enrollments/${queryify(opts)}`,
16+
courseEnrollment: (id?: number) =>
17+
`${API_BASE_URL}/api/v1/enrollments/${id ? `${id}/` : ""}`,
1818
}
1919

2020
const programs = {

frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.test.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,7 @@ describe.each([
350350
async ({ contextMenuItems }) => {
351351
const course = dashboardCourse()
352352
course.enrollment = {
353+
id: faker.number.int(),
353354
status: EnrollmentStatus.Completed,
354355
mode: EnrollmentMode.Verified,
355356
}
@@ -366,7 +367,7 @@ describe.each([
366367
await user.click(contextMenuButton)
367368
const expectedMenuItems = [
368369
...contextMenuItems,
369-
...getDefaultContextMenuItems("Test Course"),
370+
...getDefaultContextMenuItems("Test Course", course.enrollment.id),
370371
]
371372
const menuItems = screen.getAllByRole("menuitem")
372373
for (let i = 0; i < expectedMenuItems.length; i++) {

frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ const MenuButton = styled(ActionButton)<{
7878
},
7979
])
8080

81-
const getDefaultContextMenuItems = (title: string) => {
81+
const getDefaultContextMenuItems = (title: string, enrollmentId: number) => {
8282
return [
8383
{
8484
className: "dashboard-card-menu-item",
@@ -93,7 +93,7 @@ const getDefaultContextMenuItems = (title: string) => {
9393
key: "unenroll",
9494
label: "Unenroll",
9595
onClick: () => {
96-
NiceModal.show(UnenrollDialog, { title })
96+
NiceModal.show(UnenrollDialog, { title, enrollmentId })
9797
},
9898
},
9999
]
@@ -332,10 +332,12 @@ const DashboardCard: React.FC<DashboardCardProps> = ({
332332
) : run.startDate ? (
333333
<CourseStartCountdown startDate={run.startDate} />
334334
) : null
335-
const menuItems = contextMenuItems.concat(getDefaultContextMenuItems(title))
335+
const menuItems = contextMenuItems.concat(
336+
enrollment?.id ? getDefaultContextMenuItems(title, enrollment?.id) : [],
337+
)
336338
const contextMenu = isLoading ? (
337339
<Skeleton variant="rectangular" width={12} height={24} />
338-
) : (
340+
) : menuItems.length > 0 ? (
339341
<SimpleMenu
340342
items={menuItems}
341343
trigger={
@@ -349,7 +351,7 @@ const DashboardCard: React.FC<DashboardCardProps> = ({
349351
</MenuButton>
350352
}
351353
/>
352-
)
354+
) : null
353355
const desktopLayout = (
354356
<CardRoot
355357
screenSize="desktop"
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import React from "react"
2+
import {
3+
renderWithProviders,
4+
screen,
5+
setMockResponse,
6+
user,
7+
within,
8+
} from "@/test-utils"
9+
import { EnrollmentDisplay } from "./EnrollmentDisplay"
10+
import * as mitxonline from "api/mitxonline-test-utils"
11+
import { useFeatureFlagEnabled } from "posthog-js/react"
12+
import { setupEnrollments } from "./test-utils"
13+
import { faker } from "@faker-js/faker/locale/en"
14+
import { mockAxiosInstance } from "api/test-utils"
15+
import invariant from "tiny-invariant"
16+
17+
jest.mock("posthog-js/react")
18+
const mockedUseFeatureFlagEnabled = jest
19+
.mocked(useFeatureFlagEnabled)
20+
.mockImplementation(() => false)
21+
22+
describe("DashboardDialogs", () => {
23+
const setupApis = (includeExpired: boolean = true) => {
24+
const { enrollments, completed, expired, started, notStarted } =
25+
setupEnrollments(includeExpired)
26+
27+
mockedUseFeatureFlagEnabled.mockReturnValue(true)
28+
setMockResponse.get(
29+
mitxonline.urls.enrollment.courseEnrollment(),
30+
enrollments,
31+
)
32+
33+
return { enrollments, completed, expired, started, notStarted }
34+
}
35+
36+
test("Opening the unenroll dialog and confirming the unenroll fires the proper API call", async () => {
37+
const { enrollments } = setupApis()
38+
const enrollment = faker.helpers.arrayElement(enrollments)
39+
40+
setMockResponse.delete(
41+
mitxonline.urls.enrollment.courseEnrollment(enrollment.id),
42+
null,
43+
)
44+
renderWithProviders(<EnrollmentDisplay />)
45+
46+
await screen.findByRole("heading", { name: "My Learning" })
47+
48+
const cards = await screen.findAllByTestId("enrollment-card-desktop")
49+
expect(cards.length).toBe(enrollments.length)
50+
51+
const card = cards.find(
52+
(c) => !!within(c).queryByText(enrollment.run.title),
53+
)
54+
invariant(card)
55+
56+
const contextMenuButton = await within(card).findByLabelText("More options")
57+
await user.click(contextMenuButton)
58+
59+
const unenrollButton = await screen.findByRole("menuitem", {
60+
name: "Unenroll",
61+
})
62+
await user.click(unenrollButton)
63+
64+
const confirmButton = await screen.findByRole("button", {
65+
name: "Unenroll",
66+
})
67+
expect(confirmButton).toBeEnabled()
68+
69+
await user.click(confirmButton)
70+
71+
expect(mockAxiosInstance.request).toHaveBeenCalledWith(
72+
expect.objectContaining({
73+
method: "DELETE",
74+
url: mitxonline.urls.enrollment.courseEnrollment(enrollment.id),
75+
}),
76+
)
77+
})
78+
})

frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardDialogs.tsx

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,25 @@ import {
55
FormDialog,
66
DialogActions,
77
Stack,
8+
LoadingSpinner,
89
} from "ol-components"
910
import { Button, Checkbox, Alert } from "@mitodl/smoot-design"
1011

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

1416
const BoldText = styled.span(({ theme }) => ({
1517
...theme.typography.subtitle1,
1618
}))
1719

20+
const SpinnerContainer = styled.div({
21+
marginLeft: "8px",
22+
})
23+
1824
type DashboardDialogProps = {
1925
title: string
26+
enrollmentId: number
2027
}
2128
const EmailSettingsDialogInner: React.FC<DashboardDialogProps> = ({
2229
title,
@@ -77,15 +84,22 @@ const EmailSettingsDialogInner: React.FC<DashboardDialogProps> = ({
7784
)
7885
}
7986

80-
const UnenrollDialogInner: React.FC<DashboardDialogProps> = ({ title }) => {
87+
const UnenrollDialogInner: React.FC<DashboardDialogProps> = ({
88+
title,
89+
enrollmentId,
90+
}) => {
8191
const modal = NiceModal.useModal()
92+
const destroyEnrollment = useDestroyEnrollment(enrollmentId)
8293
const formik = useFormik({
8394
enableReinitialize: true,
8495
validateOnChange: false,
8596
validateOnBlur: false,
8697
initialValues: {},
8798
onSubmit: async () => {
88-
// TODO: Handle form submission
99+
await destroyEnrollment.mutateAsync()
100+
if (!destroyEnrollment.isError) {
101+
modal.hide()
102+
}
89103
},
90104
})
91105
return (
@@ -105,15 +119,33 @@ const UnenrollDialogInner: React.FC<DashboardDialogProps> = ({ title }) => {
105119
>
106120
Cancel
107121
</Button>
108-
<Button variant="primary" type="submit">
122+
<Button
123+
variant="primary"
124+
type="submit"
125+
disabled={destroyEnrollment.isPending}
126+
>
109127
Unenroll
128+
{destroyEnrollment.isPending && (
129+
<SpinnerContainer>
130+
<LoadingSpinner
131+
loading={destroyEnrollment.isPending}
132+
size={16}
133+
/>
134+
</SpinnerContainer>
135+
)}
110136
</Button>
111137
</DialogActions>
112138
}
113139
>
114140
<Typography variant="body1">
115141
Are you sure you want to unenroll from {title}?
116142
</Typography>
143+
{destroyEnrollment.isError && (
144+
<Alert severity="error">
145+
There was a problem unenrolling you from this course. Please try again
146+
later.
147+
</Alert>
148+
)}
117149
</FormDialog>
118150
)
119151
}

0 commit comments

Comments
 (0)