🥅(app-desk) better error management

We don't know how the error body returned by the
api will be, so we handle it in a more generic way.
This commit is contained in:
Anthony LC
2024-02-19 17:18:02 +01:00
committed by Anthony LC
parent 195e738c3c
commit 51064ec236
11 changed files with 123 additions and 52 deletions

View File

@@ -0,0 +1,17 @@
interface IAPIError {
status: number;
cause?: string[];
}
export class APIError extends Error implements IAPIError {
public status: number;
public cause?: string[];
constructor(message: string, { status, cause }: IAPIError) {
super(message);
this.name = 'APIError';
this.status = status;
this.cause = cause;
}
}

View File

@@ -1,2 +1,4 @@
export * from './APIError';
export * from './fetchApi';
export * from './types';
export * from './utils';

View File

@@ -0,0 +1,17 @@
export const errorCauses = async (response: Response) => {
const errorsBody = (await response.json()) as Record<
string,
string | string[]
> | null;
const causes = errorsBody
? Object.entries(errorsBody)
.map(([, value]) => value)
.flat()
: undefined;
return {
status: response.status,
cause: causes,
};
};

View File

@@ -38,6 +38,8 @@ export interface TextProps extends BoxProps {
| '900';
}
export type TextType = ComponentPropsWithRef<typeof Text>;
export const TextStyled = styled(Box)<TextProps>`
${({ $textAlign }) => $textAlign && `text-align: ${$textAlign};`}
${({ $weight }) => $weight && `font-weight: ${$weight};`}

View File

@@ -0,0 +1,44 @@
import { useTranslation } from 'react-i18next';
import { Box, Text, TextType } from '@/components';
interface TextErrorsProps extends TextType {
causes?: string[];
defaultMessage?: string;
}
export const TextErrors = ({
causes,
defaultMessage,
...textProps
}: TextErrorsProps) => {
const { t } = useTranslation();
return (
<Box>
{causes &&
causes.map((cause, i) => (
<Text
key={`causes-${i}`}
className="mt-s"
$theme="danger"
$textAlign="center"
{...textProps}
>
{cause}
</Text>
))}
{!causes && (
<Text
className="mt-s"
$theme="danger"
$textAlign="center"
{...textProps}
>
{defaultMessage || t('Something bad happens, please retry.')}
</Text>
)}
</Box>
);
};

View File

@@ -1,6 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { fetchAPI } from '@/api';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { KEY_LIST_TEAM } from './useTeams';
@@ -8,9 +8,6 @@ type CreateTeamResponse = {
id: string;
name: string;
};
export interface CreateTeamResponseError {
detail: string;
}
export const createTeam = async (name: string): Promise<CreateTeamResponse> => {
const response = await fetchAPI(`teams/`, {
@@ -21,7 +18,10 @@ export const createTeam = async (name: string): Promise<CreateTeamResponse> => {
});
if (!response.ok) {
throw new Error(`Couldn't create team: ${response.statusText}`);
throw new APIError(
'Failed to create the team',
await errorCauses(response),
);
}
return response.json() as Promise<CreateTeamResponse>;
@@ -33,7 +33,7 @@ interface CreateTeamProps {
export function useCreateTeam({ onSuccess }: CreateTeamProps) {
const queryClient = useQueryClient();
return useMutation<CreateTeamResponse, CreateTeamResponseError, string>({
return useMutation<CreateTeamResponse, APIError, string>({
mutationFn: createTeam,
onSuccess: (data) => {
void queryClient.invalidateQueries({

View File

@@ -1,6 +1,6 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import { fetchAPI } from '@/api';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { TeamResponse } from './types';
@@ -8,27 +8,13 @@ export type TeamParams = {
id: string;
};
export interface TeamResponseError {
detail?: string;
status?: number;
cause?: string;
}
export const getTeam = async ({ id }: TeamParams): Promise<TeamResponse> => {
const response = await fetchAPI(`teams/${id}`);
if (!response.ok) {
if (response.status === 404) {
throw {
status: 404,
message: `Team with id ${id} not found`,
};
}
throw new Error(`Couldn't fetch team:`, {
cause: ((await response.json()) as TeamResponseError).detail,
});
throw new APIError('Failed to get the team', await errorCauses(response));
}
return response.json() as Promise<TeamResponse>;
};
@@ -36,9 +22,9 @@ export const KEY_TEAM = 'team';
export function useTeam(
param: TeamParams,
queryConfig?: UseQueryOptions<TeamResponse, TeamResponseError, TeamResponse>,
queryConfig?: UseQueryOptions<TeamResponse, APIError, TeamResponse>,
) {
return useQuery<TeamResponse, TeamResponseError, TeamResponse>({
return useQuery<TeamResponse, APIError, TeamResponse>({
queryKey: [KEY_TEAM, param],
queryFn: () => getTeam(param),
...queryConfig,

View File

@@ -5,7 +5,7 @@ import {
useInfiniteQuery,
} from '@tanstack/react-query';
import { APIList, fetchAPI } from '@/api';
import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
import { TeamResponse } from './types';
@@ -22,9 +22,6 @@ type TeamsAPIParams = TeamsParams & {
};
type TeamsResponse = APIList<TeamResponse>;
export interface TeamsResponseError {
detail: string;
}
export const getTeams = async ({
ordering,
@@ -34,7 +31,7 @@ export const getTeams = async ({
const response = await fetchAPI(`teams/?page=${page}${orderingQuery}`);
if (!response.ok) {
throw new Error(`Couldn't fetch teams: ${response.statusText}`);
throw new APIError('Failed to get the teams', await errorCauses(response));
}
return response.json() as Promise<TeamsResponse>;
};
@@ -45,7 +42,7 @@ export function useTeams(
param: TeamsParams,
queryConfig?: DefinedInitialDataInfiniteOptions<
TeamsResponse,
TeamsResponseError,
APIError,
InfiniteData<TeamsResponse>,
QueryKey,
number
@@ -53,7 +50,7 @@ export function useTeams(
) {
return useInfiniteQuery<
TeamsResponse,
TeamsResponseError,
APIError,
InfiniteData<TeamsResponse>,
QueryKey,
number

View File

@@ -2,9 +2,9 @@ import { Loader } from '@openfun/cunningham-react';
import { useRouter as useNavigate } from 'next/navigation';
import { useRouter } from 'next/router';
import { ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Text } from '@/components';
import { Box } from '@/components';
import { TextErrors } from '@/components/TextErrors';
import { TeamInfo, useTeam } from '@/features/teams/';
import { NextPageWithLayout } from '@/types/next';
@@ -27,27 +27,16 @@ interface TeamProps {
}
const Team = ({ id }: TeamProps) => {
const { t } = useTranslation();
const { data: team, isLoading, isError, error } = useTeam({ id });
const navigate = useNavigate();
if (isError) {
if (isError && error) {
if (error.status === 404) {
navigate.replace(`/404`);
return null;
}
return (
<Text
$align="center"
$justify="center"
$height="100%"
$theme="danger"
$textAlign="center"
>
{t('Something bad happens, please retry.')}
</Text>
);
return <TextErrors causes={error.cause} />;
}
if (isLoading || !team) {

View File

@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import IconGroup from '@/assets/icons/icon-group2.svg';
import { Box, Card, StyledLink, Text } from '@/components';
import { TextErrors } from '@/components/TextErrors';
import { useCunninghamTheme } from '@/cunningham';
import { useCreateTeam } from '@/features/teams';
import { NextPageWithLayout } from '@/types/next';
@@ -18,6 +19,7 @@ const Page: NextPageWithLayout = () => {
mutate: createTeam,
isError,
isPending,
error,
} = useCreateTeam({
onSuccess: (team) => {
router.push(`/teams/${team.id}`);
@@ -55,11 +57,7 @@ const Page: NextPageWithLayout = () => {
onChange={(e) => setTeamName(e.target.value)}
rightIcon={<span className="material-icons">edit</span>}
/>
{isError && (
<Text className="mt-s" $theme="danger" $textAlign="center">
{t('Something bad happens, please retry.')}
</Text>
)}
{isError && error && <TextErrors causes={error.cause} />}
{isPending && (
<Box $align="center">
<Loader />

View File

@@ -95,6 +95,25 @@ test.describe('Teams Create', () => {
await expect(page).toHaveURL(/\/teams$/);
});
test('checks error when duplicate team', async ({ page, browserName }) => {
const panel = page.getByLabel('Teams panel').first();
await panel.getByRole('button', { name: 'Add a team' }).click();
const teamName = `My duplicate team ${browserName}-${Math.floor(Math.random() * 1000)}`;
await page.getByText('Team name').fill(teamName);
await page.getByRole('button', { name: 'Create the team' }).click();
await panel.getByRole('button', { name: 'Add a team' }).click();
await page.getByText('Team name').fill(teamName);
await page.getByRole('button', { name: 'Create the team' }).click();
await expect(
page.getByText('Team with this Slug already exists.'),
).toBeVisible();
});
test('checks 404 on teams/[id] page', async ({ page }) => {
await page.goto('/teams/some-unknown-team');
await expect(page.getByText('404 - Page not found')).toBeVisible({