diff --git a/src/frontend/apps/desk/src/features/members/api/useCreateInvitation.tsx b/src/frontend/apps/desk/src/features/members/api/useCreateInvitation.tsx
index 5a38f94..487808d 100644
--- a/src/frontend/apps/desk/src/features/members/api/useCreateInvitation.tsx
+++ b/src/frontend/apps/desk/src/features/members/api/useCreateInvitation.tsx
@@ -5,6 +5,7 @@ import { User } from '@/features/auth';
import { Team } from '@/features/teams';
import { Invitation, Role } from '../types';
+import { OptionType } from '../typesSearchMembers';
interface CreateInvitationParams {
email: User['email'];
@@ -28,7 +29,10 @@ export const createInvitation = async ({
if (!response.ok) {
throw new APIError(
`Failed to create the invitation for ${email}`,
- await errorCauses(response, email),
+ await errorCauses(response, {
+ value: email,
+ type: OptionType.INVITATION,
+ }),
);
}
diff --git a/src/frontend/apps/desk/src/features/members/api/useCreateTeamAccess.tsx b/src/frontend/apps/desk/src/features/members/api/useCreateTeamAccess.tsx
index b111785..f0ce9e3 100644
--- a/src/frontend/apps/desk/src/features/members/api/useCreateTeamAccess.tsx
+++ b/src/frontend/apps/desk/src/features/members/api/useCreateTeamAccess.tsx
@@ -5,6 +5,7 @@ import { User } from '@/features/auth';
import { KEY_LIST_TEAM, KEY_TEAM, Team } from '@/features/teams';
import { Access, Role } from '../types';
+import { OptionType } from '../typesSearchMembers';
import { KEY_LIST_TEAM_ACCESSES } from '.';
@@ -32,7 +33,10 @@ export const createTeamAccess = async ({
if (!response.ok) {
throw new APIError(
`Failed to add ${name} in the team.`,
- await errorCauses(response),
+ await errorCauses(response, {
+ value: name,
+ type: OptionType.NEW_MEMBER,
+ }),
);
}
diff --git a/src/frontend/apps/desk/src/features/members/assets/add-member.svg b/src/frontend/apps/desk/src/features/members/assets/add-member.svg
new file mode 100644
index 0000000..08de3aa
--- /dev/null
+++ b/src/frontend/apps/desk/src/features/members/assets/add-member.svg
@@ -0,0 +1,14 @@
+
diff --git a/src/frontend/apps/desk/src/features/members/components/ModalAddMembers.tsx b/src/frontend/apps/desk/src/features/members/components/ModalAddMembers.tsx
index 1a353f9..1ce4e52 100644
--- a/src/frontend/apps/desk/src/features/members/components/ModalAddMembers.tsx
+++ b/src/frontend/apps/desk/src/features/members/components/ModalAddMembers.tsx
@@ -11,13 +11,22 @@ import { createGlobalStyle } from 'styled-components';
import { APIError } from '@/api';
import { Box, Text } from '@/components';
+import { useCunninghamTheme } from '@/cunningham';
import { Team } from '@/features/teams';
-import { useCreateInvitation } from '../api';
-import { Invitation, Role } from '../types';
+import { useCreateInvitation, useCreateTeamAccess } from '../api';
+import IconAddMember from '../assets/add-member.svg';
+import { Role } from '../types';
+import {
+ OptionInvitation,
+ OptionNewMember,
+ OptionSelect,
+ OptionType,
+ isOptionNewMember,
+} from '../typesSearchMembers';
import { ChooseRole } from './ChooseRole';
-import { OptionSelect, SearchMembers } from './SearchMembers';
+import { OptionsSelect, SearchMembers } from './SearchMembers';
const GlobalStyle = createGlobalStyle`
.c__modal {
@@ -25,6 +34,11 @@ const GlobalStyle = createGlobalStyle`
}
`;
+type APIErrorMember = APIError<{
+ value: string;
+ type: OptionType;
+}>;
+
interface ModalAddMembersProps {
currentRole: Role;
onClose: () => void;
@@ -36,49 +50,81 @@ export const ModalAddMembers = ({
onClose,
team,
}: ModalAddMembersProps) => {
+ const { colorsTokens } = useCunninghamTheme();
const { t } = useTranslation();
- const [selectedMembers, setSelectedMembers] = useState([]);
+ const [selectedMembers, setSelectedMembers] = useState([]);
const [selectedRole, setSelectedRole] = useState(Role.MEMBER);
const { toast } = useToastProvider();
const { mutateAsync: createInvitation } = useCreateInvitation();
+ const { mutateAsync: createTeamAccess } = useCreateTeamAccess();
- const handleValidate = async () => {
- const promisesMembers = selectedMembers.map((selectedMember) => {
- return createInvitation({
- email: selectedMember.value.email,
- role: selectedRole,
- teamId: team.id,
- });
+ const switchActions = (selectedMembers: OptionsSelect) =>
+ selectedMembers.map(async (selectedMember) => {
+ switch (selectedMember.type) {
+ case OptionType.INVITATION:
+ await createInvitation({
+ email: selectedMember.value.email,
+ role: selectedRole,
+ teamId: team.id,
+ });
+ break;
+
+ case OptionType.NEW_MEMBER:
+ await createTeamAccess({
+ name: selectedMember.value.name,
+ role: selectedRole,
+ teamId: team.id,
+ userId: selectedMember.value.id,
+ });
+ break;
+ }
+
+ return selectedMember;
});
- const promises = await Promise.allSettled(promisesMembers);
+ const toastOptions = {
+ duration: 4000,
+ };
+
+ const onError = (dataError: APIErrorMember['data']) => {
+ const messageError =
+ dataError?.type === OptionType.INVITATION
+ ? t(`Failed to create the invitation for {{email}}`, {
+ email: dataError?.value,
+ })
+ : t(`Failed to add {{name}} in the team`, {
+ name: dataError?.value,
+ });
+
+ toast(messageError, VariantType.ERROR, toastOptions);
+ };
+
+ const onSuccess = (option: OptionSelect) => {
+ const message = !isOptionNewMember(option)
+ ? t('Invitation sent to {{email}}', {
+ email: option.value.email,
+ })
+ : t('Member {{name}} added to the team', {
+ name: option.value.name,
+ });
+
+ toast(message, VariantType.SUCCESS, toastOptions);
+ };
+
+ const handleValidate = async () => {
+ const settledPromises = await Promise.allSettled<
+ OptionInvitation | OptionNewMember
+ >(switchActions(selectedMembers));
onClose();
- promises.forEach((promise) => {
- switch (promise.status) {
+ settledPromises.forEach((settledPromise) => {
+ switch (settledPromise.status) {
case 'rejected':
- const apiError = promise.reason as APIError;
- toast(
- t(`Failed to create the invitation for {{email}}`, {
- email: apiError.data,
- }),
- VariantType.ERROR,
- {
- duration: 4000,
- },
- );
+ onError((settledPromise.reason as APIErrorMember).data);
break;
case 'fulfilled':
- toast(
- t('Invitation sent to {{email}}', {
- email: promise.value.email,
- }),
- VariantType.SUCCESS,
- {
- duration: 4000,
- },
- );
+ onSuccess(settledPromise.value);
break;
}
});
@@ -106,7 +152,14 @@ export const ModalAddMembers = ({
}
size={ModalSize.MEDIUM}
- title={t('Add members to the team')}
+ title={
+
+
+
+ {t('Add a member')}
+
+
+ }
>
diff --git a/src/frontend/apps/desk/src/features/members/components/SearchMembers.tsx b/src/frontend/apps/desk/src/features/members/components/SearchMembers.tsx
index df6eae8..3acd335 100644
--- a/src/frontend/apps/desk/src/features/members/components/SearchMembers.tsx
+++ b/src/frontend/apps/desk/src/features/members/components/SearchMembers.tsx
@@ -3,20 +3,17 @@ import { useTranslation } from 'react-i18next';
import { Options } from 'react-select';
import AsyncSelect from 'react-select/async';
-import { User } from '@/features/auth';
import { Team } from '@/features/teams';
import { isValidEmail } from '@/utils';
import { KEY_LIST_USER, useUsers } from '../api/useUsers';
+import { OptionSelect, OptionType } from '../typesSearchMembers';
-export type OptionSelect = Options<{
- value: Partial & { email: User['email'] };
- label: string;
-}>;
+export type OptionsSelect = Options;
interface SearchMembersProps {
team: Team;
- setSelectedMembers: (value: OptionSelect) => void;
+ setSelectedMembers: (value: OptionsSelect) => void;
}
export const SearchMembers = ({
@@ -26,7 +23,7 @@ export const SearchMembers = ({
const { t } = useTranslation();
const [input, setInput] = useState('');
const [userQuery, setUserQuery] = useState('');
- const resolveOptionsRef = useRef<((value: OptionSelect) => void) | null>(
+ const resolveOptionsRef = useRef<((value: OptionsSelect) => void) | null>(
null,
);
const { data } = useUsers(
@@ -44,9 +41,10 @@ export const SearchMembers = ({
return;
}
- let users: OptionSelect = options.map((user) => ({
+ let users: OptionsSelect = options.map((user) => ({
value: user,
- label: user.name || '',
+ label: user.name || user.email,
+ type: OptionType.NEW_MEMBER,
}));
if (userQuery && isValidEmail(userQuery)) {
@@ -57,6 +55,7 @@ export const SearchMembers = ({
{
value: { email: userQuery },
label: userQuery,
+ type: OptionType.INVITATION,
},
];
}
@@ -67,8 +66,8 @@ export const SearchMembers = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [options]);
- const loadOptions = (): Promise => {
- return new Promise((resolve) => {
+ const loadOptions = (): Promise => {
+ return new Promise((resolve) => {
resolveOptionsRef.current = resolve;
});
};
diff --git a/src/frontend/apps/desk/src/features/members/typesSearchMembers.tsx b/src/frontend/apps/desk/src/features/members/typesSearchMembers.tsx
new file mode 100644
index 0000000..15da829
--- /dev/null
+++ b/src/frontend/apps/desk/src/features/members/typesSearchMembers.tsx
@@ -0,0 +1,26 @@
+import { User } from '@/features/auth';
+
+export enum OptionType {
+ INVITATION = 'invitation',
+ NEW_MEMBER = 'new_member',
+}
+
+export const isOptionNewMember = (
+ data: OptionSelect,
+): data is OptionNewMember => {
+ return 'id' in data.value;
+};
+
+export interface OptionInvitation {
+ value: { email: string };
+ label: string;
+ type: OptionType.INVITATION;
+}
+
+export interface OptionNewMember {
+ value: User;
+ label: string;
+ type: OptionType.NEW_MEMBER;
+}
+
+export type OptionSelect = OptionNewMember | OptionInvitation;
diff --git a/src/frontend/apps/desk/src/i18n/translations.json b/src/frontend/apps/desk/src/i18n/translations.json
index cbd421a..b75a5a9 100644
--- a/src/frontend/apps/desk/src/i18n/translations.json
+++ b/src/frontend/apps/desk/src/i18n/translations.json
@@ -5,6 +5,7 @@
"404 - Page not found": "404 - Page introuvable",
"Access to the cells menu": "Accès au menu cellules",
"Add": "Ajouter",
+ "Add a member": "Ajouter un membre",
"Add a team": "Ajouter un groupe",
"Add members to the team": "Ajoutez des membres à votre groupe",
"Add people to the “{{teamName}}“ group.": "Ajouter des personnes au groupe “{{teamName}}“.",
@@ -27,6 +28,7 @@
"Empty teams icon": "Icône de groupe vide",
"Enter the new name of the selected team": "Entrez le nouveau nom du groupe sélectionné",
"Enter the new team name": "Entrez le nouveau nom de groupe",
+ "Failed to add {{name}} in the team": "Échec de l'ajout de {{name}} dans le groupe",
"Failed to create the invitation for {{email}}": "Échec de la création de l'invitation pour {{email}}",
"Favorite": "Favoris",
"Find a member to add to the team": "Trouver un membre à ajouter au groupe",
@@ -41,6 +43,7 @@
"Marianne Logo": "Logo Marianne",
"Member": "Membre",
"Member icon": "Icône de membre",
+ "Member {{name}} added to the team": "Membre {{name}} ajouté au groupe",
"Members of “{{teamName}}“": "Membres de “{{teamName}}“",
"Name the team": "Nommer le groupe",
"Names": "Noms",
diff --git a/src/frontend/apps/e2e/__tests__/app-desk/member-create.spec.ts b/src/frontend/apps/e2e/__tests__/app-desk/member-create.spec.ts
index d992db1..f422af3 100644
--- a/src/frontend/apps/e2e/__tests__/app-desk/member-create.spec.ts
+++ b/src/frontend/apps/e2e/__tests__/app-desk/member-create.spec.ts
@@ -25,7 +25,7 @@ test.describe('Members Create', () => {
await expect(page.getByRole('button', { name: 'Cancel' })).toBeVisible();
});
- test('it selects 2 users', async ({ page, browserName }) => {
+ test('it selects 2 users and 1 invitation', async ({ page, browserName }) => {
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes('/users/?q=test') && response.status() === 200,
@@ -38,53 +38,192 @@ test.describe('Members Create', () => {
const inputSearch = page.getByLabel(/Find a member to add to the team/);
- for (let i = 0; i < 2; i++) {
- await inputSearch.fill('test');
+ // Select user 1
+ await inputSearch.fill('test');
- const response = await responsePromise;
- const users = (await response.json()).results as {
- name: string;
- }[];
+ const response = await responsePromise;
+ const users = (await response.json()).results as {
+ name: string;
+ }[];
- await page.getByText(users[i].name).click();
+ await page.getByRole('option', { name: users[0].name }).click();
- await expect(
- page.getByText(`${users[i].name}`, { exact: true }),
- ).toBeVisible();
- await expect(page.getByLabel(`Remove ${users[i].name}`)).toBeVisible();
- }
+ // Select user 2
+ await inputSearch.fill('test1');
+ await page.getByRole('option', { name: users[1].name }).click();
+ // Select email
+ const email = randomName('test@test.fr', browserName, 1)[0];
+ await inputSearch.fill(email);
+ await page.getByRole('option', { name: email }).click();
+
+ // Check user 1 tag
+ await expect(
+ page.getByText(`${users[0].name}`, { exact: true }),
+ ).toBeVisible();
+ await expect(page.getByLabel(`Remove ${users[0].name}`)).toBeVisible();
+
+ // Check user 2 tag
+ await expect(
+ page.getByText(`${users[1].name}`, { exact: true }),
+ ).toBeVisible();
+ await expect(page.getByLabel(`Remove ${users[1].name}`)).toBeVisible();
+
+ // Check invitation tag
+ await expect(page.getByText(email, { exact: true })).toBeVisible();
+ await expect(page.getByLabel(`Remove ${email}`)).toBeVisible();
+
+ // Check roles are displayed
await expect(page.getByText(/Choose a role/)).toBeVisible();
await expect(page.getByRole('radio', { name: 'Member' })).toBeChecked();
await expect(page.getByRole('radio', { name: 'Owner' })).toBeVisible();
await expect(page.getByRole('radio', { name: 'Admin' })).toBeVisible();
});
- test('it sends an invitation', async ({ page, browserName }) => {
+ test('it sends a new invitation and adds a new member', async ({
+ page,
+ browserName,
+ }) => {
+ const responsePromiseSearchUser = page.waitForResponse(
+ (response) =>
+ response.url().includes('/users/?q=test') && response.status() === 200,
+ );
+
await createTeam(page, 'member-invitation', browserName, 1);
await page.getByLabel('Add members to the team').click();
+ // Select a new email
+ const inputSearch = page.getByLabel(/Find a member to add to the team/);
+
+ const email = randomName('test@test.fr', browserName, 1)[0];
+ await inputSearch.fill(email);
+ await page.getByRole('option', { name: email }).click();
+
+ // Select a new user
+ await inputSearch.fill('test');
+ const responseSearchUser = await responsePromiseSearchUser;
+ const users = (await responseSearchUser.json()).results as {
+ name: string;
+ }[];
+ await page.getByRole('option', { name: users[0].name }).click();
+
+ // Choose a role
+ await page.getByRole('radio', { name: 'Admin' }).click();
+
+ const responsePromiseCreateInvitation = page.waitForResponse(
+ (response) =>
+ response.url().includes('/invitations/') && response.status() === 201,
+ );
+ const responsePromiseAddMember = page.waitForResponse(
+ (response) =>
+ response.url().includes('/accesses/') && response.status() === 201,
+ );
+
+ await page.getByRole('button', { name: 'Validate' }).click();
+
+ // Check invitation sent
+ await expect(page.getByText(`Invitation sent to ${email}`)).toBeVisible();
+ const responseCreateInvitation = await responsePromiseCreateInvitation;
+ expect(responseCreateInvitation.ok()).toBeTruthy();
+
+ // Check member added
+ await expect(
+ page.getByText(`Member ${users[0].name} added to the team`),
+ ).toBeVisible();
+ const responseAddMember = await responsePromiseAddMember;
+ expect(responseAddMember.ok()).toBeTruthy();
+
+ const table = page.getByLabel('List members card').getByRole('table');
+ await expect(table.getByText(users[0].name)).toBeVisible();
+ await expect(table.getByText('Admin')).toBeVisible();
+ });
+
+ test('it try to add twice the same user', async ({ page, browserName }) => {
+ const responsePromiseSearchUser = page.waitForResponse(
+ (response) =>
+ response.url().includes('/users/?q=test') && response.status() === 200,
+ );
+
+ await createTeam(page, 'member-twice', browserName, 1);
+
+ await page.getByLabel('Add members to the team').click();
+
+ const inputSearch = page.getByLabel(/Find a member to add to the team/);
+ await inputSearch.fill('test');
+ const responseSearchUser = await responsePromiseSearchUser;
+ const users = (await responseSearchUser.json()).results as {
+ name: string;
+ }[];
+ await page.getByRole('option', { name: users[0].name }).click();
+
+ // Choose a role
+ await page.getByRole('radio', { name: 'Owner' }).click();
+
+ const responsePromiseAddMember = page.waitForResponse(
+ (response) =>
+ response.url().includes('/accesses/') && response.status() === 201,
+ );
+
+ await page.getByRole('button', { name: 'Validate' }).click();
+
+ await expect(
+ page.getByText(`Member ${users[0].name} added to the team`),
+ ).toBeVisible();
+ const responseAddMember = await responsePromiseAddMember;
+ expect(responseAddMember.ok()).toBeTruthy();
+
+ await page.getByLabel('Add members to the team').click();
+
+ await inputSearch.fill('test');
+ await page.getByRole('option', { name: users[0].name }).click();
+ await page.getByRole('radio', { name: 'Owner' }).click();
+
+ await page.getByRole('button', { name: 'Validate' }).click();
+
+ await expect(
+ page.getByText(`Failed to add ${users[0].name} in the team`),
+ ).toBeVisible();
+ });
+
+ test('it try to add twice the same invitation', async ({
+ page,
+ browserName,
+ }) => {
+ await createTeam(page, 'invitation-twice', browserName, 1);
+
+ await page.getByLabel('Add members to the team').click();
+
const inputSearch = page.getByLabel(/Find a member to add to the team/);
const email = randomName('test@test.fr', browserName, 1)[0];
await inputSearch.fill(email);
await page.getByRole('option', { name: email }).click();
- await expect(page.getByText(email, { exact: true })).toBeVisible();
- await expect(page.getByLabel(`Remove ${email}`)).toBeVisible();
-
+ // Choose a role
await page.getByRole('radio', { name: 'Owner' }).click();
- const responsePromise = page.waitForResponse(
+ const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
response.url().includes('/invitations/') && response.status() === 201,
);
await page.getByRole('button', { name: 'Validate' }).click();
- await expect(page.getByText(`Invitation sent to ${email}`)).toBeVisible();
- const response = await responsePromise;
- expect(response.ok()).toBeTruthy();
+ // Check invitation sent
+ await expect(page.getByText(`Invitation sent to ${email}`)).toBeVisible();
+ const responseCreateInvitation = await responsePromiseCreateInvitation;
+ expect(responseCreateInvitation.ok()).toBeTruthy();
+
+ await page.getByLabel('Add members to the team').click();
+
+ await inputSearch.fill(email);
+ await page.getByRole('option', { name: email }).click();
+
+ await page.getByRole('button', { name: 'Validate' }).click();
+
+ await expect(
+ page.getByText(`Failed to create the invitation for ${email}`),
+ ).toBeVisible();
});
});