Skip to content
Merged
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
8 changes: 7 additions & 1 deletion frontends/api/src/mitxonline/hooks/organizations/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"] })
},
Expand Down
64 changes: 64 additions & 0 deletions frontends/main/src/app-pages/DashboardPage/HomeContent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -239,4 +241,66 @@ describe("HomeContent", () => {
}
},
)

test("Does not display enrollment error alert when query param is not present", async () => {
setupAPIs()
renderWithProviders(<HomeContent />)

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<ReturnType<typeof NextProgressBar.useRouter>> as ReturnType<
typeof NextProgressBar.useRouter
>)

renderWithProviders(<HomeContent />, {
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<ReturnType<typeof NextProgressBar.useRouter>> as ReturnType<
typeof NextProgressBar.useRouter
>)

renderWithProviders(<HomeContent />, {
url: "/dashboard",
})

await screen.findByRole("heading", {
name: "Your MIT Learning Journey",
})

// Verify router.replace was not called
expect(mockReplace).not.toHaveBeenCalled()
})
})
46 changes: 43 additions & 3 deletions frontends/main/src/app-pages/DashboardPage/HomeContent.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think a query param is a reasonable way to do this. (If we had a global state store, that would be another option.)

But this behavior:

  1. Page redirects with error
  2. User seees error
  3. user hits reload (or user navigates then presses "back")

seems odd to me. I'm inclined to clear the error on page load. What do you think?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I led you somewhat astray here. As implemented, the URL param gets cleared on load, but...so does the error.

One idea is

const enrollmentError = useRef(searchParams.get(ENROLLMENT_ERROR_QUERY_PARAM))
// then
  React.useEffect(() => {
    if (searchParams.has(ENROLLMENT_ERROR_QUERY_PARAM)) {
      // simpler & safe IMO to read window.location
      // effects only run on the client, so it i will exist, and we don't need to re-run the effect
      // when window.location changes, so safe to access it
      const url = new URL(window.location.href)
      url.searchParams.delete(ENROLLMENT_ERROR_QUERY_PARAM)
      router.replace(url.toString())
    }
  }, [searchParams, router])
// then
enrollmentError.current ? <Alert /> : null

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 (
<>
<HomeHeader>
Expand All @@ -88,6 +113,21 @@ const HomeContent: React.FC = () => {
</ButtonLink>
</HomeHeaderRight>
</HomeHeader>
{showEnrollmentError && (
<AlertBanner severity="error" closable={true}>
<Typography variant="subtitle2" component="span">
Enrollment Error
</Typography>
<Typography variant="body2" component="span">
{" - "}
The Enrollment Code is incorrect or no longer available.{" "}
<Link color="red" href={`mailto:${supportEmail}`}>
Contact Support
</Link>{" "}
for assistance.
</Typography>
</AlertBanner>
)}
<OrganizationCards />
{showEnrollments ? <EnrollmentDisplay /> : null}
<Suspense>
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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,
})
Expand Down Expand Up @@ -54,20 +57,24 @@ describe("EnrollmentCodePage", () => {
[Permission.Authenticated]: true,
})

const mitxUser = factories.user.user()
setMockResponse.get(b2bUrls.userMe.get(), mitxUser)

setMockResponse.post(b2bUrls.b2bAttach.b2bAttachView("test-code"), [])

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

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(<EnrollmentCodePage code="test-code" />, {
url: commonUrls.B2B_ATTACH_VIEW,
Expand All @@ -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(<EnrollmentCodePage code="already-used-code" />, {
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(<EnrollmentCodePage code="invalid-code" />, {
url: commonUrls.B2B_ATTACH_VIEW,
})

await waitFor(() => {
expect(makeRequest).toHaveBeenCalledWith("post", attachUrl, undefined)
})

await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith(
commonUrls.DASHBOARD_HOME_ENROLLMENT_ERROR,
)
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,38 @@ const InterstitialMessage = styled(Typography)(({ theme }) => ({
}))

const EnrollmentCodePage: React.FC<EnrollmentCodePage> = ({ 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) {
Expand Down
2 changes: 2 additions & 0 deletions frontends/main/src/common/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading