Skip to content
Closed
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
12 changes: 12 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
Release Notes
=============

Version 0.63.6
--------------

- Facet counts and aggregations for Vector search (#3210)
- Program Unenrollment (#3203)
- Update dependency Django to v4.2.30 [SECURITY] (#3212)
- Update dependency cryptography to v46.0.7 [SECURITY] (#3211)
- Update dependency requests to v2.33.0 [SECURITY] (#3214)
- Filtering for similarity endpoints (#3204)
- fix: minor improvements on video collection pages (#3205)
- Update dependency litellm to v1.83.0 [SECURITY] (#3213)

Version 0.63.5 (Released April 16, 2026)
--------------

Expand Down
105 changes: 105 additions & 0 deletions frontends/api/src/generated/v0/api.ts

Large diffs are not rendered by default.

576 changes: 230 additions & 346 deletions frontends/api/src/generated/v1/api.ts

Large diffs are not rendered by default.

22 changes: 22 additions & 0 deletions frontends/api/src/mitxonline/hooks/enrollment/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,27 @@ const useDestroyEnrollment = () => {
})
}

const useDestroyProgramEnrollment = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (programId: number) =>
programEnrollmentsApi.v3ProgramEnrollmentsDestroy({
program_id: programId,
}),
onSuccess: (_data, vars) => {
queryClient.setQueryData(
enrollmentQueries.programEnrollmentsList().queryKey,
(data) => data?.filter((enrollment) => enrollment.program.id !== vars),
)
},
onSettled: () => {
queryClient.invalidateQueries({
queryKey: enrollmentKeys.programEnrollmentsList(),
})
},
})
}

const useCreateProgramEnrollment = () => {
const queryClient = useQueryClient()
return useMutation({
Expand Down Expand Up @@ -110,6 +131,7 @@ export {
useCreateEnrollment,
useUpdateEnrollment,
useDestroyEnrollment,
useDestroyProgramEnrollment,
useCreateProgramEnrollment,
useCreateVerifiedProgramEnrollment,
}
2 changes: 2 additions & 0 deletions frontends/api/src/mitxonline/test-utils/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ const enrollment = {

const programEnrollments = {
enrollmentsListV3: () => `${API_BASE_URL}/api/v3/program_enrollments/`,
programEnrollment: (programId: number) =>
`${API_BASE_URL}/api/v3/program_enrollments/${programId}/`,
}

const b2b = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
EmailSettingsDialog,
JustInTimeDialog,
UnenrollDialog,
UnenrollProgramDialog,
} from "./DashboardDialogs"
import NiceModal from "@ebay/nice-modal-react"
import {
Expand All @@ -47,6 +48,7 @@ import {
CourseRunEnrollmentV3,
V3UserProgramEnrollment,
CourseRunV2,
DisplayModeEnum,
} from "@mitodl/mitxonline-api-axios/v2"
import CourseEnrollmentDialog from "@/page-components/EnrollmentDialogs/CourseEnrollmentDialog"

Expand Down Expand Up @@ -196,6 +198,23 @@ const getContextMenuItems = (
href: detailsUrl,
})
}

if (
program.display_mode !== DisplayModeEnum.Course &&
!isVerifiedEnrollmentMode(resource.data.enrollment_mode)
) {
menuItems.push({
className: "dashboard-card-menu-item",
key: "unenroll-program",
label: "Unenroll",
onClick: () => {
NiceModal.show(UnenrollProgramDialog, {
title,
programId: program.id,
})
},
})
}
}
if (resource.type === DashboardType.CourseRunEnrollment) {
const detailsUrl = useProductPages
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,191 @@ describe("DashboardDialogs", () => {
})
})

describe("UnenrollProgramDialog", () => {
const setupProgramCard = (
enrollmentMode: string | null = "audit",
displayMode: string | null = null,
) => {
const mitxOnlineUser = mitxonline.factories.user.user()
setMockResponse.get(mitxonline.urls.userMe.get(), mitxOnlineUser)

const programEnrollment =
mitxonline.factories.enrollment.programEnrollmentV3({
enrollment_mode: enrollmentMode,
program: { display_mode: displayMode } as never,
})

return { programEnrollment }
}

test("Shows unenroll option for free (audit) program enrollments", async () => {
const { programEnrollment } = setupProgramCard("audit", null)

renderWithProviders(
<DashboardCard
resource={{
type: DashboardType.ProgramEnrollment,
data: programEnrollment,
}}
/>,
)

const desktopCard = await screen.findByTestId("enrollment-card-desktop")
const contextMenuButton = within(desktopCard).getByLabelText("More options")
await user.click(contextMenuButton)

expect(
await screen.findByRole("menuitem", { name: "Unenroll" }),
).toBeInTheDocument()
})

test("Does not show unenroll option for paid (verified) program enrollments", async () => {
const { programEnrollment } = setupProgramCard("verified", null)

renderWithProviders(
<DashboardCard
resource={{
type: DashboardType.ProgramEnrollment,
data: programEnrollment,
}}
/>,
)

const desktopCard = await screen.findByTestId("enrollment-card-desktop")
const contextMenuButton = within(desktopCard).getByLabelText("More options")
await user.click(contextMenuButton)

expect(
screen.queryByRole("menuitem", { name: "Unenroll" }),
).not.toBeInTheDocument()
})

test("Does not show unenroll option for program-as-course display_mode programs", async () => {
const { programEnrollment } = setupProgramCard("audit", "course")

renderWithProviders(
<DashboardCard
resource={{
type: DashboardType.ProgramEnrollment,
data: programEnrollment,
}}
/>,
)

const desktopCard = await screen.findByTestId("enrollment-card-desktop")
const contextMenuButton = within(desktopCard).getByLabelText("More options")
await user.click(contextMenuButton)

expect(
screen.queryByRole("menuitem", { name: "Unenroll" }),
).not.toBeInTheDocument()
})

test("Confirming unenroll from a program fires the proper API call", async () => {
const { programEnrollment } = setupProgramCard("audit", null)

setMockResponse.delete(
mitxonline.urls.programEnrollments.programEnrollment(
programEnrollment.program.id,
),
null,
)

renderWithProviders(
<DashboardCard
resource={{
type: DashboardType.ProgramEnrollment,
data: programEnrollment,
}}
/>,
)

const desktopCard = await screen.findByTestId("enrollment-card-desktop")
const contextMenuButton = within(desktopCard).getByLabelText("More options")
await user.click(contextMenuButton)

const unenrollMenuItem = await screen.findByRole("menuitem", {
name: "Unenroll",
})
await user.click(unenrollMenuItem)

const dialog = await screen.findByRole("dialog", {
name: `Unenroll from ${programEnrollment.program.title}`,
})
expect(dialog).toBeInTheDocument()
expect(
within(dialog).getByText(
`Are you sure you want to unenroll from ${programEnrollment.program.title}?`,
),
).toBeInTheDocument()

const confirmButton = within(dialog).getByRole("button", {
name: "Unenroll",
})
await user.click(confirmButton)

expect(mockAxiosInstance.request).toHaveBeenCalledWith(
expect.objectContaining({
method: "DELETE",
url: mitxonline.urls.programEnrollments.programEnrollment(
programEnrollment.program.id,
),
}),
)
})

test("Cancelling the dialog does not fire the API call", async () => {
const { programEnrollment } = setupProgramCard("audit", null)

renderWithProviders(
<DashboardCard
resource={{
type: DashboardType.ProgramEnrollment,
data: programEnrollment,
}}
/>,
)

const desktopCard = await screen.findByTestId("enrollment-card-desktop")
const contextMenuButton = within(desktopCard).getByLabelText("More options")
await user.click(contextMenuButton)

await user.click(await screen.findByRole("menuitem", { name: "Unenroll" }))
await screen.findByRole("dialog", {
name: `Unenroll from ${programEnrollment.program.title}`,
})

await user.click(screen.getByRole("button", { name: "Cancel" }))

expect(mockAxiosInstance.request).not.toHaveBeenCalledWith(
expect.objectContaining({ method: "DELETE" }),
)
})

test.each(["enrollment-card-desktop", "enrollment-card-mobile"] as const)(
"Unenroll option is accessible from the %s overflow menu",
async (cardTestId) => {
const { programEnrollment } = setupProgramCard("audit", null)

renderWithProviders(
<DashboardCard
resource={{
type: DashboardType.ProgramEnrollment,
data: programEnrollment,
}}
/>,
)

const card = await screen.findByTestId(cardTestId)
await user.click(within(card).getByLabelText("More options"))

expect(
await screen.findByRole("menuitem", { name: "Unenroll" }),
).toBeInTheDocument()
},
)
})

describe("JustInTimeDialog", () => {
const getFields = (root: HTMLElement) => {
return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { useFormik } from "formik"
import {
useCreateB2bEnrollment,
useDestroyEnrollment,
useDestroyProgramEnrollment,
useUpdateEnrollment,
} from "api/mitxonline-hooks/enrollment"
import {
Expand Down Expand Up @@ -318,4 +319,77 @@ const EmailSettingsDialog = NiceModal.create(EmailSettingsDialogInner)
const UnenrollDialog = NiceModal.create(UnenrollDialogInner)
const JustInTimeDialog = NiceModal.create(JustInTimeDialogInner)

export { EmailSettingsDialog, UnenrollDialog, JustInTimeDialog }
type UnenrollProgramDialogProps = {
title: string
programId: number
}

const UnenrollProgramDialogInner: React.FC<UnenrollProgramDialogProps> = ({
title,
programId,
}) => {
const modal = NiceModal.useModal()
const destroyProgramEnrollment = useDestroyProgramEnrollment()
const formik = useFormik({
enableReinitialize: true,
validateOnChange: false,
validateOnBlur: false,
initialValues: {},
onSubmit: async () => {
await destroyProgramEnrollment.mutateAsync(programId)
modal.hide()
},
})
return (
<FormDialog
title={`Unenroll from ${title}`}
fullWidth
onReset={formik.resetForm}
onSubmit={formik.handleSubmit}
{...muiDialogV5(modal)}
actions={
<DialogActions>
<Button
variant="secondary"
onClick={() => {
modal.hide()
}}
>
Cancel
</Button>
<Button
variant="primary"
type="submit"
disabled={destroyProgramEnrollment.isPending}
endIcon={
destroyProgramEnrollment.isPending ? (
<LoadingSpinner color="inherit" loading={true} size={16} />
) : undefined
}
>
Unenroll
</Button>
</DialogActions>
}
>
<Typography variant="body1">
Are you sure you want to unenroll from {title}?
</Typography>
{destroyProgramEnrollment.isError && (
<Alert severity="error">
There was a problem unenrolling you from this program. Please try
again later.
</Alert>
)}
</FormDialog>
)
}

const UnenrollProgramDialog = NiceModal.create(UnenrollProgramDialogInner)

export {
EmailSettingsDialog,
UnenrollDialog,
UnenrollProgramDialog,
JustInTimeDialog,
}
Loading
Loading