Skip to content

Commit 08db6a1

Browse files
Program Unenrollment (#3203)
* feat: add program unenrollment via overflow menu on dashboard - Add useDestroyProgramEnrollment hook using v3ProgramEnrollmentsDestroy API - Add UnenrollProgramDialog confirmation dialog for program unenrollment - Add Unenroll menu item to program enrollment cards' overflow menu, visible only for free (non-verified) enrollments of regular programs (display_mode != 'course') - Add programEnrollment URL helper to test utils - Add tests covering visibility conditions and API call correctness Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: add cancel and mobile coverage for UnenrollProgramDialog - Add test verifying Cancel button does not fire the DELETE API call - Add parameterized test covering both desktop and mobile overflow menus Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: prevent loading flash in AllEnrollmentsDisplay after program unenrollment When program enrollments are invalidated after unenrolling, the dependent programsList and coursesList queries change keys (their id arrays change). Without keepPreviousData, the new key has no cache and isLoading fires, blanking the entire section. Use placeholderData: keepPreviousData on those two derived queries so stale data shows during the refetch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * prevent loading state after delete enrollment * remove unnecesary conditional --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 10ede08 commit 08db6a1

6 files changed

Lines changed: 308 additions & 2 deletions

File tree

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,27 @@ const useDestroyEnrollment = () => {
7272
})
7373
}
7474

