diff --git a/frontends/api/src/mitxonline/hooks/organizations/queries.ts b/frontends/api/src/mitxonline/hooks/organizations/queries.ts index b6173bcdbe..01862ed6c2 100644 --- a/frontends/api/src/mitxonline/hooks/organizations/queries.ts +++ b/frontends/api/src/mitxonline/hooks/organizations/queries.ts @@ -32,7 +32,13 @@ const organizationQueries = { const useB2BAttachMutation = (opts: B2bApiB2bAttachCreateRequest) => { const queryClient = useQueryClient() return useMutation({ - mutationFn: () => b2bApi.b2bAttachCreate(opts), + mutationFn: async () => { + const response = await b2bApi.b2bAttachCreate(opts) + // 200 (already attached) indicates user already attached to all contracts + // 201 (successfully attached) is success + // 404 (invalid or expired code) will be thrown as error by axios + return response + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["mitxonline"] }) }, diff --git a/frontends/main/src/app-pages/DashboardPage/HomeContent.test.tsx b/frontends/main/src/app-pages/DashboardPage/HomeContent.test.tsx index a81e290173..ec9bf9df29 100644 --- a/frontends/main/src/app-pages/DashboardPage/HomeContent.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/HomeContent.test.tsx @@ -18,8 +18,10 @@ import * as mitxonline from "api/mitxonline-test-utils" import { useFeatureFlagEnabled } from "posthog-js/react" import HomeContent from "./HomeContent" import invariant from "tiny-invariant" +import * as NextProgressBar from "next-nprogress-bar" jest.mock("posthog-js/react") +jest.mock("next-nprogress-bar") const mockedUseFeatureFlagEnabled = jest .mocked(useFeatureFlagEnabled) .mockImplementation(() => false) @@ -239,4 +241,66 @@ describe("HomeContent", () => { } }, ) + + test("Does not display enrollment error alert when query param is not present", async () => { + setupAPIs() + renderWithProviders() + + await screen.findByRole("heading", { + name: "Your MIT Learning Journey", + }) + + expect(screen.queryByText("Enrollment Error")).not.toBeInTheDocument() + }) + + test("Displays enrollment error alert when query param is present and then clears it", async () => { + setupAPIs() + const mockReplace = jest.fn() + jest.spyOn(NextProgressBar, "useRouter").mockReturnValue({ + replace: mockReplace, + } as Partial> as ReturnType< + typeof NextProgressBar.useRouter + >) + + renderWithProviders(, { + url: "/dashboard?enrollment_error=1", + }) + + await screen.findByRole("heading", { + name: "Your MIT Learning Journey", + }) + + // Verify the alert was shown + expect(screen.getByText("Enrollment Error")).toBeInTheDocument() + expect( + screen.getByText( + /The Enrollment Code is incorrect or no longer available/, + ), + ).toBeInTheDocument() + expect(screen.getByText("Contact Support")).toBeInTheDocument() + + // Verify the query param is cleared + expect(mockReplace).toHaveBeenCalledWith("/dashboard") + }) + + test("Does not clear query param when it is not present", async () => { + setupAPIs() + const mockReplace = jest.fn() + jest.spyOn(NextProgressBar, "useRouter").mockReturnValue({ + replace: mockReplace, + } as Partial> as ReturnType< + typeof NextProgressBar.useRouter + >) + + renderWithProviders(, { + url: "/dashboard", + }) + + await screen.findByRole("heading", { + name: "Your MIT Learning Journey", + }) + + // Verify router.replace was not called + expect(mockReplace).not.toHaveBeenCalled() + }) }) diff --git a/frontends/main/src/app-pages/DashboardPage/HomeContent.tsx b/frontends/main/src/app-pages/DashboardPage/HomeContent.tsx index 377d1eb7e9..8b4e022d08 100644 --- a/frontends/main/src/app-pages/DashboardPage/HomeContent.tsx +++ b/frontends/main/src/app-pages/DashboardPage/HomeContent.tsx @@ -1,9 +1,9 @@ "use client" import React, { Suspense } from "react" -import { ButtonLink } from "@mitodl/smoot-design" +import { Alert, ButtonLink } from "@mitodl/smoot-design" import { ResourceTypeEnum } from "api" -import { styled, Typography } from "ol-components" -import { PROFILE } from "@/common/urls" +import { Link, styled, Typography } from "ol-components" +import { PROFILE, ENROLLMENT_ERROR_QUERY_PARAM } from "@/common/urls" import { TopPicksCarouselConfig, TopicCarouselConfig, @@ -18,6 +18,8 @@ import { useFeatureFlagEnabled } from "posthog-js/react" import { FeatureFlags } from "@/common/feature_flags" import { useUserMe } from "api/hooks/user" import { OrganizationCards } from "./CoursewareDisplay/OrganizationCards" +import { useSearchParams } from "next/navigation" +import { useRouter } from "next-nprogress-bar" const SubTitleText = styled(Typography)(({ theme }) => ({ color: theme.custom.colors.darkGray2, @@ -66,13 +68,36 @@ const TitleText = styled(Typography)(({ theme }) => ({ }, })) as typeof Typography +const AlertBanner = styled(Alert)({ + marginTop: "32px", +}) + const HomeContent: React.FC = () => { + const searchParams = useSearchParams() + const router = useRouter() + const enrollmentError = searchParams.get(ENROLLMENT_ERROR_QUERY_PARAM) + const [showEnrollmentError, setShowEnrollmentError] = React.useState(false) const { isLoading: isLoadingProfile, data: user } = useUserMe() const topics = user?.profile?.preference_search_filters.topic const certification = user?.profile?.preference_search_filters.certification const showEnrollments = useFeatureFlagEnabled( FeatureFlags.EnrollmentDashboard, ) + const supportEmail = process.env.NEXT_PUBLIC_MITOL_SUPPORT_EMAIL || "" + + // Show error and clear the query param + React.useEffect(() => { + if (enrollmentError) { + setShowEnrollmentError(true) + const newParams = new URLSearchParams(searchParams.toString()) + newParams.delete(ENROLLMENT_ERROR_QUERY_PARAM) + const newUrl = newParams.toString() + ? `${window.location.pathname}?${newParams.toString()}` + : window.location.pathname + router.replace(newUrl) + } + }, [enrollmentError, searchParams, router]) + return ( <> @@ -88,6 +113,21 @@ const HomeContent: React.FC = () => { + {showEnrollmentError && ( + + + Enrollment Error + + + {" - "} + The Enrollment Code is incorrect or no longer available.{" "} + + Contact Support + {" "} + for assistance. + + + )} {showEnrollments ? : null} diff --git a/frontends/main/src/app-pages/EnrollmentCodePage/EnrollmentCodePage.test.tsx b/frontends/main/src/app-pages/EnrollmentCodePage/EnrollmentCodePage.test.tsx index af357581de..df03c49885 100644 --- a/frontends/main/src/app-pages/EnrollmentCodePage/EnrollmentCodePage.test.tsx +++ b/frontends/main/src/app-pages/EnrollmentCodePage/EnrollmentCodePage.test.tsx @@ -1,7 +1,7 @@ import React from "react" import { renderWithProviders, setMockResponse, waitFor } from "@/test-utils" import { makeRequest, urls } from "api/test-utils" -import { urls as b2bUrls } from "api/mitxonline-test-utils" +import { urls as b2bUrls, factories } from "api/mitxonline-test-utils" import * as commonUrls from "@/common/urls" import { Permission } from "api/hooks/user" import EnrollmentCodePage from "./EnrollmentCodePage" @@ -25,6 +25,9 @@ describe("EnrollmentCodePage", () => { [Permission.Authenticated]: false, }) + const mitxUser = factories.user.user() + setMockResponse.get(b2bUrls.userMe.get(), mitxUser) + setMockResponse.post(b2bUrls.b2bAttach.b2bAttachView("test-code"), [], { code: 403, }) @@ -54,6 +57,9 @@ describe("EnrollmentCodePage", () => { [Permission.Authenticated]: true, }) + const mitxUser = factories.user.user() + setMockResponse.get(b2bUrls.userMe.get(), mitxUser) + setMockResponse.post(b2bUrls.b2bAttach.b2bAttachView("test-code"), []) renderWithProviders(, { @@ -61,13 +67,14 @@ describe("EnrollmentCodePage", () => { }) }) - test("Redirects to dashboard on successful attachment", async () => { + test("Redirects to dashboard on successful attachment (201 status)", async () => { setMockResponse.get(urls.userMe.get(), { [Permission.Authenticated]: true, }) const attachUrl = b2bUrls.b2bAttach.b2bAttachView("test-code") - setMockResponse.post(attachUrl, []) + // 201 status indicates successful attachment to new contract(s) + setMockResponse.post(attachUrl, {}, { code: 201 }) renderWithProviders(, { url: commonUrls.B2B_ATTACH_VIEW, @@ -77,6 +84,54 @@ describe("EnrollmentCodePage", () => { expect(makeRequest).toHaveBeenCalledWith("post", attachUrl, undefined) }) - expect(mockPush).toHaveBeenCalledWith(commonUrls.DASHBOARD_HOME) + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith(commonUrls.DASHBOARD_HOME) + }) + }) + + test("Redirects to dashboard when user already attached to all contracts (200 status)", async () => { + setMockResponse.get(urls.userMe.get(), { + [Permission.Authenticated]: true, + }) + + const attachUrl = b2bUrls.b2bAttach.b2bAttachView("already-used-code") + // 200 status indicates user already attached to all contracts - still redirect to dashboard without error + setMockResponse.post(attachUrl, {}, { code: 200 }) + + renderWithProviders(, { + url: commonUrls.B2B_ATTACH_VIEW, + }) + + await waitFor(() => { + expect(makeRequest).toHaveBeenCalledWith("post", attachUrl, undefined) + }) + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith(commonUrls.DASHBOARD_HOME) + }) + }) + + test("Redirects to dashboard with error for invalid code (404 status)", async () => { + setMockResponse.get(urls.userMe.get(), { + [Permission.Authenticated]: true, + }) + + const attachUrl = b2bUrls.b2bAttach.b2bAttachView("invalid-code") + // 404 status indicates invalid or expired enrollment code + setMockResponse.post(attachUrl, {}, { code: 404 }) + + renderWithProviders(, { + url: commonUrls.B2B_ATTACH_VIEW, + }) + + await waitFor(() => { + expect(makeRequest).toHaveBeenCalledWith("post", attachUrl, undefined) + }) + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith( + commonUrls.DASHBOARD_HOME_ENROLLMENT_ERROR, + ) + }) }) }) diff --git a/frontends/main/src/app-pages/EnrollmentCodePage/EnrollmentCodePage.tsx b/frontends/main/src/app-pages/EnrollmentCodePage/EnrollmentCodePage.tsx index ade2e949f4..2d87f9e5c4 100644 --- a/frontends/main/src/app-pages/EnrollmentCodePage/EnrollmentCodePage.tsx +++ b/frontends/main/src/app-pages/EnrollmentCodePage/EnrollmentCodePage.tsx @@ -17,22 +17,38 @@ const InterstitialMessage = styled(Typography)(({ theme }) => ({ })) const EnrollmentCodePage: React.FC = ({ code }) => { + const router = useRouter() + const enrollment = useB2BAttachMutation({ enrollment_code: code, }) - const router = useRouter() const { isLoading: userLoading, data: user } = useQuery({ ...userQueries.me(), staleTime: 0, }) - const enrollAsync = enrollment.mutateAsync React.useEffect(() => { - if (user?.is_authenticated) { - enrollAsync().then(() => router.push(urls.DASHBOARD_HOME)) + if ( + user?.is_authenticated && + !enrollment.isPending && + !enrollment.isSuccess + ) { + enrollment.mutate() + } + }, [user?.is_authenticated, enrollment]) + + // Handle redirect based on response status code + // 201: Successfully attached to new contract(s) -> redirect to dashboard + // 200: Already attached to all contracts -> redirect to dashboard + // 404: Invalid or expired code -> show error + React.useEffect(() => { + if (enrollment.isSuccess) { + router.push(urls.DASHBOARD_HOME) + } else if (enrollment.isError) { + router.push(urls.DASHBOARD_HOME_ENROLLMENT_ERROR) } - }, [user?.is_authenticated, enrollAsync, router]) + }, [enrollment.isSuccess, enrollment.isError, router]) React.useEffect(() => { if (userLoading) { diff --git a/frontends/main/src/common/urls.ts b/frontends/main/src/common/urls.ts index 8843084378..b70edf1181 100644 --- a/frontends/main/src/common/urls.ts +++ b/frontends/main/src/common/urls.ts @@ -60,6 +60,8 @@ export const DASHBOARD_VIEW = "/dashboard/[tab]" const dashboardView = (tab: string) => generatePath(DASHBOARD_VIEW, { tab }) export const DASHBOARD_HOME = "/dashboard" +export const ENROLLMENT_ERROR_QUERY_PARAM = "enrollment_error" +export const DASHBOARD_HOME_ENROLLMENT_ERROR = `/dashboard?${ENROLLMENT_ERROR_QUERY_PARAM}=1` export const MY_LISTS = dashboardView("my-lists") export const PROFILE = dashboardView("profile") export const SETTINGS = dashboardView("settings")