Unify error handling and propose actions to user on error

This commit is contained in:
Artur Pata 2024-08-21 15:40:12 +03:00
parent 11acadfde9
commit ef5bee43a1
12 changed files with 313 additions and 96 deletions

View File

@ -1,48 +0,0 @@
import React 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-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 from './dashboard/user-context'
import ThemeContextProvider from './dashboard/theme-context'
timer.start()
const container = document.getElementById('stats-react-container')
if (container) {
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);
const app = (
<ErrorBoundary>
<ThemeContextProvider>
<SiteContextProvider site={site}>
<UserContextProvider role={container.dataset.currentUserRole} loggedIn={container.dataset.loggedIn === 'true'}>
<RouterProvider router={router} />
</UserContextProvider>
</SiteContextProvider>
</ThemeContextProvider>
</ErrorBoundary>
)
const root = createRoot(container)
root.render(app)
}

76
assets/js/dashboard.tsx Normal file
View File

@ -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)
}

View File

@ -1,26 +0,0 @@
import React from 'react';
import RocketIcon from './stats/modals/rocket-icon'
export default class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = {error: null}
}
static getDerivedStateFromError(error) {
return {error: error}
}
render() {
if (this.state.error) {
return (
<div className="text-center text-gray-900 dark:text-gray-100 mt-36">
<RocketIcon />
<div className="text-lg font-bold">Oops! Something went wrong</div>
<div className="text-lg">{this.state.error.name + ': ' + this.state.error.message}</div>
</div>
)
}
return this.props.children;
}
}

View File

@ -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:'
)
]
])
})

View File

@ -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
}
}

View File

@ -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()
})

View File

@ -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>
</>
)
}

View File

