Skip to content

Commit cccd229

Browse files
authored
add error message for enrollment code issues (#2685)
* if the user's b2b orgs haven't changed after redeeming an enrollment code, redirect to the dashboard with an error flag in the query string params * if enrollment code redemption fails, display an error * adjust the logic to use response codes added to the mitxonline attach endpoint * clear the error message query param after the first time it's seen * prevent immediate hiding of the error when the query string is updated * remove unnecessary state variable
1 parent 45f4725 commit cccd229

File tree

6 files changed

+196
-13
lines changed

6 files changed

+196
-13
lines changed

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,13 @@ const organizationQueries = {
3232
const useB2BAttachMutation = (opts: B2bApiB2bAttachCreateRequest) => {
3333
const queryClient = useQueryClient()
3434
return useMutation({
35-
mutationFn: () => b2bApi.b2bAttachCreate(opts),
35+
mutationFn: async () => {
36+
const response = await b2bApi.b2bAttachCreate(opts)
37+
// 200 (already attached) indicates user already attached to all contracts
38+
// 201 (successfully attached) is success
39+
// 404 (invalid or expired code) will be thrown as error by axios
40+
return response
41+
},
3642
onSuccess: () => {
3743
queryClient.invalidateQueries({ queryKey: ["mitxonline"] })
3844
},

frontends/main/src/app-pages/DashboardPage/HomeContent.test.tsx

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@ import * as mitxonline from "api/mitxonline-test-utils"
1818
import { useFeatureFlagEnabled } from "posthog-js/react"
1919
import HomeContent from "./HomeContent"
2020
import invariant from "tiny-invariant"
21+
import * as NextProgressBar from "next-nprogress-bar"
2122

2223
jest.mock("posthog-js/react")
24+
jest.mock("next-nprogress-bar")
2325
const mockedUseFeatureFlagEnabled = jest
2426
.mocked(useFeatureFlagEnabled)
2527
.mockImplementation(() => false)
@@ -239,4 +241,66 @@ describe("HomeContent", () => {
239241
}
240242
},
241243
)
244+
245+
test("Does not display enrollment error alert when query param is not present", async () => {
246+
setupAPIs()
247+
renderWithProviders(<HomeContent />)
248+
249+
await screen.findByRole("heading", {
250+
name: "Your MIT Learning Journey",
251+
})
252+
253+
expect(screen.queryByText("Enrollment Error")).not.toBeInTheDocument()
254+
})
255+
256+
test("Displays enrollment error alert when query param is present and then clears it", async () => {
257+
setupAPIs()
258+
const mockReplace = jest.fn()
259+
jest.spyOn(NextProgressBar, "useRouter").mockReturnValue({
260+
replace: mockReplace,
261+
} as Partial<ReturnType<typeof NextProgressBar.useRouter>> as ReturnType<
262+
typeof NextProgressBar.useRouter
263+
>)
264+
265+
renderWithProviders(<HomeContent />, {
266+
url: "/dashboard?enrollment_error=1",
267+
})
268+
269+
await screen.findByRole("heading", {
270+
name: "Your MIT Learning Journey",
271+
})
272+
273+
// Verify the alert was shown
274+
expect(screen.getByText("Enrollment Error")).toBeInTheDocument()
275+
expect(
276+
screen.getByText(
277+
/The Enrollment Code is incorrect or no longer available/,
278+
),
279+
).toBeInTheDocument()
280+
expect(screen.getByText("Contact Support")).toBeInTheDocument()
281+
282+
// Verify the query param is cleared
283+
expect(mockReplace).toHaveBeenCalledWith("/dashboard")
284+
})
285+
286+
test("Does not clear query param when it is not present", async () => {
287+
setupAPIs()
288+
const mockReplace = jest.fn()
289+
jest.spyOn(NextProgressBar, "useRouter").mockReturnValue({
290+
replace: mockReplace,
291+
} as Partial<ReturnType<typeof NextProgressBar.useRouter>> as ReturnType<
292+
typeof NextProgressBar.useRouter
293+
>)
294+
295+
renderWithProviders(<HomeContent />, {
296+
url: "/dashboard",
297+
})
298+
299+
await screen.findByRole("heading", {
300+
name: "Your MIT Learning Journey",
301+
})
302+
303+
// Verify router.replace was not called
304+
expect(mockReplace).not.toHaveBeenCalled()
305+
})
242306
})

frontends/main/src/app-pages/DashboardPage/HomeContent.tsx

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
"use client"
22
import React, { Suspense } from "react"
3-
import { ButtonLink } from "@mitodl/smoot-design"
3+
import { Alert, ButtonLink } from "@mitodl/smoot-design"
44
import { ResourceTypeEnum } from "api"
5-
import { styled, Typography } from "ol-components"
6-
import { PROFILE } from "@/common/urls"
5+
import { Link, styled, Typography } from "ol-components"
6+
import { PROFILE, ENROLLMENT_ERROR_QUERY_PARAM } from "@/common/urls"
77
import {
88
TopPicksCarouselConfig,
99
TopicCarouselConfig,
@@ -18,6 +18,8 @@ import { useFeatureFlagEnabled } from "posthog-js/react"
1818
import { FeatureFlags } from "@/common/feature_flags"
1919
import { useUserMe } from "api/hooks/user"
2020
import { OrganizationCards } from "./CoursewareDisplay/OrganizationCards"
21+
import { useSearchParams } from "next/navigation"
22+
import { useRouter } from "next-nprogress-bar"
2123

2224
const SubTitleText = styled(Typography)(({ theme }) => ({
2325
color: theme.custom.colors.darkGray2,
@@ -66,13 +68,36 @@ const TitleText = styled(Typography)(({ theme }) => ({
6668
},
6769
})) as typeof Typography
6870

71+
const AlertBanner = styled(Alert)({
72+
marginTop: "32px",
73+
})
74+
6975
const HomeContent: React.FC = () => {
76+
const searchParams = useSearchParams()
77+
const router = useRouter()
78+
const enrollmentError = searchParams.get(ENROLLMENT_ERROR_QUERY_PARAM)
79+
const [showEnrollmentError, setShowEnrollmentError] = React.useState(false)
7080
const { isLoading: isLoadingProfile, data: user } = useUserMe()
7181
const topics = user?.profile?.preference_search_filters.topic
7282
const certification = user?.profile?.preference_search_filters.certification
7383
const showEnrollments = useFeatureFlagEnabled(
7484
FeatureFlags.EnrollmentDashboard,
7585
)
86+
const supportEmail = process.env.NEXT_PUBLIC_MITOL_SUPPORT_EMAIL || ""
87+
88+
// Show error and clear the query param
89+
React.useEffect(() => {
90+
if (enrollmentError) {
91+
setShowEnrollmentError(true)
92+
const newParams = new URLSearchParams(searchParams.toString())
93+
newParams.delete(ENROLLMENT_ERROR_QUERY_PARAM)
94+
const newUrl = newParams.toString()
95+
? `${window.location.pathname}?${newParams.toString()}`
96+
: window.location.pathname
97+
router.replace(newUrl)
98+
}
99+
}, [enrollmentError, searchParams, router])
100+
76101
return (
77102
<>
78103
<HomeHeader>
@@ -88,6 +113,21 @@ const HomeContent: React.FC = () => {
88113
</ButtonLink>
89114
</HomeHeaderRight>
90115
</HomeHeader>
116+
{showEnrollmentError && (
117+
<AlertBanner severity="error" closable={true}>
118+
<Typography variant="subtitle2" component="span">
119+
Enrollment Error
120+
</Typography>
121+
<Typography variant="body2" component="span">
122+
{" - "}
123+
The Enrollment Code is incorrect or no longer available.{" "}
124+
<Link color="red" href={`mailto:${supportEmail}`}>
125+
Contact Support
126+
</Link>{" "}
127+
for assistance.
128+
</Typography>
129+
</AlertBanner>
130+
)}
91131
<OrganizationCards />
92132
{showEnrollments ? <EnrollmentDisplay /> : null}
93133
<Suspense>

frontends/main/src/app-pages/EnrollmentCodePage/EnrollmentCodePage.test.tsx

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React from "react"
22
import { renderWithProviders, setMockResponse, waitFor } from "@/test-utils"
33
import { makeRequest, urls } from "api/test-utils"
4-
import { urls as b2bUrls } from "api/mitxonline-test-utils"
4+
import { urls as b2bUrls, factories } from "api/mitxonline-test-utils"
55
import * as commonUrls from "@/common/urls"
66
import { Permission } from "api/hooks/user"
77
import EnrollmentCodePage from "./EnrollmentCodePage"
@@ -25,6 +25,9 @@ describe("EnrollmentCodePage", () => {
2525
[Permission.Authenticated]: false,
2626
})
2727

28+
const mitxUser = factories.user.user()
29+
setMockResponse.get(b2bUrls.userMe.get(), mitxUser)
30+
2831
setMockResponse.post(b2bUrls.b2bAttach.b2bAttachView("test-code"), [], {
2932
code: 403,
3033
})
@@ -54,20 +57,24 @@ describe("EnrollmentCodePage", () => {
5457
[Permission.Authenticated]: true,
5558
})
5659

60+
const mitxUser = factories.user.user()
61+
setMockResponse.get(b2bUrls.userMe.get(), mitxUser)
62+
5763
setMockResponse.post(b2bUrls.b2bAttach.b2bAttachView("test-code"), [])
5864

5965
renderWithProviders(<EnrollmentCodePage code="test-code" />, {
6066
url: commonUrls.B2B_ATTACH_VIEW,
6167
})
6268
})
6369

64-
test("Redirects to dashboard on successful attachment", async () => {
70+
test("Redirects to dashboard on successful attachment (201 status)", async () => {
6571
setMockResponse.get(urls.userMe.get(), {
6672
[Permission.Authenticated]: true,
6773
})
6874

6975
const attachUrl = b2bUrls.b2bAttach.b2bAttachView("test-code")
70-
setMockResponse.post(attachUrl, [])
76+
// 201 status indicates successful attachment to new contract(s)
77+
setMockResponse.post(attachUrl, {}, { code: 201 })
7178

7279
renderWithProviders(<EnrollmentCodePage code="test-code" />, {
7380
url: commonUrls.B2B_ATTACH_VIEW,
@@ -77,6 +84,54 @@ describe("EnrollmentCodePage", () => {
7784
expect(makeRequest).toHaveBeenCalledWith("post", attachUrl, undefined)
7885
})
7986

80-
expect(mockPush).toHaveBeenCalledWith(commonUrls.DASHBOARD_HOME)
87+
await waitFor(() => {
88+
expect(mockPush).toHaveBeenCalledWith(commonUrls.DASHBOARD_HOME)
89+
})
90+
})
91+
92+
test("Redirects to dashboard when user already attached to all contracts (200 status)", async () => {
93+
setMockResponse.get(urls.userMe.get(), {
94+
[Permission.Authenticated]: true,
95+
})
96+
97+
const attachUrl = b2bUrls.b2bAttach.b2bAttachView("already-used-code")
98+
// 200 status indicates user already attached to all contracts - still redirect to dashboard without error
99+
setMockResponse.post(attachUrl, {}, { code: 200 })
100+
101+
renderWithProviders(<EnrollmentCodePage code="already-used-code" />, {
102+
url: commonUrls.B2B_ATTACH_VIEW,
103+
})
104+
105+
await waitFor(() => {
106+
expect(makeRequest).toHaveBeenCalledWith("post", attachUrl, undefined)
107+
})
108+
109+
await waitFor(() => {
110+
expect(mockPush).toHaveBeenCalledWith(commonUrls.DASHBOARD_HOME)
111+
})
112+
})
113+
114+
test("Redirects to dashboard with error for invalid code (404 status)", async () => {
115+
setMockResponse.get(urls.userMe.get(), {
116+
[Permission.Authenticated]: true,
117+
})
118+
119+
const attachUrl = b2bUrls.b2bAttach.b2bAttachView("invalid-code")
120+
// 404 status indicates invalid or expired enrollment code
121+
setMockResponse.post(attachUrl, {}, { code: 404 })
122+
123+
renderWithProviders(<EnrollmentCodePage code="invalid-code" />, {
124+
url: commonUrls.B2B_ATTACH_VIEW,
125+
})
126+
127+
await waitFor(() => {
128+
expect(makeRequest).toHaveBeenCalledWith("post", attachUrl, undefined)
129+
})
130+
131+
await waitFor(() => {
132+
expect(mockPush).toHaveBeenCalledWith(
133+
commonUrls.DASHBOARD_HOME_ENROLLMENT_ERROR,
134+
)
135+
})
81136
})
82137
})

frontends/main/src/app-pages/EnrollmentCodePage/EnrollmentCodePage.tsx

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,22 +17,38 @@ const InterstitialMessage = styled(Typography)(({ theme }) => ({
1717
}))
1818

1919
const EnrollmentCodePage: React.FC<EnrollmentCodePage> = ({ code }) => {
20+
const router = useRouter()
21+
2022
const enrollment = useB2BAttachMutation({
2123
enrollment_code: code,
2224
})
23-
const router = useRouter()
2425

2526
const { isLoading: userLoading, data: user } = useQuery({
2627
...userQueries.me(),
2728
staleTime: 0,
2829
})
2930

30-
const enrollAsync = enrollment.mutateAsync
3131
React.useEffect(() => {
32-
if (user?.is_authenticated) {
33-
enrollAsync().then(() => router.push(urls.DASHBOARD_HOME))
32+
if (
33+
user?.is_authenticated &&
34+
!enrollment.isPending &&
35+
!enrollment.isSuccess
36+
) {
37+
enrollment.mutate()
38+
}
39+
}, [user?.is_authenticated, enrollment])
40+
41+
// Handle redirect based on response status code
42+
// 201: Successfully attached to new contract(s) -> redirect to dashboard
43+
// 200: Already attached to all contracts -> redirect to dashboard
44+
// 404: Invalid or expired code -> show error
45+
React.useEffect(() => {
46+
if (enrollment.isSuccess) {
47+
router.push(urls.DASHBOARD_HOME)
48+
} else if (enrollment.isError) {
49+
router.push(urls.DASHBOARD_HOME_ENROLLMENT_ERROR)
3450
}
35-
}, [user?.is_authenticated, enrollAsync, router])
51+
}, [enrollment.isSuccess, enrollment.isError, router])
3652

3753
React.useEffect(() => {
3854
if (userLoading) {

frontends/main/src/common/urls.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ export const DASHBOARD_VIEW = "/dashboard/[tab]"
6060
const dashboardView = (tab: string) => generatePath(DASHBOARD_VIEW, { tab })
6161

6262
export const DASHBOARD_HOME = "/dashboard"
63+
export const ENROLLMENT_ERROR_QUERY_PARAM = "enrollment_error"
64+
export const DASHBOARD_HOME_ENROLLMENT_ERROR = `/dashboard?${ENROLLMENT_ERROR_QUERY_PARAM}=1`
6365
export const MY_LISTS = dashboardView("my-lists")
6466
export const PROFILE = dashboardView("profile")
6567
export const SETTINGS = dashboardView("settings")

0 commit comments

Comments
 (0)