Skip to content

Commit

Permalink
Unify error handling and propose actions to user on error
Browse files Browse the repository at this point in the history
  • Loading branch information
apata committed Aug 22, 2024
1 parent 11acadf commit ef5bee4
Show file tree
Hide file tree
Showing 12 changed files with 313 additions and 96 deletions.
48 changes: 0 additions & 48 deletions assets/js/dashboard.js

This file was deleted.

76 changes: 76 additions & 0 deletions assets/js/dashboard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/** @format */

import React, { ReactNode } from 'react'
import { createRoot } from 'react-dom/client'
import 'url-search-params-polyfill'

import { RouterProvider } from 'react-router-dom'
import { createAppRouter } from './dashboard/router'
import ErrorBoundary from './dashboard/error/error-boundary'
import * as api from './dashboard/api'
import * as timer from './dashboard/util/realtime-update-timer'
import { filtersBackwardsCompatibilityRedirect } from './dashboard/query'
import SiteContextProvider, {
parseSiteFromDataset
} from './dashboard/site-context'
import UserContextProvider, { Role } from './dashboard/user-context'
import ThemeContextProvider from './dashboard/theme-context'
import {
GoBackToDashboard,
GoToSites,
SomethingWentWrongMessage
} from './dashboard/error/something-went-wrong'

timer.start()

const container = document.getElementById('stats-react-container')

if (container && container.dataset) {
let app: ReactNode

try {
const site = parseSiteFromDataset(container.dataset)

const sharedLinkAuth = container.dataset.sharedLinkAuth

if (sharedLinkAuth) {
api.setSharedLinkAuth(sharedLinkAuth)
}

try {
filtersBackwardsCompatibilityRedirect(window.location, window.history)
} catch (e) {
console.error('Error redirecting in a backwards compatible way', e)
}

const router = createAppRouter(site)

app = (
<ErrorBoundary
renderFallbackComponent={({ error }) => (
<SomethingWentWrongMessage
error={error}
callToAction={<GoBackToDashboard site={site} />}
/>
)}
>
<ThemeContextProvider>
<SiteContextProvider site={site}>
<UserContextProvider
role={container.dataset.currentUserRole as Role}
loggedIn={container.dataset.loggedIn === 'true'}
>
<RouterProvider router={router} />
</UserContextProvider>
</SiteContextProvider>
</ThemeContextProvider>
</ErrorBoundary>
)
} catch (err) {
console.error('Error loading dashboard', err)
app = <SomethingWentWrongMessage error={err} callToAction={<GoToSites />} />
}

const root = createRoot(container)
root.render(app)
}
26 changes: 0 additions & 26 deletions assets/js/dashboard/error-boundary.js

This file was deleted.

68 changes: 68 additions & 0 deletions assets/js/dashboard/error/error-boundary.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/** @format */

import React, { useState } from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ErrorBoundary from './error-boundary'

const consoleErrorSpy = jest
.spyOn(global.console, 'error')
.mockImplementation(() => {})

const HappyPathUI = () => {
const [count, setCount] = useState(0)
if (count > 0) {
throw new Error('Anything')
}
return (
<button
data-testid="happy-path-ui"
onClick={() => {
setCount(1)
}}
>
Throw error
</button>
)
}

const ErrorUI = ({ error }: { error?: unknown }) => {
return <div data-testid="error-ui">message: {(error as Error).message}</div>
}

it('shows only on error', async () => {
render(
<ErrorBoundary renderFallbackComponent={ErrorUI}>
<HappyPathUI />
</ErrorBoundary>
)
expect(screen.getByTestId('happy-path-ui')).toBeVisible()
expect(screen.queryByTestId('error-ui')).toBeNull()

await userEvent.click(screen.getByText('Throw error'))

expect(screen.queryByTestId('happy-path-ui')).toBeNull()
expect(screen.getByTestId('error-ui')).toBeVisible()

expect(screen.getByText('message: Anything')).toBeVisible()

expect(consoleErrorSpy.mock.calls).toEqual([
[
expect.objectContaining({
detail: expect.objectContaining({ message: 'Anything' }),
type: 'unhandled exception'
})
],
[
expect.objectContaining({
detail: expect.objectContaining({ message: 'Anything' }),
type: 'unhandled exception'
})
],
[
expect.stringMatching(
'The above error occurred in the <HappyPathUI> component:'
)
]
])
})
31 changes: 31 additions & 0 deletions assets/js/dashboard/error/error-boundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/** @format */

