Skip to content

Commit

Permalink
đŸ„…(frontend) improve add member form error handling
Browse files Browse the repository at this point in the history
- handle error thrown when group already exist
- add component tests
  • Loading branch information
daproclaima committed Sep 10, 2024
1 parent 864702d commit ae7e281
Show file tree
Hide file tree
Showing 7 changed files with 206 additions and 7 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ export const useAddMailDomain = ({
onSuccess(data);
},
onError: (error) => {
onError(error);
if (typeof onError === 'function') {
onError(error);
}
},
});
};
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,10 @@ export const createTeam = async (name: string): Promise<CreateTeamResponse> => {

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<CreateTeamResponse, APIError, string>({
mutationFn: createTeam,
Expand All @@ -41,5 +42,10 @@ export function useCreateTeam({ onSuccess }: CreateTeamProps) {
});
onSuccess(data);
},
onError: (error: APIError) => {
if (typeof onError === 'function') {
onError(error);
}
},
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -14,15 +15,54 @@ import { InputTeamName } from './InputTeamName';
export const CardCreateTeam = () => {
const { t } = useTranslation();
const router = useRouter();
const [errorCauses, setErrorCauses] = useState<string[]>([]);
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();
Expand Down Expand Up @@ -50,7 +90,7 @@ export const CardCreateTeam = () => {
</Box>
<InputTeamName
label={t('Team name')}
{...{ error, isError, isPending, setTeamName }}
{...{ errorCauses, isError, isPending, setTeamName }}
/>
</Box>
<Box $justify="space-between" $direction="row" $align="center">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -15,7 +15,7 @@ interface InputTeamNameProps {

export const InputTeamName = ({
defaultValue,
error,
errorCauses,
isError,
isPending,
label,
Expand All @@ -42,7 +42,7 @@ export const InputTeamName = ({
}}
state={isInputError ? 'error' : 'default'}
/>
{isError && error && <TextErrors causes={error.cause} />}
{isError && !!errorCauses?.length && <TextErrors causes={errorCauses} />}
{isPending && (
<Box $align="center">
<Loader />
Expand Down
Original file line number Diff line number Diff line change
@@ -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(<CardCreateTeam />, { 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();
});
});
1 change: 1 addition & 0 deletions src/frontend/apps/desk/src/i18n/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down

0 comments on commit ae7e281

Please sign in to comment.