75+
const useDestroyProgramEnrollment = () => {
76+
const queryClient = useQueryClient()
77+
return useMutation({
78+
mutationFn: (programId: number) =>
79+
programEnrollmentsApi.v3ProgramEnrollmentsDestroy({
80+
program_id: programId,
81+
}),
82+
onSuccess: (_data, vars) => {
83+
queryClient.setQueryData(
84+
enrollmentQueries.programEnrollmentsList().queryKey,
85+
(data) => data?.filter((enrollment) => enrollment.program.id !== vars),
86+
)
87+
},
88+
onSettled: () => {
89+
queryClient.invalidateQueries({
90+
queryKey: enrollmentKeys.programEnrollmentsList(),
91+
})
92+
},
93+
})
94+
}
95+
7596
const useCreateProgramEnrollment = () => {
7697
const queryClient = useQueryClient()
7798
return useMutation({
@@ -110,6 +131,7 @@ export {
110131
useCreateEnrollment,
111132
useUpdateEnrollment,
112133
useDestroyEnrollment,
134+
useDestroyProgramEnrollment,
113135
useCreateProgramEnrollment,
114136
useCreateVerifiedProgramEnrollment,
115137
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ const enrollment = {
2727

2828
const programEnrollments = {
2929
enrollmentsListV3: () => `${API_BASE_URL}/api/v3/program_enrollments/`,
30+
programEnrollment: (programId: number) =>
31+
`${API_BASE_URL}/api/v3/program_enrollments/${programId}/`,
3032
}
3133

3234
const b2b = {

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
EmailSettingsDialog,
2626
JustInTimeDialog,
2727
UnenrollDialog,
28+
UnenrollProgramDialog,
2829
} from "./DashboardDialogs"
2930
import NiceModal from "@ebay/nice-modal-react"
3031
import {
@@ -47,6 +48,7 @@ import {
4748
CourseRunEnrollmentV3,
4849
V3UserProgramEnrollment,
4950
CourseRunV2,
51+
DisplayModeEnum,
5052
} from "@mitodl/mitxonline-api-axios/v2"
5153
import CourseEnrollmentDialog from "@/page-components/EnrollmentDialogs/CourseEnrollmentDialog"
5254

@@ -196,6 +198,23 @@ const getContextMenuItems = (
196198
href: detailsUrl,
197199
})
198200
}
201+
202+
if (
203+
program.display_mode !== DisplayModeEnum.Course &&
204+
!isVerifiedEnrollmentMode(resource.data.enrollment_mode)
205+
) {
206+
menuItems.push({
207+
className: "dashboard-card-menu-item",
208+
key: "unenroll-program",
209+
label: "Unenroll",
210+
onClick: () => {
211+
NiceModal.show(UnenrollProgramDialog, {
212+
title,
213+
programId: program.id,
214+
})
215+
},
216+
})
217+
}
199218
}
200219
if (resource.type === DashboardType.CourseRunEnrollment) {
201220
const detailsUrl = useProductPages

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

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,191 @@ describe("DashboardDialogs", () => {
147147
})
148148
})
149149

150+
describe("UnenrollProgramDialog", () => {
151+
const setupProgramCard = (
152+
enrollmentMode: string | null = "audit",
153+
displayMode: string | null = null,
154+
) => {
155+
const mitxOnlineUser = mitxonline.factories.user.user()
156+
setMockResponse.get(mitxonline.urls.userMe.get(), mitxOnlineUser)
157+
158+
const programEnrollment =
159+
mitxonline.factories.enrollment.programEnrollmentV3({
160+
enrollment_mode: enrollmentMode,
161+
program: { display_mode: displayMode } as never,
162+
})
163+
164+
return { programEnrollment }
165+
}
166+
167+
test("Shows unenroll option for free (audit) program enrollments", async () => {
168+
const { programEnrollment } = setupProgramCard("audit", null)
169+
170+
renderWithProviders(
171+
<DashboardCard
172+
resource={{
173+
type: DashboardType.ProgramEnrollment,
174+
data: programEnrollment,
175+
}}
176+
/>,
177+
)
178+
179+
const desktopCard = await screen.findByTestId("enrollment-card-desktop")
180+
const contextMenuButton = within(desktopCard).getByLabelText("More options")
181+
await user.click(contextMenuButton)
182+
183+
expect(
184+
await screen.findByRole("menuitem", { name: "Unenroll" }),
185+
).toBeInTheDocument()
186+
})
187+
188+
test("Does not show unenroll option for paid (verified) program enrollments", async () => {
189+
const { programEnrollment } = setupProgramCard("verified", null)
190+
191+
renderWithProviders(
192+
<DashboardCard
193+
resource={{
194+
type: DashboardType.ProgramEnrollment,
195+
data: programEnrollment,
196+
}}
197+
/>,
198+
)
199+
200+
const desktopCard = await screen.findByTestId("enrollment-card-desktop")
201+
const contextMenuButton = within(desktopCard).getByLabelText("More options")
202+
await user.click(contextMenuButton)
203+
204+
expect(
205+
screen.queryByRole("menuitem", { name: "Unenroll" }),
206+
).not.toBeInTheDocument()
207+
})
208+
209+
test("Does not show unenroll option for program-as-course display_mode programs", async () => {
210+
const { programEnrollment } = setupProgramCard("audit", "course")
211+
212+
renderWithProviders(
213+
<DashboardCard
214+
resource={{
215+
type: DashboardType.ProgramEnrollment,
216+
data: programEnrollment,
217+
}}
218+
/>,
219+
)
220+
221+
const desktopCard = await screen.findByTestId("enrollment-card-desktop")
222+
const contextMenuButton = within(desktopCard).getByLabelText("More options")
223+
await user.click(contextMenuButton)
224+
225+
expect(
226+
screen.queryByRole("menuitem", { name: "Unenroll" }),
227+
).not.toBeInTheDocument()
228+
})
229+
230+
test("Confirming unenroll from a program fires the proper API call", async () => {
231+
const { programEnrollment } = setupProgramCard("audit", null)
232+
233+
setMockResponse.delete(
234+
mitxonline.urls.programEnrollments.programEnrollment(
235+
programEnrollment.program.id,
236+
),
237+
null,
238+
)
239+
240+
renderWithProviders(
241+
<DashboardCard
242+
resource={{
243+
type: DashboardType.ProgramEnrollment,
244+
data: programEnrollment,
245+
}}
246+
/>,
247+
)
248+
249+
const desktopCard = await screen.findByTestId("enrollment-card-desktop")
250+
const contextMenuButton = within(desktopCard).getByLabelText("More options")
251+
await user.click(contextMenuButton)
252+
253+
const unenrollMenuItem = await screen.findByRole("menuitem", {
254+
name: "Unenroll",
255+
})
256+
await user.click(unenrollMenuItem)
257+
258+
const dialog = await screen.findByRole("dialog", {
259+
name: `Unenroll from ${programEnrollment.program.title}`,
260+
})
261+
expect(dialog).toBeInTheDocument()
262+
expect(
263+
within(dialog).getByText(
264+
`Are you sure you want to unenroll from ${programEnrollment.program.title}?`,
265+
),
266+
).toBeInTheDocument()
267+
268+
const confirmButton = within(dialog).getByRole("button", {
269+
name: "Unenroll",
270+
})
271+
await user.click(confirmButton)
272+
273+
expect(mockAxiosInstance.request).toHaveBeenCalledWith(
274+
expect.objectContaining({
275+
method: "DELETE",
276+
url: mitxonline.urls.programEnrollments.programEnrollment(
277+
programEnrollment.program.id,
278+
),
279+
}),
280+
)
281+
})
282+
283+
test("Cancelling the dialog does not fire the API call", async () => {
284+
const { programEnrollment } = setupProgramCard("audit", null)
285+
286+
renderWithProviders(
287+
<DashboardCard
288+
resource={{
289+
type: DashboardType.ProgramEnrollment,
290+
data: programEnrollment,
291+
}}
292+
/>,
293+
)
294+
295+
const desktopCard = await screen.findByTestId("enrollment-card-desktop")
296+
const contextMenuButton = within(desktopCard).getByLabelText("More options")
297+
await user.click(contextMenuButton)
298+
299+
await user.click(await screen.findByRole("menuitem", { name: "Unenroll" }))
300+
await screen.findByRole("dialog", {
301+
name: `Unenroll from ${programEnrollment.program.title}`,
302+
})
303+
304+
await user.click(screen.getByRole("button", { name: "Cancel" }))
305+
306+
expect(mockAxiosInstance.request).not.toHaveBeenCalledWith(
307+
expect.objectContaining({ method: "DELETE" }),
308+
)
309+
})
310+
311+
test.each(["enrollment-card-desktop", "enrollment-card-mobile"] as const)(
312+
"Unenroll option is accessible from the %s overflow menu",
313+
async (cardTestId) => {
314+
const { programEnrollment } = setupProgramCard("audit", null)
315+
316+
renderWithProviders(
317+
<DashboardCard
318+
resource={{
319+
type: DashboardType.ProgramEnrollment,
320+
data: programEnrollment,
321+
}}
322+
/>,
323+
)
324+
325+
const card = await screen.findByTestId(cardTestId)
326+
await user.click(within(card).getByLabelText("More options"))
327+
328+
expect(
329+
await screen.findByRole("menuitem", { name: "Unenroll" }),
330+
).toBeInTheDocument()
331+
},
332+
)
333+
})
334+
150335
describe("JustInTimeDialog", () => {
151336
const getFields = (root: HTMLElement) => {
152337
return {

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

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { useFormik } from "formik"
1717
import {
1818
useCreateB2bEnrollment,
1919
useDestroyEnrollment,
20+
useDestroyProgramEnrollment,
2021
useUpdateEnrollment,
2122
} from "api/mitxonline-hooks/enrollment"
2223
import {
@@ -318,4 +319,77 @@ const EmailSettingsDialog = NiceModal.create(EmailSettingsDialogInner)
318319
const UnenrollDialog = NiceModal.create(UnenrollDialogInner)
319320
const JustInTimeDialog = NiceModal.create(JustInTimeDialogInner)
320321

321-
export { EmailSettingsDialog, UnenrollDialog, JustInTimeDialog }
322+
type UnenrollProgramDialogProps = {
323+
title: string
324+
programId: number
325+
}
326+
327+
const UnenrollProgramDialogInner: React.FC<UnenrollProgramDialogProps> = ({
328+
title,
329+
programId,
330+
}) => {
331+
const modal = NiceModal.useModal()
332+
const destroyProgramEnrollment = useDestroyProgramEnrollment()
333+
const formik = useFormik({
334+
enableReinitialize: true,
335+
validateOnChange: false,
336+
validateOnBlur: false,
337+
initialValues: {},
338+
onSubmit: async () => {
339+
await destroyProgramEnrollment.mutateAsync(programId)
340+
modal.hide()
341+
},
342+
})
343+
return (
344+
<FormDialog
345+
title={`Unenroll from ${title}`}
346+
fullWidth
347+
onReset={formik.resetForm}
348+
onSubmit={formik.handleSubmit}
349+
{...muiDialogV5(modal)}
350+
actions={
351+
<DialogActions>
352+
<Button
353+
variant="secondary"
354+
onClick={() => {
355+
modal.hide()
356+
}}
357+
>
358+
Cancel
359+
</Button>
360+
<Button
361+
variant="primary"
362+
type="submit"
363+
disabled={destroyProgramEnrollment.isPending}
364+
endIcon={
365+
destroyProgramEnrollment.isPending ? (
366+
<LoadingSpinner color="inherit" loading={true} size={16} />
367+
) : undefined
368+
}
369+
>
370+
Unenroll
371+
</Button>
372+
</DialogActions>
373+
}
374+
>
375+
<Typography variant="body1">
376+
Are you sure you want to unenroll from {title}?
377+
</Typography>
378+
{destroyProgramEnrollment.isError && (
379+
<Alert severity="error">
380+
There was a problem unenrolling you from this program. Please try
381+
again later.
382+
</Alert>
383+
)}
384+
</FormDialog>
385+
)
386+
}
387+
388+
const UnenrollProgramDialog = NiceModal.create(UnenrollProgramDialogInner)
389+
390+
export {
391+
EmailSettingsDialog,
392+
UnenrollDialog,
393+
UnenrollProgramDialog,
394+
JustInTimeDialog,
395+
}

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
theme,
1515
} from "ol-components"
1616
import { Alert } from "@mitodl/smoot-design"
17-
import { useQuery } from "@tanstack/react-query"
17+
import { keepPreviousData, useQuery } from "@tanstack/react-query"
1818
import {
1919
EnrollmentStatus,
2020
getEnrollmentStatus,
@@ -790,6 +790,9 @@ const AllEnrollmentsDisplay: React.FC = () => {
790790
page_size: enrolledProgramIds.length,
791791
}),
792792
enabled: enrolledProgramIds.length > 0,
793+
// If the query key changes, show the old data while loading
794+
// example: Deleting a program enrollment
795+
placeholderData: keepPreviousData,
793796
})
794797

795798
const filteredProgramEnrollments = enrolledPrograms
@@ -820,6 +823,7 @@ const AllEnrollmentsDisplay: React.FC = () => {
820823
page_size: homeCourseProgramModuleIds.length || undefined,
821824
}),
822825
enabled: homeCourseProgramModuleIds.length > 0,
826+
placeholderData: keepPreviousData,
823827
})
824828

825829
const homeCourseProgramsById = new Map(

0 commit comments

Comments
 (0)