@ -1,7 +1,13 @@
/* @format */ /** @format */
import React from 'react' import React from 'react'
import { createBrowserRouter, Outlet } from 'react-router-dom' import { createBrowserRouter, Outlet, useRouteError } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { PlausibleSite, useSiteContext } from './site-context'
import {
GoBackToDashboard,
SomethingWentWrongMessage
} from './error/something-went-wrong'
import Dashboard from './index' import Dashboard from './index'
import SourcesModal from './stats/modals/sources' import SourcesModal from './stats/modals/sources'
import ReferrersDrilldownModal from './stats/modals/referrer-drilldown' import ReferrersDrilldownModal from './stats/modals/referrer-drilldown'
@ -19,7 +25,6 @@ import PropsModal from './stats/modals/props'
import ConversionsModal from './stats/modals/conversions' import ConversionsModal from './stats/modals/conversions'
import FilterModal from './stats/modals/filter-modal' import FilterModal from './stats/modals/filter-modal'
import QueryContextProvider from './query-context' import QueryContextProvider from './query-context'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@ -156,19 +161,33 @@ export const filterRoute = {
element: <FilterModal /> element: <FilterModal />
} }
export function getRouterBasepath(site) { export function getRouterBasepath(
site: Pick<PlausibleSite, 'shared' | 'domain'>
): string {
const basepath = site.shared const basepath = site.shared
? `/share/${encodeURIComponent(site.domain)}` ? `/share/${encodeURIComponent(site.domain)}`
: `/${encodeURIComponent(site.domain)}` : `/${encodeURIComponent(site.domain)}`
return basepath return basepath
} }
export function createAppRouter(site) { function RouteErrorElement() {
const site = useSiteContext()
const error = useRouteError()
return (
<SomethingWentWrongMessage
error={error}
callToAction={<GoBackToDashboard site={site} />}
/>
)
}
export function createAppRouter(site: PlausibleSite) {
const basepath = getRouterBasepath(site) const basepath = getRouterBasepath(site)
const router = createBrowserRouter( const router = createBrowserRouter(
[ [
{ {
...rootRoute, ...rootRoute,
errorElement: <RouteErrorElement />,
children: [ children: [
sourcesRoute, sourcesRoute,
utmMediumsRoute, utmMediumsRoute,
@ -199,6 +218,7 @@ export function createAppRouter(site) {
{ {
basename: basepath, basename: basepath,
future: { future: {
// @ts-expect-error valid according to docs (https://reactrouter.com/en/main/routers/create-browser-router#optsfuture)
v7_prependBasename: true v7_prependBasename: true
} }
} }

View File

@ -1,10 +1,10 @@
/* @format */ /** @format */
import React, { createContext, ReactNode, useContext } from 'react' import React, { createContext, ReactNode, useContext } from 'react'
export function parseSiteFromDataset(dataset: Record<string, string>) { export function parseSiteFromDataset(dataset: DOMStringMap): PlausibleSite {
const site = { return {
domain: dataset.domain, domain: dataset.domain!,
offset: dataset.offset, offset: dataset.offset!,
hasGoals: dataset.hasGoals === 'true', hasGoals: dataset.hasGoals === 'true',
hasProps: dataset.hasProps === 'true', hasProps: dataset.hasProps === 'true',
funnelsAvailable: dataset.funnelsAvailable === 'true', funnelsAvailable: dataset.funnelsAvailable === 'true',
@ -12,18 +12,17 @@ export function parseSiteFromDataset(dataset: Record<string, string>) {
conversionsOptedOut: dataset.conversionsOptedOut === 'true', conversionsOptedOut: dataset.conversionsOptedOut === 'true',
funnelsOptedOut: dataset.funnelsOptedOut === 'true', funnelsOptedOut: dataset.funnelsOptedOut === 'true',
propsOptedOut: dataset.propsOptedOut === 'true', propsOptedOut: dataset.propsOptedOut === 'true',
revenueGoals: JSON.parse(dataset.revenueGoals), revenueGoals: JSON.parse(dataset.revenueGoals!),
funnels: JSON.parse(dataset.funnels), funnels: JSON.parse(dataset.funnels!),
statsBegin: dataset.statsBegin, statsBegin: dataset.statsBegin!,
nativeStatsBegin: dataset.nativeStatsBegin, nativeStatsBegin: dataset.nativeStatsBegin!,
embedded: dataset.embedded, embedded: dataset.embedded!,
background: dataset.background, background: dataset.background!,
isDbip: dataset.isDbip === 'true', isDbip: dataset.isDbip === 'true',
flags: JSON.parse(dataset.flags), flags: JSON.parse(dataset.flags!),
validIntervalsByPeriod: JSON.parse(dataset.validIntervalsByPeriod), validIntervalsByPeriod: JSON.parse(dataset.validIntervalsByPeriod!),
shared: !!dataset.sharedLinkAuth shared: !!dataset.sharedLinkAuth
} }
return site
} }
const siteContextDefaultValue = { const siteContextDefaultValue = {

View File

@ -5,7 +5,7 @@ import_config "prod.exs"
config :esbuild, config :esbuild,
default: [ default: [
args: args:
~w(js/app.js js/dashboard.js js/embed.host.js js/embed.content.js --bundle --target=es2017 --loader:.js=jsx --outdir=../priv/static/js --define:BUILD_EXTRA=false), ~w(js/app.js js/dashboard.tsx js/embed.host.js js/embed.content.js --bundle --target=es2017 --loader:.js=jsx --outdir=../priv/static/js --define:BUILD_EXTRA=false),
cd: Path.expand("../assets", __DIR__), cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
] ]

View File

@ -5,7 +5,7 @@ import_config "dev.exs"
config :esbuild, config :esbuild,
default: [ default: [
args: args:
~w(js/app.js js/dashboard.js js/embed.host.js js/embed.content.js --bundle --target=es2017 --loader:.js=jsx --outdir=../priv/static/js --define:BUILD_EXTRA=false), ~w(js/app.js js/dashboard.tsx js/embed.host.js js/embed.content.js --bundle --target=es2017 --loader:.js=jsx --outdir=../priv/static/js --define:BUILD_EXTRA=false),
cd: Path.expand("../assets", __DIR__), cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
] ]

View File

@ -20,7 +20,7 @@ config :esbuild,
version: "0.17.11", version: "0.17.11",
default: [ default: [
args: args:
~w(js/app.js js/dashboard.js js/embed.host.js js/embed.content.js --bundle --target=es2017 --loader:.js=jsx --outdir=../priv/static/js --define:BUILD_EXTRA=true), ~w(js/app.js js/dashboard.tsx js/embed.host.js js/embed.content.js --bundle --target=es2017 --loader:.js=jsx --outdir=../priv/static/js --define:BUILD_EXTRA=true),
cd: Path.expand("../assets", __DIR__), cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
] ]