🥅(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:
17
src/frontend/apps/desk/src/api/APIError.ts
Normal file
17
src/frontend/apps/desk/src/api/APIError.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
export * from './APIError';
|
||||
export * from './fetchApi';
|
||||
export * from './types';
|
||||
export * from './utils';
|
||||
|
||||
17
src/frontend/apps/desk/src/api/utils.ts
Normal file
17
src/frontend/apps/desk/src/api/utils.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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};`}
|
||||
|
||||
44
src/frontend/apps/desk/src/components/TextErrors.tsx
Normal file
44
src/frontend/apps/desk/src/components/TextErrors.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user