Skip to content

Commit

Permalink
fix(components): gs-location-filter: show better error #75
Browse files Browse the repository at this point in the history
Show the error message from LAPIS when the call to fetch the autocomplete list fails.
The error details are shown in a separate modal.
  • Loading branch information
fengelniederhammer committed May 22, 2024
1 parent e606bd0 commit 187f9e7
Show file tree
Hide file tree
Showing 6 changed files with 123 additions and 10 deletions.
48 changes: 46 additions & 2 deletions components/src/lapisApi/lapisApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,35 @@ import {
aggregatedResponse,
insertionsResponse,
type LapisBaseRequest,
lapisError,
type MutationsRequest,
mutationsResponse,
problemDetail,
type ProblemDetail,
} from './lapisTypes';
import { type SequenceType } from '../types';

export class UnknownLapisError extends Error {
constructor(
message: string,
public readonly status: number,
) {
super(message);
this.name = 'UnknownLapisError';
}
}

export class LapisError extends Error {
constructor(
message: string,
public readonly status: number,
public readonly problemDetail: ProblemDetail,
) {
super(message);
this.name = 'LapisError';
}
}

export async function fetchAggregated(lapisUrl: string, body: LapisBaseRequest, signal?: AbortSignal) {
const response = await fetch(aggregatedEndpoint(lapisUrl), {
method: 'POST',
Expand Down Expand Up @@ -79,9 +103,29 @@ export async function fetchReferenceGenome(lapisUrl: string, signal?: AbortSigna
const handleErrors = async (response: Response) => {
if (!response.ok) {
if (response.status >= 400 && response.status < 500) {
throw new Error(`${response.statusText}: ${JSON.stringify(await response.json())}`);
const json = await response.json();

const lapisErrorResult = lapisError.safeParse(json);
if (lapisErrorResult.success) {
throw new LapisError(
response.statusText + lapisErrorResult.data.error.detail,
response.status,
lapisErrorResult.data.error,
);
}

const problemDetailResult = problemDetail.safeParse(json);
if (problemDetailResult.success) {
throw new LapisError(
response.statusText + problemDetailResult.data.detail,
response.status,
problemDetailResult.data,
);
}

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

Expand Down
14 changes: 14 additions & 0 deletions components/src/lapisApi/lapisTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,17 @@ function makeLapisResponse<T extends ZodTypeAny>(data: T) {
data,
});
}

export const problemDetail = z.object({
title: z.string().optional(),
status: z.number(),
detail: z.string().optional(),
type: z.string(),
instance: z.string().optional(),
});

export type ProblemDetail = z.infer<typeof problemDetail>;

export const lapisError = z.object({
error: problemDetail,
});
13 changes: 10 additions & 3 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, waitFor, within } from '@storybook/test';
import { expect, userEvent, waitFor, within } from '@storybook/test';

import { ErrorDisplay, UserFacingError } from './error-display';
import { ResizeContainer } from './resize-container';
Expand Down Expand Up @@ -30,14 +30,21 @@ export const ErrorStory: StoryObj = {
export const UserFacingErrorStory: StoryObj = {
render: () => (
<ResizeContainer size={{ height: '600px', width: '100%' }}>
<ErrorDisplay error={new UserFacingError('some message')} />
<ErrorDisplay error={new UserFacingError('Error Title', 'some message')} />
</ResizeContainer>
),

play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const error = canvas.getByText('Oops! Something went wrong.', { exact: false });
const detailMessage = () => canvas.getByText('some message');
await waitFor(() => expect(error).toBeInTheDocument());
await waitFor(() => expect(canvas.getByText('some message')).toBeInTheDocument());
await waitFor(() => {
expect(detailMessage()).not.toBeVisible();
});
await userEvent.click(canvas.getByText('Show details.'));
await waitFor(() => {
expect(detailMessage()).toBeVisible();
});
},
};
38 changes: 35 additions & 3 deletions components/src/preact/components/error-display.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { type FunctionComponent } from 'preact';
import { useRef } from 'preact/hooks';

export class UserFacingError extends Error {
constructor(message: string) {
constructor(
public readonly headline: string,
message: string,
) {
super(message);
this.name = 'UserFacingError';
}
Expand All @@ -10,11 +14,39 @@ export class UserFacingError extends Error {
export const ErrorDisplay: FunctionComponent<{ error: Error }> = ({ error }) => {
console.error(error);

const ref = useRef<HTMLDialogElement>(null);

return (
<div 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>Oops! Something went wrong.</div>
{error instanceof UserFacingError && <div className='text-sm text-gray-600'>{error.message}</div>}
<div>
Oops! Something went wrong.
{error instanceof UserFacingError && (
<>
{' '}
<button
className='text-sm text-gray-600 hover:text-gray-300'
onClick={() => ref.current?.showModal()}
>
Show details.
</button>
<dialog ref={ref} class='modal'>
<div class='modal-box'>
<form method='dialog'>
<button className='btn btn-sm btn-circle btn-ghost absolute right-2 top-2'>
</button>
</form>
<h1 class='text-lg'>{error.headline}</h1>
<p class='py-4'>{error.message}</p>
</div>
<form method='dialog' class='modal-backdrop'>
<button>close</button>
</form>
</dialog>
</>
)}
</div>
</div>
);
};
16 changes: 15 additions & 1 deletion components/src/preact/locationFilter/fetchAutocompletionList.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
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 @@ -8,7 +10,19 @@ export async function fetchAutocompletionList(fields: string[], lapis: string, s

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

const data = (await fetchAggregatedOperator.evaluate(lapis, signal)).content;
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 locationValues = data
.map((entry) => fields.reduce((acc, field) => ({ ...acc, [field]: entry[field] }), {}))
.reduce<Set<string>>((setOfAllHierarchies, entry) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,9 @@ export const FetchingLocationsFails: StoryObj<LocationFilterProps> = {
matcher: aggregatedEndpointMatcher,
response: {
status: 400,
body: { error: 'no data' },
body: {
error: { status: 400, detail: 'Dummy error message from mock LAPIS', type: 'about:blank' },
},
},
},
],
Expand Down

0 comments on commit 187f9e7

Please sign in to comment.