-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Unify error handling and propose actions to user on error
- Loading branch information
Showing
12 changed files
with
313 additions
and
96 deletions.
There are no files selected for viewing
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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:' | ||
) | ||
] | ||
]) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
</> | ||
) | ||
} |
Oops, something went wrong.