import React, { ReactNode, ReactElement } from 'react'

type ErrorBoundaryProps = {
children: ReactNode
renderFallbackComponent: (props: { error?: unknown }) => ReactElement
}

type ErrorBoundaryState = { error: null | unknown }

export default class ErrorBoundary extends React.Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
constructor(props: ErrorBoundaryProps) {
super(props)
this.state = { error: null }
}

static getDerivedStateFromError(error: unknown) {
return { error }
}

render() {
if (this.state.error) {
return this.props.renderFallbackComponent({ error: this.state.error })
}
return this.props.children
}
}
27 changes: 27 additions & 0 deletions assets/js/dashboard/error/something-went-wrong.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/** @format */

import React from 'react'
import { render, screen } from '@testing-library/react'
import { GoToSites, SomethingWentWrongMessage } from './something-went-wrong'

it('handles unknown error', async () => {
render(<SomethingWentWrongMessage error={1} />)

expect(screen.getByText('Oops! Something went wrong.')).toBeVisible()
expect(screen.getByText('Unknown error')).toBeVisible()
})

it('handles normal error', async () => {
render(<SomethingWentWrongMessage error={new Error('any message')} />)

expect(screen.getByText('Oops! Something went wrong.')).toBeVisible()
expect(screen.getByText('Error: any message')).toBeVisible()
})

it('shows call to action if defined', async () => {
render(<SomethingWentWrongMessage error={1} callToAction={<GoToSites />} />)

expect(screen.getByText('Oops! Something went wrong.')).toBeVisible()
expect(screen.getByText('Try going back or')).toBeVisible()
expect(screen.getByRole('link', { name: 'go to your sites' })).toBeVisible()
})
70 changes: 70 additions & 0 deletions assets/js/dashboard/error/something-went-wrong.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/** @format */

import React, { ReactNode } from 'react'
import RocketIcon from '../stats/modals/rocket-icon'
import { useInRouterContext } from 'react-router-dom'
import { PlausibleSite } from '../site-context'
import { getRouterBasepath, rootRoute } from '../router'
import { AppNavigationLink } from '../navigation/use-app-navigate'

export function SomethingWentWrongMessage({
error,
callToAction = null
}: {
error: unknown
callToAction?: ReactNode
}) {
return (
<div className="text-center text-gray-900 dark:text-gray-100 mt-36">
<RocketIcon />
<div className="text-lg">
<span className="font-bold">Oops! Something went wrong.</span>
{!!callToAction && ' '}
{callToAction}
</div>
<div className="text-md font-mono mt-2">
{error instanceof Error
? [error.name, error.message].join(': ')
: 'Unknown error'}
</div>
</div>
)
}

const linkClass =
'hover:underline text-indigo-600 hover:text-indigo-700 dark:text-indigo-500 dark:hover:text-indigo-600'

export function GoBackToDashboard({
site
}: {
site: Pick<PlausibleSite, 'domain' | 'shared'>
}) {
const canUseAppLink = useInRouterContext()
const linkText = 'go to dashboard'

return (
<span>
<>Try going back or </>
{canUseAppLink ? (
<AppNavigationLink path={rootRoute.path} className={linkClass}>
{linkText}
</AppNavigationLink>
) : (
<a href={getRouterBasepath(site)} className={linkClass}>
{linkText}
</a>
)}
</span>
)
}

export function GoToSites() {
return (
<>
<>Try going back or </>
<a href={'/sites'} className={linkClass}>
{'go to your sites'}
</a>
</>
)
}
Loading

0 comments on commit ef5bee4

Please sign in to comment.