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(); }); });