-
Notifications
You must be signed in to change notification settings - Fork 14
feat: add error-view component #493
Changes from all commits
70deae2
76751ea
d29e4a7
1854bca
34efb60
4df7e87
88c5857
1084f0a
98f6cdd
d392ccc
bc8dece
9fd11bc
7b52c88
a0817c0
5ca636c
87f2f03
34c8c9c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@hashicorp/react-error-view': patch | ||
| --- | ||
|
|
||
| Adds the react-error-view component. |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| # Error View | ||
|
|
||
| A component used to display an error view. For further details on this component, see [docs.mdx](./docs.mdx). These docs can also be viewed through our [Swingset instance](https://react-components.vercel.app). | ||
|
|
||
| ## `useErrorPageAnalytics` | ||
|
|
||
| The `useErrorPageAnalytics` hook is used within the `ErrorView` component, and is provided as a named export from `@hashicorp/react-error-view`. This hook is intended for use cases where the opinionated, `g-grid-container`-based styles of the default `ErrorView` component are not suited to a project. | ||
|
|
||
| ## `use404Redirects` | ||
|
|
||
| The `use404Redirects` hook can be used on a `page/404.js` page to handle redirects that aren't triggered via client-side navigations. It determines if the current page results in a server-side redirect by performing a HEAD request against the current URL, and performing a client-side navigation to the resulting URL. | ||
|
|
||
| ### Usage | ||
|
|
||
| ```tsx | ||
| import ErrorPage, { use404Redirects } from '@hashicorp/react-error-view' | ||
|
|
||
| export default function NotFound() { | ||
| use404Redirects() | ||
|
|
||
| return <ErrorView statusCode={404} /> | ||
| } | ||
| ``` | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| import nock from 'nock' | ||
| import { render, waitFor } from '@testing-library/react' | ||
| import * as Router from 'next/router' | ||
| import use404Redirects from '../use-404-redirects' | ||
|
|
||
| function HookTestComponent() { | ||
| use404Redirects() | ||
| return <div>Testing the use404Redirects hook...</div> | ||
| } | ||
|
|
||
| const useRouter = jest.spyOn(Router, 'useRouter') | ||
|
|
||
| describe('use404Redirects', () => { | ||
| let originalLocation: typeof window.location | ||
| let originalAnalytics: typeof window.analytics | ||
|
|
||
| beforeAll(() => { | ||
| originalLocation = window.location | ||
| originalAnalytics = window.analytics | ||
| }) | ||
|
|
||
| beforeEach(() => { | ||
| nock('http://local.test') | ||
| .head('/testing') | ||
| .reply(308, '', { Location: '/new-destination' }) | ||
| .head('/new-destination') | ||
| .reply(200, '') | ||
| .head('/valid') | ||
| .reply(200, '') | ||
|
|
||
| delete (window as any).location | ||
| window.location = { | ||
| // We use an absolute URL here since fetch in Jest can't use relative URLs | ||
| pathname: 'http://local.test/testing', | ||
| href: 'http://local.test/testing', | ||
| } as $TSFixMe | ||
| }) | ||
|
|
||
| afterEach(() => { | ||
| jest.resetAllMocks() | ||
| window.location = originalLocation | ||
| window.analytics = originalAnalytics | ||
| }) | ||
|
|
||
| afterAll(() => { | ||
| nock.restore() | ||
| }) | ||
|
|
||
| it('tracks event and calls router.replace with resolved URL', async () => { | ||
| const useRouterMethods = { replace: jest.fn() } as $TSFixMe | ||
| useRouter.mockImplementationOnce(() => useRouterMethods) | ||
|
|
||
| window.analytics = { track: jest.fn() } as $TSFixMe | ||
|
|
||
| // Render and assert | ||
| render(<HookTestComponent />) | ||
| await waitFor(() => | ||
| expect(useRouterMethods.replace).toHaveBeenCalledWith('/new-destination') | ||
| ) | ||
| expect(window.analytics.track).toHaveBeenCalledTimes(1) | ||
| expect(window.analytics.track).toBeCalledWith('http://local.test/testing', { | ||
| category: 'Client-side Redirect', | ||
| label: 'http://local.test/new-destination', | ||
| }) | ||
| }) | ||
|
|
||
| it('does not call router.replace when current URL == resolved URL', async () => { | ||
| const useRouterMethods = { replace: jest.fn() } as $TSFixMe | ||
| useRouter.mockImplementationOnce(() => useRouterMethods) | ||
|
|
||
| window.analytics = { track: jest.fn() } as $TSFixMe | ||
|
|
||
| window.location = { | ||
| pathname: 'http://local.test/valid', | ||
| href: 'http://local.test/valid', | ||
| } as $TSFixMe | ||
|
|
||
| // Render and assert | ||
| render(<HookTestComponent />) | ||
| await waitFor(() => expect(useRouterMethods.replace).not.toHaveBeenCalled()) | ||
| expect(window.analytics.track).not.toHaveBeenCalled() | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| import { render, waitFor } from '@testing-library/react' | ||
| import useErrorPageAnalytics from '../use-error-page-analytics' | ||
|
|
||
| function HookTestComponent({ statusCode }) { | ||
| useErrorPageAnalytics(statusCode) | ||
| return <div>Testing the useErrorPageAnalytics hook...</div> | ||
| } | ||
|
|
||
| describe('useErrorPageAnalytics', () => { | ||
| it('calls window.analytics.track with the provided error code', async () => { | ||
| // Mock window.analytics | ||
| const forMockRestore = window.analytics | ||
| // $TSFixMe loosens window.analytics type to diverge from Segment type | ||
| // defined in @hashicorp/platform-types | ||
| window.analytics = { track: jest.fn() } as $TSFixMe | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Without the |
||
| // Render and assert | ||
| render(<HookTestComponent statusCode={404} />) | ||
| await waitFor(() => expect(window.analytics.track).toHaveBeenCalledTimes(1)) | ||
| expect(window.analytics.track).toBeCalledWith(window.location.href, { | ||
| category: '404 Response', | ||
| label: 'No Referrer', | ||
| }) | ||
| // Cleanup | ||
| window.analytics = forMockRestore | ||
| }) | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| --- | ||
| componentName: 'ErrorView' | ||
| --- | ||
|
|
||
| A component used to display an error view, within layouts that use `g-grid-container`. | ||
|
|
||
| <LiveComponent>{` | ||
| <ErrorView statusCode={404} /> | ||
| `}</LiveComponent> | ||
|
|
||
| ### `useErrorPageAnalytics` | ||
|
|
||
| This component leverages a `useErrorPageAnalytics` hook internally, which reports the page error using `window.location.track()`. | ||
|
|
||
| > Note that `window.location.track()` is initialized in `ConsentManager`. | ||
|
|
||
| This hook is intended for use cases where the opinionated, `g-grid-container`-based styles of the default `ErrorView` component are not suited to a project. | ||
|
|
||
| ```jsx | ||
| /** | ||
| * eg 404.tsx or 500.tsx | ||
| * ref: https://nextjs.org/docs/advanced-features/custom-error-page | ||
| */ | ||
| import { useErrorPageAnalytics } from '@hashicorp/react-error-view' | ||
| import s from './custom-styles.module.css' | ||
|
|
||
| export default function CustomFourOhFourPage(): React.ReactElement { | ||
| useErrorPageAnalytics(404) | ||
|
|
||
| return ( | ||
| <p> | ||
| Oops, something went wrong! We logged an error. Please enjoy this "Back to | ||
| Home" link. | ||
| </p> | ||
| ) | ||
| } | ||
| ``` | ||
|
|
||
| <UsageDetails packageJson={packageJson} /> | ||
|
|
||
| ### Props | ||
|
|
||
| <PropsTable props={componentProps} /> | ||
|
|
||
| ### Examples | ||
|
|
||
| `statusCode={404}` | ||
|
|
||
| <LiveComponent>{` | ||
| <ErrorView statusCode={404} /> | ||
| `}</LiveComponent> | ||
|
|
||
| Fallback (for `500` pages, and any non-`404` statusCode) | ||
|
|
||
| <LiveComponent>{` | ||
| <ErrorView statusCode={500} /> | ||
| `}</LiveComponent> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| import React from 'react' | ||
| import Link from 'next/link' | ||
| import useErrorPageAnalytics from './use-error-page-analytics' | ||
| import use404Redirects from './use-404-redirects' | ||
| import s from './style.module.css' | ||
|
|
||
| export interface ErrorPageProps { | ||
| /** Error code to be recorded via window.analytics.track. */ | ||
| statusCode: number | ||
| } | ||
|
|
||
| const CONTENT_DICT = { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Went with a very fixed content approach for this component. I think this will satisfy most of our existing use cases, and have provided the |
||
| 404: { | ||
| heading: 'Not Found', | ||
| message: "We're sorry, but we can't find the page you're looking for.", | ||
| }, | ||
| fallback: { | ||
| heading: 'Something went wrong.', | ||
| message: | ||
| "We're sorry, but the requested page isn't available right now. We've logged this as an error, and will look into it. Please check back soon.", | ||
| }, | ||
| } | ||
|
|
||
| function ErrorPage({ statusCode }: ErrorPageProps): React.ReactElement { | ||
| useErrorPageAnalytics(statusCode) | ||
|
|
||
| const { heading, message } = CONTENT_DICT[statusCode] || CONTENT_DICT.fallback | ||
| return ( | ||
| <div className={s.root}> | ||
| <h1 className={s.heading}>{heading}</h1> | ||
| <p>{message}</p> | ||
| <p> | ||
| <Link href="/"> | ||
| <a className={s.link}>Back to Home</a> | ||
| </Link> | ||
| </p> | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| export { useErrorPageAnalytics, use404Redirects } | ||
| export default ErrorPage | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Kept this README pretty minimal, to avoid duplicating content in the
docs.mdxfile.