Skip to content

Commit

Permalink
89 Error boundary (#93)
Browse files Browse the repository at this point in the history
* upgraded deps

* upgraded deps

* initial error boundary

* rename page

* docs and translations

* error page image

* tests

* pr fixes
  • Loading branch information
mwarman authored Oct 22, 2024
1 parent b4f8b5f commit 7d36471
Show file tree
Hide file tree
Showing 20 changed files with 560 additions and 264 deletions.
370 changes: 185 additions & 185 deletions package-lock.json

Large diffs are not rendered by default.

37 changes: 19 additions & 18 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,20 +31,21 @@
"@fortawesome/fontawesome-svg-core": "6.6.0",
"@fortawesome/free-solid-svg-icons": "6.6.0",
"@fortawesome/react-fontawesome": "0.2.2",
"@ionic/react": "8.3.2",
"@ionic/react-router": "8.3.2",
"@tanstack/react-query": "5.59.9",
"@tanstack/react-query-devtools": "5.59.9",
"@ionic/react": "8.3.3",
"@ionic/react-router": "8.3.3",
"@tanstack/react-query": "5.59.15",
"@tanstack/react-query-devtools": "5.59.15",
"axios": "1.7.7",
"classnames": "2.5.1",
"dayjs": "1.11.13",
"formik": "2.4.6",
"i18next": "23.15.2",
"i18next": "23.16.1",
"i18next-browser-languagedetector": "8.0.0",
"lodash": "4.17.21",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-i18next": "15.0.2",
"react-error-boundary": "4.1.2",
"react-i18next": "15.0.3",
"react-router": "5.3.4",
"react-router-dom": "5.3.4",
"uuid": "10.0.0",
Expand All @@ -53,31 +54,31 @@
"devDependencies": {
"@capacitor/cli": "6.1.2",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.5.0",
"@testing-library/jest-dom": "6.6.2",
"@testing-library/react": "16.0.1",
"@testing-library/user-event": "14.5.2",
"@types/lodash": "4.17.10",
"@types/lodash": "4.17.12",
"@types/react": "18.3.11",
"@types/react-dom": "18.3.1",
"@types/react-router": "5.1.20",
"@types/react-router-dom": "5.3.3",
"@types/uuid": "10.0.0",
"@typescript-eslint/eslint-plugin": "8.8.1",
"@typescript-eslint/parser": "8.8.1",
"@typescript-eslint/eslint-plugin": "8.10.0",
"@typescript-eslint/parser": "8.10.0",
"@vitejs/plugin-legacy": "5.4.2",
"@vitejs/plugin-react": "4.3.2",
"@vitest/coverage-v8": "2.1.2",
"@vitejs/plugin-react": "4.3.3",
"@vitest/coverage-v8": "2.1.3",
"cypress": "13.15.0",
"eslint": "8.57.0",
"eslint-plugin-react": "7.37.1",
"eslint-plugin-react-hooks": "5.0.0",
"eslint-plugin-react-refresh": "0.4.12",
"eslint-plugin-react-refresh": "0.4.13",
"jsdom": "25.0.1",
"msw": "2.4.10",
"sass": "1.79.5",
"terser": "5.34.1",
"msw": "2.4.11",
"sass": "1.80.3",
"terser": "5.36.0",
"typescript": "5.5.4",
"vite": "5.4.8",
"vitest": "2.1.2"
"vite": "5.4.9",
"vitest": "2.1.3"
}
}
36 changes: 20 additions & 16 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { IonApp, setupIonicReact } from '@ionic/react';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { ErrorBoundary } from 'react-error-boundary';

import ErrorPage from 'common/components/Error/ErrorPage';
import ConfigContextProvider from './common/providers/ConfigProvider';
import { queryClient } from 'common/utils/query-client';
import AuthProvider from 'common/providers/AuthProvider';
Expand All @@ -11,7 +13,7 @@ import ScrollProvider from 'common/providers/ScrollProvider';
import Toasts from 'common/components/Toast/Toasts';
import AppRouter from 'common/components/Router/AppRouter';

import './theme/main.scss';
import './theme/main.css';

setupIonicReact();

