Skip to content

Commit

Permalink
feat(components): also display error details when LAPIS calls fail
Browse files Browse the repository at this point in the history
closes #366
  • Loading branch information
fengelniederhammer authored and JonasKellerer committed Aug 21, 2024
1 parent c608a60 commit fbb16a4
Show file tree
Hide file tree
Showing 21 changed files with 122 additions and 80 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
key: ${{ runner.os }}-node-${{ hashFiles('components/**/package-lock.json') }}

- name: Install npm packages
run: npm ci
run: npm ci && npm run generate-manifest

- name: Check format
run: npm run check-format
Expand Down
4 changes: 4 additions & 0 deletions components/.storybook-preact/preview.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { Preview } from '@storybook/preact';

import '../src/styles/tailwind.css';
import { withActions } from '@storybook/addon-actions/decorator';
import { GS_ERROR_EVENT_TYPE } from '../src/preact/components/error-display';

const preview: Preview = {
parameters: {
Expand All @@ -10,7 +12,9 @@ const preview: Preview = {
date: /Date$/i,
},
},
actions: { handles: [GS_ERROR_EVENT_TYPE] },
},
decorators: [withActions],
};

export default preview;
6 changes: 6 additions & 0 deletions components/.storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { REFERENCE_GENOME_ENDPOINT } from '../src/constants';
import referenceGenome from '../src/lapisApi/__mockData__/referenceGenome.json';
import customElements from '../custom-elements.json';
import DocumentationTemplate from './DocumentationTemplate.mdx';
import { withActions } from '@storybook/addon-actions/decorator';
import { GS_ERROR_EVENT_TYPE } from '../src/preact/components/error-display';

setCustomElementsManifest(customElements);

Expand Down Expand Up @@ -35,7 +37,11 @@ const preview: Preview = {
docs: {
page: DocumentationTemplate,
},
actions: { handles: [GS_ERROR_EVENT_TYPE] },
},
decorators: [withActions],
};

export default preview;

export const previewHandles = preview.parameters!.actions.handles;
22 changes: 15 additions & 7 deletions components/src/lapisApi/lapisApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export class UnknownLapisError extends Error {
constructor(
message: string,
public readonly status: number,
public readonly requestedData: string,
) {
super(message);
this.name = 'UnknownLapisError';
Expand All @@ -26,6 +27,7 @@ export class LapisError extends Error {
message: string,
public readonly status: number,
public readonly problemDetail: ProblemDetail,
public readonly requestedData: string,
) {
super(message);
this.name = 'LapisError';
Expand All @@ -42,7 +44,7 @@ export async function fetchAggregated(lapisUrl: string, body: LapisBaseRequest,
signal,
});

await handleErrors(response);
await handleErrors(response, 'aggregated data');

return aggregatedResponse.parse(await response.json());
}
Expand All @@ -62,7 +64,7 @@ export async function fetchInsertions(
signal,
});

await handleErrors(response);
await handleErrors(response, `${sequenceType} insertions`);

return insertionsResponse.parse(await response.json());
}
Expand All @@ -82,7 +84,7 @@ export async function fetchSubstitutionsOrDeletions(
signal,
});

await handleErrors(response);
await handleErrors(response, `${sequenceType} mutations`);

return mutationsResponse.parse(await response.json());
}
Expand All @@ -96,11 +98,11 @@ export async function fetchReferenceGenome(lapisUrl: string, signal?: AbortSigna
signal,
});

await handleErrors(response);
await handleErrors(response, 'the reference genomes');
return referenceGenomeResponse.parse(await response.json());
}

const handleErrors = async (response: Response) => {
const handleErrors = async (response: Response, requestedData: string) => {
if (!response.ok) {
if (response.status >= 400 && response.status < 500) {
const json = await response.json();
Expand All @@ -111,6 +113,7 @@ const handleErrors = async (response: Response) => {
response.statusText + lapisErrorResult.data.error.detail,
response.status,
lapisErrorResult.data.error,
requestedData,
);
}

Expand All @@ -120,12 +123,17 @@ const handleErrors = async (response: Response) => {
response.statusText + problemDetailResult.data.detail,
response.status,
problemDetailResult.data,
requestedData,
);
}

throw new UnknownLapisError(`${response.statusText}: ${JSON.stringify(json)}`, response.status);
throw new UnknownLapisError(
`${response.statusText}: ${JSON.stringify(json)}`,
response.status,
requestedData,
);
}
throw new UnknownLapisError(`${response.statusText}: ${response.status}`, response.status);
throw new UnknownLapisError(`${response.statusText}: ${response.status}`, response.status, requestedData);
}
};

