diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f4f8d5b1..29ccf0d4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to - 📈(monitoring) configure sentry monitoring #378 - đŸ„…(frontend) improve api error handling #355 +- đŸ„…(frontend) improve add group form error handling ### Changed diff --git a/src/frontend/apps/desk/src/features/mail-domains/api/useAddMailDomain.tsx b/src/frontend/apps/desk/src/features/mail-domains/api/useAddMailDomain.tsx index 60c81c501..3e4b54bae 100644 --- a/src/frontend/apps/desk/src/features/mail-domains/api/useAddMailDomain.tsx +++ b/src/frontend/apps/desk/src/features/mail-domains/api/useAddMailDomain.tsx @@ -46,7 +46,9 @@ export const useAddMailDomain = ({ onSuccess(data); }, onError: (error) => { - onError(error); + if (typeof onError === 'function') { + onError(error); + } }, }); }; diff --git a/src/frontend/apps/desk/src/features/teams/team-management/api/useCreateTeam.tsx b/src/frontend/apps/desk/src/features/teams/team-management/api/useCreateTeam.tsx index 97dbd1b57..ad51c9eb5 100644 --- a/src/frontend/apps/desk/src/features/teams/team-management/api/useCreateTeam.tsx +++ b/src/frontend/apps/desk/src/features/teams/team-management/api/useCreateTeam.tsx @@ -29,9 +29,10 @@ export const createTeam = async (name: string): Promise => { interface CreateTeamProps { onSuccess: (data: CreateTeamResponse) => void; + onError: (error: APIError) => void; } -export function useCreateTeam({ onSuccess }: CreateTeamProps) { +export function useCreateTeam({ onSuccess, onError }: CreateTeamProps) { const queryClient = useQueryClient(); return useMutation({ mutationFn: createTeam, @@ -41,5 +42,10 @@ export function useCreateTeam({ onSuccess }: CreateTeamProps) { }); onSuccess(data); }, + onError: (error: APIError) => { + if (typeof onError === 'function') { + onError(error); + } + }, }); } diff --git a/src/frontend/apps/desk/src/features/teams/team-management/components/CardCreateTeam.tsx b/src/frontend/apps/desk/src/features/teams/team-management/components/CardCreateTeam.tsx index 46b914ce8..ad7ef9a63 100644 --- a/src/frontend/apps/desk/src/features/teams/team-management/components/CardCreateTeam.tsx +++ b/src/frontend/apps/desk/src/features/teams/team-management/components/CardCreateTeam.tsx @@ -3,6 +3,7 @@ import { useRouter } from 'next/navigation'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { parseAPIError } from '@/api/parseAPIError'; import IconGroup from '@/assets/icons/icon-group2.svg'; import { Box, Card, StyledLink, Text } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; @@ -14,15 +15,54 @@ import { InputTeamName } from './InputTeamName'; export const CardCreateTeam = () => { const { t } = useTranslation(); const router = useRouter(); + const [errorCauses, setErrorCauses] = useState([]); const { mutate: createTeam, isError, isPending, - error, } = useCreateTeam({ onSuccess: (team) => { router.push(`/teams/${team.id}`); }, + onError: (error) => { + const handledCauses: string[] = []; + const unhandledCauses = parseAPIError({ + error, + errorParams: { + slug: { + causes: ['Team with this Slug already exists.'], + handleError: () => { + handledCauses.push( + t( + 'This name is already used for another group. Please enter another one.', + ), + ); + }, + }, + }, + serverErrorParams: { + defaultMessage: t( + 'Your request cannot be processed because the server is experiencing an error. If the problem ' + + 'persists, please contact our support to resolve the issue: suiteterritoriale@anct.gouv.fr', + ), + }, + }); + + let causes: string[] = []; + + if (handledCauses?.length) { + causes = [...handledCauses]; + } + if (unhandledCauses?.length) { + causes = [...causes, ...unhandledCauses]; + } + + setErrorCauses((state) => + causes && JSON.stringify(causes) !== JSON.stringify(state) + ? causes + : state, + ); + }, }); const [teamName, setTeamName] = useState(''); const { colorsTokens } = useCunninghamTheme(); @@ -50,7 +90,7 @@ export const CardCreateTeam = () => { diff --git a/src/frontend/apps/desk/src/features/teams/team-management/components/InputTeamName.tsx b/src/frontend/apps/desk/src/features/teams/team-management/components/InputTeamName.tsx index 3a58903c3..32b5ec4d8 100644 --- a/src/frontend/apps/desk/src/features/teams/team-management/components/InputTeamName.tsx +++ b/src/frontend/apps/desk/src/features/teams/team-management/components/InputTeamName.tsx @@ -5,7 +5,7 @@ import { APIError } from '@/api'; import { Box, TextErrors } from '@/components'; interface InputTeamNameProps { - error: APIError | null; + errorCauses: APIError['cause']; isError: boolean; isPending: boolean; label: string; @@ -15,7 +15,7 @@ interface InputTeamNameProps { export const InputTeamName = ({ defaultValue, - error, + errorCauses, isError, isPending, label, @@ -42,7 +42,7 @@ export const InputTeamName = ({ }} state={isInputError ? 'error' : 'default'} /> - {isError && error && } + {isError && !!errorCauses?.length && } {isPending && ( diff --git a/src/frontend/apps/desk/src/features/teams/team-management/components/__tests__/CardCreateTeam.test.tsx b/src/frontend/apps/desk/src/features/teams/team-management/components/__tests__/CardCreateTeam.test.tsx new file mode 100644 index 000000000..d22359626 --- /dev/null +++ b/src/frontend/apps/desk/src/features/teams/team-management/components/__tests__/CardCreateTeam.test.tsx @@ -0,0 +1,149 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import fetchMock from 'fetch-mock'; +import React from 'react'; + +import { AppWrapper } from '@/tests/utils'; + +import { CardCreateTeam } from '../CardCreateTeam'; + +const mockPush = jest.fn(); +jest.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockPush, + }), +})); + +describe('CardCreateTeam', () => { + const renderCardCreateTeam = () => + render(, { wrapper: AppWrapper }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + fetchMock.restore(); + }); + + it('renders all the elements', () => { + renderCardCreateTeam(); + + expect(screen.getByLabelText('Create new team card')).toBeInTheDocument(); + expect(screen.getByText('Create a new group')).toBeInTheDocument(); + expect(screen.getByLabelText('Team name')).toBeInTheDocument(); + expect(screen.getByText('Cancel')).toBeInTheDocument(); + expect(screen.getByText('Create the team')).toBeInTheDocument(); + }); + + it('handles input for team name and enables submit button', async () => { + const user = userEvent.setup(); + renderCardCreateTeam(); + + const teamNameInput = screen.getByLabelText('Team name'); + const createButton = screen.getByText('Create the team'); + + expect(createButton).toBeDisabled(); + + await user.type(teamNameInput, 'New Team'); + expect(createButton).toBeEnabled(); + }); + + it('creates a team successfully and redirects', async () => { + fetchMock.post('end:teams/', { + id: '270328ea-c2c0-4f74-a449-5cdc976dcdb6', + name: 'New Team', + }); + + const user = userEvent.setup(); + renderCardCreateTeam(); + + const teamNameInput = screen.getByLabelText('Team name'); + const createButton = screen.getByText('Create the team'); + + await user.type(teamNameInput, 'New Team'); + await user.click(createButton); + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith( + '/teams/270328ea-c2c0-4f74-a449-5cdc976dcdb6', + ); + }); + + expect(fetchMock.calls()).toHaveLength(1); + expect(fetchMock.lastCall()?.[0]).toContain('/teams/'); + expect(fetchMock.lastCall()?.[1]?.body).toEqual( + JSON.stringify({ name: 'New Team' }), + ); + }); + + it('displays an error message when team name already exists', async () => { + fetchMock.post('end:teams/', { + body: { + cause: ['Team with this Slug already exists.'], + }, + status: 400, + }); + + const user = userEvent.setup(); + renderCardCreateTeam(); + + const teamNameInput = screen.getByLabelText('Team name'); + const createButton = screen.getByText('Create the team'); + + await user.type(teamNameInput, 'Existing Team'); + await user.click(createButton); + + await waitFor(() => { + expect( + screen.getByText(/This name is already used for another group/i), + ).toBeInTheDocument(); + }); + }); + + it('handles server error gracefully', async () => { + fetchMock.post('end:/teams/', { + body: {}, + status: 500, + }); + + const user = userEvent.setup(); + renderCardCreateTeam(); + + const teamNameInput = screen.getByLabelText('Team name'); + const createButton = screen.getByText('Create the team'); + + await user.type(teamNameInput, 'Server Error Team'); + await user.click(createButton); + + await waitFor(() => { + expect( + screen.getByText( + /Your request cannot be processed because the server is experiencing an error/i, + ), + ).toBeInTheDocument(); + }); + + expect(fetchMock.calls()).toHaveLength(1); + expect(fetchMock.lastCall()?.[0]).toContain('/teams/'); + expect(fetchMock.lastCall()?.[1]?.body).toEqual( + JSON.stringify({ name: 'Server Error Team' }), + ); + }); + + it('disables create button when API request is pending', async () => { + // Never resolves + fetchMock.post('end:teams/', new Promise(() => {})); + + const user = userEvent.setup(); + renderCardCreateTeam(); + + const teamNameInput = screen.getByLabelText('Team name'); + const createButton = screen.getByText('Create the team'); + + await user.type(teamNameInput, 'Pending Team'); + await user.click(createButton); + + expect(createButton).toBeDisabled(); + }); +}); diff --git a/src/frontend/apps/desk/src/i18n/translations.json b/src/frontend/apps/desk/src/i18n/translations.json index 8acf3f8fd..a9426927a 100644 --- a/src/frontend/apps/desk/src/i18n/translations.json +++ b/src/frontend/apps/desk/src/i18n/translations.json @@ -147,6 +147,7 @@ "This domain name is deactivated. No new mailboxes can be created.": "Ce nom de domaine est dĂ©sactivĂ©. Aucune nouvelle boĂźte mail ne peut ĂȘtre crĂ©Ă©e.", "This email prefix is already used.": "Ce prĂ©fixe d'email est dĂ©jĂ  utilisĂ©.", "This mail domain is already used. Please, choose another one.": "Ce domaine de messagerie est dĂ©jĂ  utilisĂ©. Veuillez en choisir un autre.", + "This name is already used for another group. Please enter another one.": "Un autre groupe utilise dĂ©jĂ  ce nom. Veuillez en saisir un autre.", "This procedure is to be used in the following case: you have reported to the website \n manager an accessibility defect which prevents you from accessing content or one of the \n portal's services and you have not obtained a satisfactory response.": "Cette procĂ©dure est Ă  utiliser dans le cas suivant : vous avez signalĂ© au responsable du site internet un dĂ©faut d’accessibilitĂ© qui vous empĂȘche d’accĂ©der Ă  un contenu ou Ă  un des services du portail et vous n’avez pas obtenu de rĂ©ponse satisfaisante.", "This site does not display a cookie consent banner, why?": "Ce site n'affiche pas de banniĂšre de consentement des cookies, pourquoi?", "This site places a small text file (a \"cookie\") on your computer when you visit it.": "Ce site place un petit fichier texte (un « cookie ») sur votre ordinateur lorsque vous le visitez.",