Skip to content

Commit

Permalink
Add error handling (#1613)
Browse files Browse the repository at this point in the history
  • Loading branch information
abeglova authored Sep 30, 2024
1 parent 78d2385 commit 8d2a220
Show file tree
Hide file tree
Showing 17 changed files with 194 additions and 322 deletions.
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import React from "react"
import { waitFor } from "@testing-library/react"
import { renderWithProviders, screen } from "../../test-utils"
import { HOME, login } from "@/common/urls"
import { HOME } from "@/common/urls"
import ForbiddenPage from "./ForbiddenPage"
import { setMockResponse, urls } from "api/test-utils"
import { Permissions } from "@/common/permissions"
Expand All @@ -25,19 +24,6 @@ afterAll(() => {
window.location = oldWindowLocation
})

test("The ForbiddenPage loads with meta", async () => {
setMockResponse.get(urls.userMe.get(), {
[Permissions.Authenticated]: true,
})
renderWithProviders(<ForbiddenPage />)
await waitFor(() => {
expect(document.title).toBe("Not Allowed | MIT Learn")
})

const meta = document.head.querySelector('meta[name="robots"]')
expect(meta).toHaveProperty("content", "noindex,noarchive")
})

test("The ForbiddenPage loads with Correct Title", () => {
setMockResponse.get(urls.userMe.get(), {
[Permissions.Authenticated]: true,
Expand All @@ -54,17 +40,3 @@ test("The ForbiddenPage loads with a link that directs to HomePage", () => {
const homeLink = screen.getByRole("link", { name: "Home" })
expect(homeLink).toHaveAttribute("href", HOME)
})

test("Redirects unauthenticated users to login", async () => {
setMockResponse.get(urls.userMe.get(), {
[Permissions.Authenticated]: false,
})
renderWithProviders(<ForbiddenPage />, { url: "/some/url?foo=bar#baz" })

const expectedUrl = login({
pathname: "/some/url",
search: "?foo=bar",
hash: "#baz",
})
expect(window.location.assign).toHaveBeenCalledWith(expectedUrl)
})
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,18 @@ import React, { useEffect } from "react"
import ErrorPageTemplate from "./ErrorPageTemplate"
import { useUserMe } from "api/hooks/user"
import { Typography } from "ol-components"
import { login } from "@/common/urls"
import { useLocation } from "react-router"
import { redirect } from "next/navigation"
import * as urls from "@/common/urls"

const ForbiddenPage: React.FC = () => {
const location = useLocation()
const { data: user } = useUserMe()

useEffect(() => {
if (!user?.is_authenticated) {
window.location.assign(login(location))
const loginUrl = urls.login()
redirect(loginUrl)
}
})
}, [user])
return (
<ErrorPageTemplate title="Not Allowed">
<Typography variant="h3" component="h1">
Expand Down
9 changes: 0 additions & 9 deletions frontends/main/src/app-pages/ErrorPage/NotFoundPage.test.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,8 @@
import React from "react"
import { waitFor } from "@testing-library/react"
import { renderWithProviders, screen } from "@/test-utils"
import { HOME } from "@/common/urls"
import NotFoundPage from "./NotFoundPage"

test.skip("The NotFoundPage loads with meta", async () => {
renderWithProviders(<NotFoundPage />, {})
await waitFor(() => {
const meta = document.head.querySelector('meta[name="robots"]')
expect(meta).toHaveProperty("content", "noindex,noarchive")
})
})

test("The NotFoundPage loads with Correct Title", () => {
renderWithProviders(<NotFoundPage />, {})
screen.getByRole("heading", { name: "404 Not Found Error" })
Expand Down
8 changes: 7 additions & 1 deletion frontends/main/src/app/dashboard/[tab]/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,20 @@ import DashboardPage from "@/app-pages/DashboardPage/DashboardPage"

import { Metadata } from "next"
import { standardizeMetadata } from "@/common/metadata"
import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute"
import { Permissions } from "@/common/permissions"

export const metadata: Metadata = standardizeMetadata({
title: "Your MIT Learning Journey",
social: false,
})

const Page: React.FC = () => {
return <DashboardPage />
return (
<RestrictedRoute requires={Permissions.Authenticated}>
<DashboardPage />
</RestrictedRoute>
)
}

export default Page
8 changes: 7 additions & 1 deletion frontends/main/src/app/dashboard/[tab]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,19 @@ import DashboardPage from "@/app-pages/DashboardPage/DashboardPage"

import { Metadata } from "next"
import { standardizeMetadata } from "@/common/metadata"
import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute"
import { Permissions } from "@/common/permissions"

export const metadata: Metadata = standardizeMetadata({
title: "Your MIT Learning Journey",
social: false,
})
const Page: React.FC = () => {
return <DashboardPage />
return (
<RestrictedRoute requires={Permissions.Authenticated}>
<DashboardPage />
</RestrictedRoute>
)
}

export default Page
9 changes: 8 additions & 1 deletion frontends/main/src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,20 @@ import React from "react"
import { Metadata } from "next"
import DashboardPage from "@/app-pages/DashboardPage/DashboardPage"
import { standardizeMetadata } from "@/common/metadata"
import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute"
import { Permissions } from "@/common/permissions"

export const metadata: Metadata = standardizeMetadata({
title: "Your MIT Learning Journey",
social: false,
})

const Page: React.FC = () => {
return <DashboardPage />
return (
<RestrictedRoute requires={Permissions.Authenticated}>
<DashboardPage />
</RestrictedRoute>
)
}

export default Page
9 changes: 9 additions & 0 deletions frontends/main/src/app/getQueryClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,22 @@ type MaybeHasStatus = {

const RETRY_STATUS_CODES = [408, 429, 502, 503, 504]
const MAX_RETRIES = 3
const THROW_ERROR_CODES: (number | undefined)[] = [404, 403, 401]

const makeQueryClient = (): QueryClient => {
return new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
staleTime: Infinity,
// Throw runtime errors instead of marking query as errored.
// The runtime error will be caught by an error boundary.
// For now, only do this for 404s, 403s, and 401s. Other errors should
// be handled locally by components.
useErrorBoundary: (error) => {
const status = (error as MaybeHasStatus)?.response?.status
return THROW_ERROR_CODES.includes(status)
},
retry: (failureCount, error) => {
const status = (error as MaybeHasStatus)?.response?.status
/**
Expand Down
5 changes: 4 additions & 1 deletion frontends/main/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { PageWrapper, PageWrapperInner } from "./styled"
import Providers from "./providers"
import { MITLearnGlobalStyles } from "ol-components"
import Script from "next/script"
import ErrorBoundary from "@/components/ErrorBoundary/ErrorBoundary"

import "./GlobalStyles"

Expand All @@ -22,7 +23,9 @@ export default function RootLayout({
<MITLearnGlobalStyles />
<PageWrapper>
<Header />
<PageWrapperInner>{children}</PageWrapperInner>
<PageWrapperInner>
<ErrorBoundary>{children}</ErrorBoundary>
</PageWrapperInner>
<Footer />
</PageWrapper>
</Providers>
Expand Down
8 changes: 7 additions & 1 deletion frontends/main/src/app/learningpaths/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import React from "react"
import LearningPathDetailsPage from "@/app-pages/LearningPathDetailsPage/LearningPathDetailsPage"
import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute"
import { Permissions } from "@/common/permissions"

const Page: React.FC = () => {
return <LearningPathDetailsPage />
return (
<RestrictedRoute requires={Permissions.LearningPathEditor}>
<LearningPathDetailsPage />
</RestrictedRoute>
)
}

export default Page
9 changes: 8 additions & 1 deletion frontends/main/src/app/learningpaths/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,19 @@ import LearningPathListingPage from "@/app-pages/LearningPathListingPage/Learnin

import { Metadata } from "next"
import { standardizeMetadata } from "@/common/metadata"
import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute"
import { Permissions } from "@/common/permissions"

export const metadata: Metadata = standardizeMetadata({
title: "Learning Paths",
})

const Page: React.FC = () => {
return <LearningPathListingPage />
return (
<RestrictedRoute requires={Permissions.LearningPathEditor}>
<LearningPathListingPage />
</RestrictedRoute>
)
}

export default Page
8 changes: 7 additions & 1 deletion frontends/main/src/app/onboarding/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,20 @@ import React from "react"
import { Metadata } from "next"
import OnboardingPage from "@/app-pages/OnboardingPage/OnboardingPage"
import { standardizeMetadata } from "@/common/metadata"
import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute"
import { Permissions } from "@/common/permissions"

export const metadata: Metadata = standardizeMetadata({
title: "Onboarding",
social: false,
})

const Page: React.FC = () => {
return <OnboardingPage />
return (
<RestrictedRoute requires={Permissions.Authenticated}>
<OnboardingPage />
</RestrictedRoute>
)
}

export default Page
81 changes: 81 additions & 0 deletions frontends/main/src/components/ErrorBoundary/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"use client"

import React, { Component } from "react"
import NotFoundPage from "@/app-pages/ErrorPage/NotFoundPage"
import ForbiddenPage from "@/app-pages/ErrorPage/ForbiddenPage"
import { ForbiddenError } from "@/common/permissions"
import { usePathname } from "next/navigation"

interface ErrorBoundaryProps {
children: React.ReactNode
}

interface ErrorBoundaryHandlerProps extends ErrorBoundaryProps {
pathname: string
}

interface ErrorBoundaryHandlerState {
hasError: boolean
error: unknown
previousPathname: string
}
const isForbiddenError = (error: unknown) => error instanceof ForbiddenError

class ErrorBoundaryHandler extends Component<
ErrorBoundaryHandlerProps,
ErrorBoundaryHandlerState
> {
constructor(props: ErrorBoundaryHandlerProps) {
super(props)
this.state = {
hasError: false,
error: null,
previousPathname: this.props.pathname,
}
}

static getDerivedStateFromError(error: unknown) {
return { hasError: true, error: error }
}

static getDerivedStateFromProps(
props: ErrorBoundaryHandlerProps,
state: ErrorBoundaryHandlerState,
): ErrorBoundaryHandlerState | null {
if (props.pathname !== state.previousPathname && state.error) {
return {
error: null,
hasError: false,
previousPathname: props.pathname,
}
}
return {
error: state.error,
hasError: state.hasError,
previousPathname: props.pathname,
}
}

render() {
if (this.state.hasError) {
if (isForbiddenError(this.state.error)) {
return <ForbiddenPage />
} else {
return <NotFoundPage />
}
}

return this.props.children
}
}

export function ErrorBoundary({
children,
}: ErrorBoundaryProps & { children: React.ReactNode }): JSX.Element {
const pathname = usePathname()
return (
<ErrorBoundaryHandler pathname={pathname}>{children}</ErrorBoundaryHandler>
)
}

export default ErrorBoundary
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import React from "react"
import { renderWithProviders, screen } from "../../test-utils"
import RestrictedRoute from "./RestrictedRoute"
import { Permissions } from "@/common/permissions"
import { allowConsoleErrors } from "ol-test-utilities"

test("Renders children if permission check satisfied", () => {
const errors: unknown[] = []

renderWithProviders(
<RestrictedRoute requires={Permissions.Authenticated}>
Hello, world!
</RestrictedRoute>,

{
user: { [Permissions.Authenticated]: true },
},
)

screen.getByText("Hello, world!")
expect(!errors.length).toBe(true)
})

test.each(Object.values(Permissions))(
"Throws error if and only if lacking required permission",
async (permission) => {
// if a user is not authenticated they are redirected to login before an error is thrown
if (permission === Permissions.Authenticated) {
return
}
allowConsoleErrors()

expect(() =>
renderWithProviders(
<RestrictedRoute requires={permission}>Hello, world!</RestrictedRoute>,
{
user: { [permission]: false },
},
),
).toThrow("Not allowed.")
},
)
Loading

0 comments on commit 8d2a220

Please sign in to comment.