Expand All @@ -22,21 +24,23 @@ setupIonicReact();
*/
const App = (): JSX.Element => (
<IonApp data-testid="app">
<ConfigContextProvider>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<AxiosProvider>
<ToastProvider>
<ScrollProvider>
<AppRouter />
<Toasts />
<ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-left" />
</ScrollProvider>
</ToastProvider>
</AxiosProvider>
</AuthProvider>
</QueryClientProvider>
</ConfigContextProvider>
<ErrorBoundary FallbackComponent={ErrorPage}>
<ConfigContextProvider>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<AxiosProvider>
<ToastProvider>
<ScrollProvider>
<AppRouter />
<Toasts />
<ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-left" />
</ScrollProvider>
</ToastProvider>
</AxiosProvider>
</AuthProvider>
</QueryClientProvider>
</ConfigContextProvider>
</ErrorBoundary>
</IonApp>
);

Expand Down
Binary file added src/assets/img/face_surprise_melting.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
23 changes: 23 additions & 0 deletions src/common/components/Error/ErrorPage.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
.ls-error-page {
&__container {
max-width: 576px;
}

&__content {
display: flex;
flex-direction: column;
align-items: center;
}

&__title {
margin-bottom: 2rem;
}

&__button-row {
margin-top: 4rem;

ion-button {
min-width: 12rem;
}
}
}
92 changes: 92 additions & 0 deletions src/common/components/Error/ErrorPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { IonButton, IonButtons, IonContent, IonFooter, IonPage, IonToolbar } from '@ionic/react';
import { FallbackProps } from 'react-error-boundary';
import { useTranslation } from 'react-i18next';
import { ValidationError } from 'yup';
import { AxiosError } from 'axios';

import image from 'assets/img/face_surprise_melting.png';
import { PropsWithTestId } from '../types';
import './ErrorPage.scss';
import Header from '../Header/Header';
import Container from '../Content/Container';
import ButtonRow from '../Button/ButtonRow';

/**
* Properties for the `ErrorPage` component.
*/
interface ErrorPageProps extends FallbackProps, PropsWithTestId {}

/**
* The `ErrorPage` displays the attributes of an `Error`.
* @param {ErrorPageProps} props - Component properties.
* @returns {JSX.Element} JSX
*/
const ErrorPage = ({
error,
resetErrorBoundary,
testid = 'page-error',
}: ErrorPageProps): JSX.Element => {
const { t } = useTranslation();

let title;
let message;
if (error instanceof ValidationError) {
title = t('error-validation');
message = error.errors.reduce((msg, error) => `${msg} ${error}`);
} else if (error instanceof AxiosError) {
title = error.status ?? error.code;
message = `${error.message}. ${error.config?.url}`;
} else {
title = error.name ?? t('error');
message = error.message ?? error;
}

return (
<IonPage className="ls-error-page" data-testid={testid}>
<Header title={t('ionic-playground')} />

<IonContent className="ion-padding">
<Container fixed className="ls-error-page__container">
<div className="ls-error-page__content">
<img src={image} alt={title} />

<div
className="text-3xl font-bold uppercase ls-error-page__title"
data-testid={`${testid}-title`}
>
{title}
</div>

<div
className="ion-text-center text-lg ls-error-page__message"
data-testid={`${testid}-message`}
>
{message}
</div>

<ButtonRow className="ion-hide-md-down ls-error-page__button-row">
<IonButton
color="medium"
onClick={() => resetErrorBoundary()}
data-testid={`${testid}-button`}
>
{t('label.try-again')}
</IonButton>
</ButtonRow>
</div>
</Container>
</IonContent>
<IonFooter className="ion-hide-md-up">
<IonToolbar>
<IonButtons slot="end">
<IonButton onClick={() => resetErrorBoundary()} data-testid={`${testid}-footer-button`}>
{t('label.try-again')}
</IonButton>
</IonButtons>
</IonToolbar>
</IonFooter>
</IonPage>
);
};

export default ErrorPage;
145 changes: 145 additions & 0 deletions src/common/components/Error/__tests__/ErrorPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { describe, expect, it, vi } from 'vitest';
import userEvent from '@testing-library/user-event';
import { ValidationError } from 'yup';
import { AxiosError } from 'axios';

import { render, screen } from 'test/test-utils';

import ErrorPage from '../ErrorPage';

