Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 45 additions & 6 deletions packages/react-core/src/components/ErrorPage/ErrorPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';

import { Button } from '@douglasneuroinformatics/libui/components';
import { useDownload, useTranslation } from '@douglasneuroinformatics/libui/hooks';
Expand All @@ -13,39 +13,78 @@ export type ErrorPageProps = {
export const ErrorPage = ({ error }: ErrorPageProps) => {
const download = useDownload();
const { t } = useTranslation();
const [copyState, setCopyState] = useState<'COPIED' | 'FAILED' | 'READY'>('READY');

useEffect(() => {
console.error(error);
}, [error]);

const report = JSON.stringify(serializeError(error), null, 2);

// `navigator.clipboard` is absent outside a secure context, so an instance served over plain http
// has no clipboard at all. Say so rather than appearing to copy nothing — the report is the only
// thing the administrator has to go on, and the download beside this button is the way out.
const copyReport = async () => {
try {
if (!navigator.clipboard) {
throw new Error('The clipboard is unavailable outside a secure context');
}
await navigator.clipboard.writeText(report);
setCopyState('COPIED');
} catch (err) {
console.error(err);
setCopyState('FAILED');
}
};

let heading = t({ en: 'Unknown Error', fr: 'Erreur inconnue' });
if (isAxiosError(error) && error.status) {
heading = `${error.status} - ${getReasonPhrase(error.status)}`;
}

return (
<div className="flex min-h-screen flex-col items-center justify-center gap-1 p-3 text-center">
<div
className="flex min-h-screen flex-col items-center justify-center gap-1 p-3 text-center"
data-testid="error-page"
>
<h1 className="text-muted-foreground text-sm font-semibold uppercase tracking-wide">
{t({ en: 'Something Went Wrong', fr: "Une erreur s'est produite" })}
</h1>
<h3 className="text-3xl font-extrabold tracking-tight sm:text-4xl md:text-5xl">{heading}</h3>
<p className="text-muted-foreground mt-2 max-w-prose text-sm sm:text-base">
{t({
en: 'We apologize for the inconvenience. Please download the error report using the button below and send it to your platform administrator for further assistance.',
fr: "Nous nous excusons pour ce désagrément. Veuillez télécharger le rapport d'erreur à l'aide du bouton ci-dessous et l'envoyer à l'administrateur de votre plateforme pour obtenir de l'aide."
en: 'We apologize for the inconvenience. Please copy or download the error report using the buttons below and send it to your platform administrator for further assistance.',
fr: "Nous nous excusons pour ce désagrément. Veuillez copier ou télécharger le rapport d'erreur à l'aide des boutons ci-dessous et l'envoyer à l'administrateur de votre plateforme pour obtenir de l'aide."
})}
</p>
<div className="mt-6 flex gap-2">
<Button
data-testid="copy-error-report"
type="button"
variant="outline"
onClick={() => {
void copyReport();
}}
onMouseLeave={() => {
setCopyState('READY');
}}
>
{copyState === 'COPIED' && t({ en: 'Copied', fr: 'Copié' })}
{copyState === 'FAILED' && t({ en: 'Copy Failed', fr: 'Échec de la copie' })}
{copyState === 'READY' && t({ en: 'Copy Error Report', fr: "Copier le rapport d'erreur" })}
</Button>
<Button
data-testid="download-error-report"
type="button"
variant="outline"
onClick={() => {
void download('error.json', JSON.stringify(serializeError(error), null, 2));
void download('error.json', report);
}}
>
{t({ en: 'Error Report', fr: "Rapport d'erreur" })}
{t({ en: 'Download Error Report', fr: "Télécharger le rapport d'erreur" })}
</Button>
<Button
data-testid="reload-page"
type="button"
variant="primary"
onClick={() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,19 +29,43 @@ describe('ErrorPage', () => {
expect(screen.getByText('404 - Not Found')).toBeTruthy();
});

it('should download the error report when the error report button is clicked', async () => {
it('should download the error report when the download button is clicked', async () => {
vi.spyOn(console, 'error').mockImplementation(() => undefined);
const createObjectURLSpy = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
const revokeObjectURLSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
render(<ErrorPage error={new Error('boom')} />);
fireEvent.click(screen.getByText('Error Report'));
fireEvent.click(screen.getByText('Download Error Report'));
await waitFor(() => {
expect(createObjectURLSpy).toHaveBeenCalled();
});
createObjectURLSpy.mockRestore();
revokeObjectURLSpy.mockRestore();
});

it('should write the same report the download offers to the clipboard, so it need not be downloaded to be shared', async () => {
vi.spyOn(console, 'error').mockImplementation(() => undefined);
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal('navigator', { ...navigator, clipboard: { writeText } });
render(<ErrorPage error={new Error('boom')} />);
fireEvent.click(screen.getByText('Copy Error Report'));
await waitFor(() => {
expect(screen.getByText('Copied')).toBeTruthy();
});
expect(JSON.parse(writeText.mock.calls[0]![0] as string)).toMatchObject({ message: 'boom' });
vi.unstubAllGlobals();
});

it('should report a failure to copy rather than appearing to have copied, since an insecure context has no clipboard', async () => {
vi.spyOn(console, 'error').mockImplementation(() => undefined);
vi.stubGlobal('navigator', { ...navigator, clipboard: undefined });
render(<ErrorPage error={new Error('boom')} />);
fireEvent.click(screen.getByText('Copy Error Report'));
await waitFor(() => {
expect(screen.getByText('Copy Failed')).toBeTruthy();
});
vi.unstubAllGlobals();
});

it('should reload the page when the reload button is clicked', () => {
vi.spyOn(console, 'error').mockImplementation(() => undefined);
const assignSpy = vi.spyOn(window.location, 'assign').mockImplementation(() => undefined);
Expand Down
45 changes: 45 additions & 0 deletions testing/src/specs/error-page.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { expect, test } from '../support/fixtures';

/**
* The terminal error page, reached by failing a route loader's request with a status the axios
* retry budget does not treat as transient — 500 rather than one of 502/503/504, which would show
* the self-healing connection screen instead.
*
* Not `@smoke`: the copy assertion needs `clipboard-read`, which Playwright grants on chromium only.
*/
test.describe('error page', () => {
test.beforeEach(async ({ authenticateAs, page }) => {
await authenticateAs('GROUP_MANAGER');
await page.route('**/v1/subjects*', (route) =>
route.fulfill({
body: JSON.stringify({ error: 'Internal Server Error', message: 'Something broke', statusCode: 500 }),
contentType: 'application/json',
status: 500
})
);
});

test('should offer the report for copying beside the download when a route fails', async ({ page }) => {
await page.goto('/datahub');
const errorPage = page.getByTestId('error-page');
await expect(errorPage).toBeVisible();
await expect(page.getByTestId('download-error-report')).toContainText('Download Error Report');
await expect(page.getByTestId('copy-error-report')).toContainText('Copy Error Report');
});

test('should put the error report on the clipboard so it does not have to be downloaded to be shared', async ({
context,
page
}) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
await page.goto('/datahub');
const copyButton = page.getByTestId('copy-error-report');
await copyButton.click();
await expect(copyButton).toContainText('Copied');

const copied = await page.evaluate(() => navigator.clipboard.readText());
// Parsed rather than matched as text: the point is that a readable report arrives, not that it
// is serialized in any particular shape.
expect(JSON.parse(copied)).toMatchObject({ message: expect.stringContaining('500') });
});
});
Loading