diff --git a/src/frontend/apps/desk/src/api/APIError.ts b/src/frontend/apps/desk/src/api/APIError.ts new file mode 100644 index 0000000..a94adb4 --- /dev/null +++ b/src/frontend/apps/desk/src/api/APIError.ts @@ -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; + } +} diff --git a/src/frontend/apps/desk/src/api/index.ts b/src/frontend/apps/desk/src/api/index.ts index f9f4a38..c8c14e4 100644 --- a/src/frontend/apps/desk/src/api/index.ts +++ b/src/frontend/apps/desk/src/api/index.ts @@ -1,2 +1,4 @@ +export * from './APIError'; export * from './fetchApi'; export * from './types'; +export * from './utils'; diff --git a/src/frontend/apps/desk/src/api/utils.ts b/src/frontend/apps/desk/src/api/utils.ts new file mode 100644 index 0000000..6be3c02 --- /dev/null +++ b/src/frontend/apps/desk/src/api/utils.ts @@ -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, + }; +}; diff --git a/src/frontend/apps/desk/src/components/Text.tsx b/src/frontend/apps/desk/src/components/Text.tsx index b2a335a..bbeca2a 100644 --- a/src/frontend/apps/desk/src/components/Text.tsx +++ b/src/frontend/apps/desk/src/components/Text.tsx @@ -38,6 +38,8 @@ export interface TextProps extends BoxProps { | '900'; } +export type TextType = ComponentPropsWithRef; + export const TextStyled = styled(Box)` ${({ $textAlign }) => $textAlign && `text-align: ${$textAlign};`} ${({ $weight }) => $weight && `font-weight: ${$weight};`} diff --git a/src/frontend/apps/desk/src/components/TextErrors.tsx b/src/frontend/apps/desk/src/components/TextErrors.tsx new file mode 100644 index 0000000..6c1d0c1 --- /dev/null +++ b/src/frontend/apps/desk/src/components/TextErrors.tsx @@ -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 ( + + {causes && + causes.map((cause, i) => ( + + {cause} + + ))} + + {!causes && ( + + {defaultMessage || t('Something bad happens, please retry.')} + + )} + + ); +}; diff --git a/src/frontend/apps/desk/src/features/teams/api/useCreateTeam.tsx b/src/frontend/apps/desk/src/features/teams/api/useCreateTeam.tsx index 384ce73..97dbd1b 100644 --- a/src/frontend/apps/desk/src/features/teams/api/useCreateTeam.tsx +++ b/src/frontend/apps/desk/src/features/teams/api/useCreateTeam.tsx @@ -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 => { const response = await fetchAPI(`teams/`, { @@ -21,7 +18,10 @@ export const createTeam = async (name: string): Promise => { }); 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; @@ -33,7 +33,7 @@ interface CreateTeamProps { export function useCreateTeam({ onSuccess }: CreateTeamProps) { const queryClient = useQueryClient(); - return useMutation({ + return useMutation({ mutationFn: createTeam, onSuccess: (data) => { void queryClient.invalidateQueries({ diff --git a/src/frontend/apps/desk/src/features/teams/api/useTeam.tsx b/src/frontend/apps/desk/src/features/teams/api/useTeam.tsx index bdca9f6..deb09ca 100644 --- a/src/frontend/apps/desk/src/features/teams/api/useTeam.tsx +++ b/src/frontend/apps/desk/src/features/teams/api/useTeam.tsx @@ -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 => { 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; }; @@ -36,9 +22,9 @@ export const KEY_TEAM = 'team'; export function useTeam( param: TeamParams, - queryConfig?: UseQueryOptions, + queryConfig?: UseQueryOptions, ) { - return useQuery({ + return useQuery({ queryKey: [KEY_TEAM, param], queryFn: () => getTeam(param), ...queryConfig, diff --git a/src/frontend/apps/desk/src/features/teams/api/useTeams.tsx b/src/frontend/apps/desk/src/features/teams/api/useTeams.tsx index dcb2562..05d1fc7 100644 --- a/src/frontend/apps/desk/src/features/teams/api/useTeams.tsx +++ b/src/frontend/apps/desk/src/features/teams/api/useTeams.tsx @@ -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; -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; }; @@ -45,7 +42,7 @@ export function useTeams( param: TeamsParams, queryConfig?: DefinedInitialDataInfiniteOptions< TeamsResponse, - TeamsResponseError, + APIError, InfiniteData, QueryKey, number @@ -53,7 +50,7 @@ export function useTeams( ) { return useInfiniteQuery< TeamsResponse, - TeamsResponseError, + APIError, InfiniteData, QueryKey, number diff --git a/src/frontend/apps/desk/src/pages/teams/[id].tsx b/src/frontend/apps/desk/src/pages/teams/[id].tsx index 3900963..f664193 100644 --- a/src/frontend/apps/desk/src/pages/teams/[id].tsx +++ b/src/frontend/apps/desk/src/pages/teams/[id].tsx @@ -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 ( - - {t('Something bad happens, please retry.')} - - ); + return ; } if (isLoading || !team) { diff --git a/src/frontend/apps/desk/src/pages/teams/create.tsx b/src/frontend/apps/desk/src/pages/teams/create.tsx index b000fab..4498b76 100644 --- a/src/frontend/apps/desk/src/pages/teams/create.tsx +++ b/src/frontend/apps/desk/src/pages/teams/create.tsx @@ -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={edit} /> - {isError && ( - - {t('Something bad happens, please retry.')} - - )} + {isError && error && } {isPending && ( diff --git a/src/frontend/apps/e2e/__tests__/app-desk/teams-create.spec.ts b/src/frontend/apps/e2e/__tests__/app-desk/teams-create.spec.ts index 75461de..66cf638 100644 --- a/src/frontend/apps/e2e/__tests__/app-desk/teams-create.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-desk/teams-create.spec.ts @@ -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({