describe('ErrorPage', () => {
it('should render successfully', async () => {
// ARRANGE
const error = new Error('error message');
const mockReset = vi.fn();
render(<ErrorPage error={error} resetErrorBoundary={mockReset} />);
await screen.findByTestId('page-error');

// ASSERT
expect(screen.getByTestId('page-error')).toBeDefined();
});

it('should display ValidationError', async () => {
// ARRANGE
const ve1 = new ValidationError('Required.');
const ve2 = new ValidationError('Max length is 100.');
const error = new ValidationError([ve1, ve2]);
const mockReset = vi.fn();
render(<ErrorPage error={error} resetErrorBoundary={mockReset} />);
await screen.findByTestId('page-error');

// ASSERT
expect(screen.getByTestId('page-error')).toBeDefined();
expect(screen.getByTestId('page-error-title')).toHaveTextContent(/^Validation Error$/i);
expect(screen.getByTestId('page-error-message')).toHaveTextContent(
/^Required. Max length is 100.$/i,
);
});

it('should display AxiosError', async () => {
// ARRANGE
const config = {
url: 'http://www.example.org/',
};
// @ts-expect-error Only need partial object for test
const error = new AxiosError('error message', AxiosError.ERR_BAD_REQUEST, config);
const mockReset = vi.fn();
render(<ErrorPage error={error} resetErrorBoundary={mockReset} />);
await screen.findByTestId('page-error');

// ASSERT
expect(screen.getByTestId('page-error')).toBeDefined();
expect(screen.getByTestId('page-error-title')).toHaveTextContent(/^ERR_BAD_REQUEST$/i);
expect(screen.getByTestId('page-error-message')).toHaveTextContent(
/^error message. http:\/\/www.example.org\/$/i,
);
});

it('should display AxiosError with status code', async () => {
// ARRANGE
const config = {
url: 'http://www.example.org/',
};
// @ts-expect-error Only need partial object for test
const error = new AxiosError('error message', AxiosError.ERR_BAD_REQUEST, config, config, {
status: 404,
});
const mockReset = vi.fn();
render(<ErrorPage error={error} resetErrorBoundary={mockReset} />);
await screen.findByTestId('page-error');

// ASSERT
expect(screen.getByTestId('page-error')).toBeDefined();
expect(screen.getByTestId('page-error-title')).toHaveTextContent(/^404$/i);
expect(screen.getByTestId('page-error-message')).toHaveTextContent(
/^error message. http:\/\/www.example.org\/$/i,
);
});

it('should display Error', async () => {
// ARRANGE
const error = new Error('error message');
const mockReset = vi.fn();
render(<ErrorPage error={error} resetErrorBoundary={mockReset} />);
await screen.findByTestId('page-error');

// ASSERT
expect(screen.getByTestId('page-error')).toBeDefined();
expect(screen.getByTestId('page-error-title')).toHaveTextContent(/^Error$/i);
expect(screen.getByTestId('page-error-message')).toHaveTextContent(/^error message$/i);
});

it('should display Error name', async () => {
// ARRANGE
const error = new Error('error message');
error.name = 'SpecificError';
const mockReset = vi.fn();
render(<ErrorPage error={error} resetErrorBoundary={mockReset} />);
await screen.findByTestId('page-error');

// ASSERT
expect(screen.getByTestId('page-error')).toBeDefined();
expect(screen.getByTestId('page-error-title')).toHaveTextContent(/^SpecificError$/i);
expect(screen.getByTestId('page-error-message')).toHaveTextContent(/^error message$/i);
});

it('should display plain error', async () => {
// ARRANGE
const mockReset = vi.fn();
render(<ErrorPage error={'error message'} resetErrorBoundary={mockReset} />);
await screen.findByTestId('page-error');

// ASSERT
expect(screen.getByTestId('page-error')).toBeDefined();
expect(screen.getByTestId('page-error-title')).toHaveTextContent(/^Error$/i);
expect(screen.getByTestId('page-error-message')).toHaveTextContent(/^error message$/i);
});

it('should attempt to reset clicking page body button', async () => {
// ARRANGE
const user = userEvent.setup();
const mockReset = vi.fn();
render(<ErrorPage error={'error message'} resetErrorBoundary={mockReset} />);
await screen.findByTestId('page-error-button');

// ACT
await user.click(screen.getByTestId('page-error-button'));

// ASSERT
expect(mockReset).toHaveBeenCalledTimes(1);
});

it('should attempt to reset clicking footer button', async () => {
// ARRANGE
const user = userEvent.setup();
const mockReset = vi.fn();
render(<ErrorPage error={'error message'} resetErrorBoundary={mockReset} />);
await screen.findByTestId('page-error-footer-button');

// ACT
await user.click(screen.getByTestId('page-error-footer-button'));

// ASSERT
expect(mockReset).toHaveBeenCalledTimes(1);
});
});
Loading

0 comments on commit 7d36471

Please sign in to comment.