mirror of
https://github.com/plausible/analytics.git
synced 2024-12-23 01:22:15 +03:00
Unify error handling and propose actions to user on error
This commit is contained in:
parent
11acadfde9
commit
ef5bee43a1
@ -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
76
assets/js/dashboard.tsx
Normal 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)
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
68
assets/js/dashboard/error/error-boundary.test.tsx
Normal file
68
assets/js/dashboard/error/error-boundary.test.tsx
Normal 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:'
|
||||
)
|
||||
]
|
||||
])
|
||||
})
|
31
assets/js/dashboard/error/error-boundary.tsx
Normal file
31
assets/js/dashboard/error/error-boundary.tsx
Normal 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
|
||||
}
|
||||
}
|
27
assets/js/dashboard/error/something-went-wrong.test.tsx
Normal file
27
assets/js/dashboard/error/something-went-wrong.test.tsx
Normal 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()
|
||||
})
|
70
assets/js/dashboard/error/something-went-wrong.tsx
Normal file
70
assets/js/dashboard/error/something-went-wrong.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,7 +1,13 @@
|
||||
/* @format */
|
||||
/** @format */
|
||||
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 SourcesModal from './stats/modals/sources'
|
||||
import ReferrersDrilldownModal from './stats/modals/referrer-drilldown'
|
||||
@ -19,7 +25,6 @@ import PropsModal from './stats/modals/props'
|
||||
import ConversionsModal from './stats/modals/conversions'
|
||||
import FilterModal from './stats/modals/filter-modal'
|
||||
import QueryContextProvider from './query-context'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@ -156,19 +161,33 @@ export const filterRoute = {
|
||||
element: <FilterModal />
|
||||
}
|
||||
|
||||
export function getRouterBasepath(site) {
|
||||
export function getRouterBasepath(
|
||||
site: Pick<PlausibleSite, 'shared' | 'domain'>
|
||||
): string {
|
||||
const basepath = site.shared
|
||||
? `/share/${encodeURIComponent(site.domain)}`
|
||||
: `/${encodeURIComponent(site.domain)}`
|
||||
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 router = createBrowserRouter(
|
||||
[
|
||||
{
|
||||
...rootRoute,
|
||||
errorElement: <RouteErrorElement />,
|
||||
children: [
|
||||
sourcesRoute,
|
||||
utmMediumsRoute,
|
||||
@ -199,6 +218,7 @@ export function createAppRouter(site) {
|
||||
{
|
||||
basename: basepath,
|
||||
future: {
|
||||
// @ts-expect-error valid according to docs (https://reactrouter.com/en/main/routers/create-browser-router#optsfuture)
|
||||
v7_prependBasename: true
|
||||
}
|
||||
}
|
@ -1,10 +1,10 @@
|
||||
/* @format */
|
||||
/** @format */
|
||||
import React, { createContext, ReactNode, useContext } from 'react'
|
||||
|
||||
export function parseSiteFromDataset(dataset: Record<string, string>) {
|
||||
const site = {
|
||||
domain: dataset.domain,
|
||||
offset: dataset.offset,
|
||||
export function parseSiteFromDataset(dataset: DOMStringMap): PlausibleSite {
|
||||
return {
|
||||
domain: dataset.domain!,
|
||||
offset: dataset.offset!,
|
||||
hasGoals: dataset.hasGoals === 'true',
|
||||
hasProps: dataset.hasProps === 'true',
|
||||
funnelsAvailable: dataset.funnelsAvailable === 'true',
|
||||
@ -12,18 +12,17 @@ export function parseSiteFromDataset(dataset: Record<string, string>) {
|
||||
conversionsOptedOut: dataset.conversionsOptedOut === 'true',
|
||||
funnelsOptedOut: dataset.funnelsOptedOut === 'true',
|
||||
propsOptedOut: dataset.propsOptedOut === 'true',
|
||||
revenueGoals: JSON.parse(dataset.revenueGoals),
|
||||
funnels: JSON.parse(dataset.funnels),
|
||||
statsBegin: dataset.statsBegin,
|
||||
nativeStatsBegin: dataset.nativeStatsBegin,
|
||||
embedded: dataset.embedded,
|
||||
background: dataset.background,
|
||||
revenueGoals: JSON.parse(dataset.revenueGoals!),
|
||||
funnels: JSON.parse(dataset.funnels!),
|
||||
statsBegin: dataset.statsBegin!,
|
||||
nativeStatsBegin: dataset.nativeStatsBegin!,
|
||||
embedded: dataset.embedded!,
|
||||
background: dataset.background!,
|
||||
isDbip: dataset.isDbip === 'true',
|
||||
flags: JSON.parse(dataset.flags),
|
||||
validIntervalsByPeriod: JSON.parse(dataset.validIntervalsByPeriod),
|
||||
flags: JSON.parse(dataset.flags!),
|
||||
validIntervalsByPeriod: JSON.parse(dataset.validIntervalsByPeriod!),
|
||||
shared: !!dataset.sharedLinkAuth
|
||||
}
|
||||
return site
|
||||
}
|
||||
|
||||
const siteContextDefaultValue = {
|
||||
|
@ -5,7 +5,7 @@ import_config "prod.exs"
|
||||
config :esbuild,
|
||||
default: [
|
||||
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__),
|
||||
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
|
||||
]
|
||||
|
@ -5,7 +5,7 @@ import_config "dev.exs"
|
||||
config :esbuild,
|
||||
default: [
|
||||
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__),
|
||||
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
|
||||
]
|
||||
|
@ -20,7 +20,7 @@ config :esbuild,
|
||||
version: "0.17.11",
|
||||
default: [
|
||||
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__),
|
||||
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
|
||||
]
|
||||
|
Loading…
Reference in New Issue
Block a user