Expand Down
7 changes: 2 additions & 5 deletions components/src/preact/components/error-boundary.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
import { withActions } from '@storybook/addon-actions/decorator';
import { type Meta, type StoryObj } from '@storybook/preact';
import { expect, waitFor, within } from '@storybook/test';

import { ErrorBoundary } from './error-boundary';
import { GS_ERROR_EVENT_TYPE, UserFacingError } from './error-display';
import { UserFacingError } from './error-display';

const meta: Meta = {
title: 'Component/Error boundary',
component: ErrorBoundary,
parameters: {
fetchMock: {},
actions: { handles: [GS_ERROR_EVENT_TYPE] },
},
argTypes: {
size: { control: 'object' },
Expand All @@ -19,7 +17,6 @@ const meta: Meta = {
args: {
size: { height: '600px', width: '100%' },
},
decorators: [withActions],
};

export default meta;
Expand Down Expand Up @@ -61,7 +58,7 @@ export const ErrorBoundaryWithUserFacingErrorStory: StoryObj = {
const canvas = within(canvasElement);
const content = canvas.queryByText('Some content.', { exact: false });
await waitFor(() => expect(content).not.toBeInTheDocument());
await waitFor(() => expect(canvas.getByText('Error')).toBeInTheDocument());
await waitFor(() => expect(canvas.getByText('Error - Error Headline')).toBeInTheDocument());
},
};

Expand Down
22 changes: 20 additions & 2 deletions components/src/preact/components/error-display.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { type Meta, type StoryObj } from '@storybook/preact';
import { expect, userEvent, waitFor, within } from '@storybook/test';
import { expect, fn, userEvent, waitFor, within } from '@storybook/test';

import { ErrorDisplay, UserFacingError } from './error-display';
import { ResizeContainer } from './resize-container';
Expand All @@ -12,7 +12,7 @@ const meta: Meta = {

export default meta;

export const ErrorStory: StoryObj = {
export const GenericErrorStory: StoryObj = {
render: () => (
<ResizeContainer size={{ height: '600px', width: '100%' }}>
<ErrorDisplay error={new Error('some message')} />
Expand Down Expand Up @@ -48,3 +48,21 @@ export const UserFacingErrorStory: StoryObj = {
});
},
};

export const FiresEvent: StoryObj = {
render: () => (
<ResizeContainer size={{ height: '600px', width: '100%' }}>
<ErrorDisplay error={new UserFacingError('Error Title', 'some message')} />
</ResizeContainer>
),

play: async ({ canvasElement }) => {
const listenerMock = fn();
canvasElement.addEventListener('gs-error', listenerMock);

await waitFor(() => {
expect(listenerMock.mock.calls.at(-1)[0].error.name).toStrictEqual('UserFacingError');
expect(listenerMock.mock.calls.at(-1)[0].error.message).toStrictEqual('some message');
});
},
};
68 changes: 51 additions & 17 deletions components/src/preact/components/error-display.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import { type FunctionComponent } from 'preact';
import { useEffect, useRef } from 'preact/hooks';

import { LapisError, UnknownLapisError } from '../../lapisApi/lapisApi';

export const GS_ERROR_EVENT_TYPE = 'gs-error';

export interface ErrorEvent extends Event {
readonly error: Error;
export class ErrorEvent extends Event {
constructor(public readonly error: Error) {
super(GS_ERROR_EVENT_TYPE, {
bubbles: true,
composed: true,
});
}
}

export class UserFacingError extends Error {
Expand All @@ -24,30 +31,23 @@ export const ErrorDisplay: FunctionComponent<{ error: Error }> = ({ error }) =>
const ref = useRef<HTMLDialogElement>(null);

useEffect(() => {
containerRef.current?.dispatchEvent(
new ErrorEvent(GS_ERROR_EVENT_TYPE, {
error,
bubbles: true,
composed: true,
}),
);
containerRef.current?.dispatchEvent(new ErrorEvent(error));
});

const { headline, details } = getDisplayedErrorMessage(error);

return (
<div
ref={containerRef}
className='h-full w-full rounded-md border-2 border-gray-100 p-2 flex items-center justify-center flex-col'
>
<div className='text-red-700 font-bold'>Error</div>
<div className='text-red-700 font-bold'>{headline}</div>
<div>
Oops! Something went wrong.
{error instanceof UserFacingError && (
{details !== undefined && (
<>
{' '}
<button
className='text-sm text-gray-600 hover:text-gray-300'
onClick={() => ref.current?.showModal()}
>
<button className='underline hover:text-gray-400' onClick={() => ref.current?.showModal()}>
Show details.
</button>
<dialog ref={ref} class='modal'>
Expand All @@ -57,8 +57,8 @@ export const ErrorDisplay: FunctionComponent<{ error: Error }> = ({ error }) =>
</button>
</form>
<h1 class='text-lg'>{error.headline}</h1>
<p class='py-4'>{error.message}</p>
<h1 class='text-lg'>{details.headline}</h1>
<p class='py-4'>{details.message}</p>
</div>
<form method='dialog' class='modal-backdrop'>
<button>close</button>
Expand All @@ -70,3 +70,37 @@ export const ErrorDisplay: FunctionComponent<{ error: Error }> = ({ error }) =>
</div>
);
};

function getDisplayedErrorMessage(error: Error) {
if (error instanceof UserFacingError) {
return {
headline: `Error - ${error.headline}`,
details: {
headline: error.headline,
message: error.message,
},
};
}

if (error instanceof LapisError) {
return {
headline: `Error - Failed fetching ${error.requestedData} from LAPIS`,
details: {
headline: `LAPIS request failed: ${error.requestedData} - ${error.problemDetail.status} ${error.problemDetail.title}`,
message: error.problemDetail.detail ?? error.message,
},
};
}

if (error instanceof UnknownLapisError) {
return {
headline: `Error - Failed fetching ${error.requestedData} from LAPIS`,
details: {
headline: `LAPIS request failed: An unexpected error occurred while fetching ${error.requestedData}`,
message: error.message,
},
};
}

return { headline: 'Error', details: undefined };
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { withActions } from '@storybook/addon-actions/decorator';
import { type Meta, type StoryObj } from '@storybook/preact';
import { expect, waitFor, within } from '@storybook/test';
import dayjs from 'dayjs/esm';
Expand All @@ -13,6 +12,7 @@ import {
PRESET_VALUE_LAST_6_MONTHS,
PRESET_VALUE_LAST_MONTH,
} from './selectableOptions';
import { previewHandles } from '../../../.storybook/preview';
import { LAPIS_URL } from '../../constants';
import { LapisUrlContext } from '../LapisUrlContext';

Expand All @@ -23,7 +23,7 @@ const meta: Meta<DateRangeSelectorProps<'CustomDateRange'>> = {
component: DateRangeSelector,
parameters: {
actions: {
handles: ['gs-date-range-changed'],
handles: ['gs-date-range-changed', ...previewHandles],
},
fetchMock: {},
},
Expand Down Expand Up @@ -68,7 +68,6 @@ const meta: Meta<DateRangeSelectorProps<'CustomDateRange'>> = {
initialDateFrom: '',
initialDateTo: '',
},
decorators: [withActions],
};

export default meta;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { withActions } from '@storybook/addon-actions/decorator';
import { type Meta, type StoryObj } from '@storybook/preact';

import { LineageFilter, type LineageFilterProps } from './lineage-filter';
import { previewHandles } from '../../../.storybook/preview';
import { AGGREGATED_ENDPOINT, LAPIS_URL } from '../../constants';
import aggregatedData from '../../preact/lineageFilter/__mockData__/aggregated.json';
import { LapisUrlContext } from '../LapisUrlContext';
Expand All @@ -11,7 +11,7 @@ const meta: Meta = {
component: LineageFilter,
parameters: {
actions: {
handles: ['gs-lineage-filter-changed'],
handles: ['gs-lineage-filter-changed', ...previewHandles],
},
fetchMock: {
mocks: [
Expand All @@ -31,7 +31,6 @@ const meta: Meta = {
],
},
},
decorators: [withActions],
};

export default meta;
Expand Down
15 changes: 1 addition & 14 deletions components/src/preact/locationFilter/fetchAutocompletionList.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import { LapisError } from '../../lapisApi/lapisApi';
import { FetchAggregatedOperator } from '../../operator/FetchAggregatedOperator';
import { UserFacingError } from '../components/error-display';

export async function fetchAutocompletionList(fields: string[], lapis: string, signal?: AbortSignal) {
const toAncestorInHierarchyOverwriteValues = Array(fields.length - 1)
Expand All @@ -10,18 +8,7 @@ export async function fetchAutocompletionList(fields: string[], lapis: string, s

const fetchAggregatedOperator = new FetchAggregatedOperator<Record<string, string | null>>({}, fields);

let data;
try {
data = (await fetchAggregatedOperator.evaluate(lapis, signal)).content;
} catch (error) {
if (error instanceof LapisError) {
throw new UserFacingError(
`Failed to fetch autocomplete list from LAPIS: ${error.problemDetail.status} ${error.problemDetail.title ?? ''}`,
error.problemDetail.detail ?? error.message,
);
}
throw error;
}
const data = (await fetchAggregatedOperator.evaluate(lapis, signal)).content;

const locationValues = data
.map((entry) => fields.reduce((acc, field) => ({ ...acc, [field]: entry[field] }), {}))
Expand Down
Loading

0 comments on commit fbb16a4

Please sign in to comment.