From 197b16c5d09857229b76975476eb34c1e7e98934 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Fri, 31 May 2024 17:16:31 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20add=20modal=20to=20delete?= =?UTF-8?q?=20a=20member?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add modal to delete a member. --- .../apps/e2e/__tests__/app-impress/common.ts | 22 +- .../app-impress/pad-member-delete.spec.ts | 199 ++++++++++++++++++ .../members-grid/api/useDeleteDocAccess.ts | 64 ++++++ .../assets/icon-remove-member.svg | 8 + .../members-grid/components/MemberAction.tsx | 2 +- .../members-grid/components/ModalDelete.tsx | 135 ++++++++++++ 6 files changed, 417 insertions(+), 13 deletions(-) create mode 100644 src/frontend/apps/e2e/__tests__/app-impress/pad-member-delete.spec.ts create mode 100644 src/frontend/apps/impress/src/features/pads/members/members-grid/api/useDeleteDocAccess.ts create mode 100644 src/frontend/apps/impress/src/features/pads/members/members-grid/assets/icon-remove-member.svg create mode 100644 src/frontend/apps/impress/src/features/pads/members/members-grid/components/ModalDelete.tsx diff --git a/src/frontend/apps/e2e/__tests__/app-impress/common.ts b/src/frontend/apps/e2e/__tests__/app-impress/common.ts index 2ba0e5be..f3290bff 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/common.ts @@ -86,15 +86,18 @@ export const addNewMember = async ( page: Page, index: number, role: 'Admin' | 'Owner' | 'Member', - fillText: string = 'test', + fillText: string = 'user', ) => { const responsePromiseSearchUser = page.waitForResponse( (response) => response.url().includes(`/users/?q=${fillText}`) && response.status() === 200, ); - await page.getByLabel('Add members to the team').click(); - const inputSearch = page.getByLabel(/Find a member to add to the team/); + + await page.getByLabel('Open the document options').click(); + await page.getByRole('button', { name: 'Add members' }).click(); + + const inputSearch = page.getByLabel(/Find a member to add to the document/); // Select a new user await inputSearch.fill(fillText); @@ -102,23 +105,18 @@ export const addNewMember = async ( // Intercept response const responseSearchUser = await responsePromiseSearchUser; const users = (await responseSearchUser.json()).results as { - name: string; + email: string; }[]; // Choose user - await page.getByRole('option', { name: users[index].name }).click(); + await page.getByRole('option', { name: users[index].email }).click(); // Choose a role await page.getByRole('radio', { name: role }).click(); await page.getByRole('button', { name: 'Validate' }).click(); - const table = page.getByLabel('List members card').getByRole('table'); + await expect(page.getByText(`User added to the document.`)).toBeVisible(); - await expect(table.getByText(users[index].name)).toBeVisible(); - await expect( - page.getByText(`Member ${users[index].name} added to the team`), - ).toBeVisible(); - - return users[index].name; + return users[index].email; }; diff --git a/src/frontend/apps/e2e/__tests__/app-impress/pad-member-delete.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/pad-member-delete.spec.ts new file mode 100644 index 00000000..18f66b30 --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-impress/pad-member-delete.spec.ts @@ -0,0 +1,199 @@ +import { expect, test } from '@playwright/test'; + +import { addNewMember, createPad, keyCloakSignIn } from './common'; + +test.beforeEach(async ({ page, browserName }) => { + await page.goto('/'); + await keyCloakSignIn(page, browserName); +}); + +test.describe('Members Delete', () => { + test('it cannot delete himself when it is the last owner', async ({ + page, + browserName, + }) => { + await createPad(page, 'member-delete-1', browserName, 1); + + await page.getByLabel('Open the document options').click(); + await page.getByRole('button', { name: 'Manage members' }).click(); + + const table = page.getByLabel('List members card').getByRole('table'); + + const cells = table.getByRole('row').nth(1).getByRole('cell'); + await expect(cells.nth(0)).toHaveText( + new RegExp(`user@${browserName}.e2e`, 'i'), + ); + await cells.nth(2).getByLabel('Member options').click(); + await page.getByLabel('Open the modal to delete this member').click(); + + await expect( + page.getByText( + 'You are the last owner, you cannot be removed from your document.', + ), + ).toBeVisible(); + await expect(page.getByRole('button', { name: 'Validate' })).toBeDisabled(); + }); + + test('it deletes himself when it is not the last owner', async ({ + page, + browserName, + }) => { + await createPad(page, 'member-delete-2', browserName, 1); + + await addNewMember(page, 0, 'Owner'); + + await page.getByLabel('Open the document options').click(); + await page.getByRole('button', { name: 'Manage members' }).click(); + + const table = page.getByLabel('List members card').getByRole('table'); + + // find row where regexp match the name + const cells = table + .getByRole('row') + .filter({ hasText: new RegExp(`user@${browserName}.e2e`, 'i') }) + .getByRole('cell'); + await cells.nth(2).getByLabel('Member options').click(); + await page.getByLabel('Open the modal to delete this member').click(); + + await page.getByRole('button', { name: 'Validate' }).click(); + await expect( + page.getByText(`The member has been removed from the document`), + ).toBeVisible(); + await expect( + page.getByRole('button', { name: `Create a new document` }), + ).toBeVisible(); + }); + + test('it cannot delete owner member', async ({ page, browserName }) => { + await createPad(page, 'member-delete-3', browserName, 1); + + const username = await addNewMember(page, 0, 'Owner'); + + await page.getByLabel('Open the document options').click(); + await page.getByRole('button', { name: 'Manage members' }).click(); + + const table = page.getByLabel('List members card').getByRole('table'); + + // find row where regexp match the name + const cells = table + .getByRole('row') + .filter({ hasText: username }) + .getByRole('cell'); + await cells.getByLabel('Member options').click(); + await page.getByLabel('Open the modal to delete this member').click(); + + await expect( + page.getByText(`You cannot remove other owner.`), + ).toBeVisible(); + await expect(page.getByRole('button', { name: 'Validate' })).toBeDisabled(); + }); + + test('it deletes admin member', async ({ page, browserName }) => { + await createPad(page, 'member-delete-4', browserName, 1); + + const username = await addNewMember(page, 0, 'Admin'); + + await page.getByLabel('Open the document options').click(); + await page.getByRole('button', { name: 'Manage members' }).click(); + + const table = page.getByLabel('List members card').getByRole('table'); + + // find row where regexp match the name + const cells = table + .getByRole('row') + .filter({ hasText: username }) + .getByRole('cell'); + await cells.getByLabel('Member options').click(); + await page.getByLabel('Open the modal to delete this member').click(); + + await page.getByRole('button', { name: 'Validate' }).click(); + await expect( + page.getByText(`The member has been removed from the document`), + ).toBeVisible(); + await expect(table.getByText(username)).toBeHidden(); + }); + + test('it cannot delete owner member when admin', async ({ + page, + browserName, + }) => { + await createPad(page, 'member-delete-5', browserName, 1); + + const username = await addNewMember(page, 0, 'Owner'); + + await page.getByLabel('Open the document options').click(); + await page.getByRole('button', { name: 'Manage members' }).click(); + + const table = page.getByLabel('List members card').getByRole('table'); + + // find row where regexp match the name + const myCells = table + .getByRole('row') + .filter({ hasText: new RegExp(`user@${browserName}.e2e`, 'i') }) + .getByRole('cell'); + await myCells.getByLabel('Member options').click(); + + // Change role to Admin + await page.getByText('Update role').click(); + const radioGroup = page.getByLabel('Radio buttons to update the roles'); + await radioGroup.getByRole('radio', { name: 'Administrator' }).click(); + await page.getByRole('button', { name: 'Validate' }).click(); + + const cells = table + .getByRole('row') + .filter({ hasText: username }) + .getByRole('cell'); + await expect(cells.getByLabel('Member options')).toBeHidden(); + }); + + test('it deletes admin member when admin', async ({ page, browserName }) => { + await createPad(page, 'member-delete-6', browserName, 1); + + // To not be the only owner + await addNewMember(page, 0, 'Owner'); + + const username = await addNewMember(page, 1, 'Admin'); + + await expect( + page.getByText(`User added to the document.`).last(), + ).toBeHidden({ + timeout: 5000, + }); + + await page.getByLabel('Open the document options').click(); + await page.getByRole('button', { name: 'Manage members' }).click(); + + const table = page.getByLabel('List members card').getByRole('table'); + + // find row where regexp match the name + const myCells = table + .getByRole('row') + .filter({ hasText: new RegExp(`user@${browserName}.e2e`, 'i') }) + .getByRole('cell'); + await myCells.getByLabel('Member options').click(); + + // Change role to Admin + await page.getByText('Update role').click(); + const radioGroup = page.getByLabel('Radio buttons to update the roles'); + await radioGroup.getByRole('radio', { name: 'Administrator' }).click(); + await page.getByRole('button', { name: 'Validate' }).click(); + + await expect(page.getByText(`The role has been updated`)).toBeVisible(); + await expect(page.getByText(`The role has been updated`)).toBeHidden({ + timeout: 5000, + }); + + const cells = table + .getByRole('row') + .filter({ hasText: new RegExp(username, 'i') }) + .getByRole('cell'); + await cells.nth(2).getByLabel('Member options').click(); + await page.getByLabel('Open the modal to delete this member').click(); + + await page.getByRole('button', { name: 'Validate' }).click(); + await expect( + page.getByText(`The member has been removed from the document`), + ).toBeVisible(); + await expect(table.getByText(username)).toBeHidden(); + }); +}); diff --git a/src/frontend/apps/impress/src/features/pads/members/members-grid/api/useDeleteDocAccess.ts b/src/frontend/apps/impress/src/features/pads/members/members-grid/api/useDeleteDocAccess.ts new file mode 100644 index 00000000..d4767551 --- /dev/null +++ b/src/frontend/apps/impress/src/features/pads/members/members-grid/api/useDeleteDocAccess.ts @@ -0,0 +1,64 @@ +import { + UseMutationOptions, + useMutation, + useQueryClient, +} from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; +import { KEY_LIST_PAD, KEY_PAD } from '@/features/pads/pad-management'; + +import { KEY_LIST_DOC_ACCESSES } from './useDocAccesses'; + +interface DeleteDocAccessProps { + docId: string; + accessId: string; +} + +export const deleteDocAccess = async ({ + docId, + accessId, +}: DeleteDocAccessProps): Promise => { + const response = await fetchAPI(`documents/${docId}/accesses/${accessId}/`, { + method: 'DELETE', + }); + + if (!response.ok) { + throw new APIError( + 'Failed to delete the member', + await errorCauses(response), + ); + } +}; + +type UseDeleteDocAccessOptions = UseMutationOptions< + void, + APIError, + DeleteDocAccessProps +>; + +export const useDeleteDocAccess = (options?: UseDeleteDocAccessOptions) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: deleteDocAccess, + ...options, + onSuccess: (data, variables, context) => { + void queryClient.invalidateQueries({ + queryKey: [KEY_LIST_DOC_ACCESSES], + }); + void queryClient.invalidateQueries({ + queryKey: [KEY_PAD], + }); + void queryClient.invalidateQueries({ + queryKey: [KEY_LIST_PAD], + }); + if (options?.onSuccess) { + options.onSuccess(data, variables, context); + } + }, + onError: (error, variables, context) => { + if (options?.onError) { + options.onError(error, variables, context); + } + }, + }); +}; diff --git a/src/frontend/apps/impress/src/features/pads/members/members-grid/assets/icon-remove-member.svg b/src/frontend/apps/impress/src/features/pads/members/members-grid/assets/icon-remove-member.svg new file mode 100644 index 00000000..4316c35c --- /dev/null +++ b/src/frontend/apps/impress/src/features/pads/members/members-grid/assets/icon-remove-member.svg @@ -0,0 +1,8 @@ + + + diff --git a/src/frontend/apps/impress/src/features/pads/members/members-grid/components/MemberAction.tsx b/src/frontend/apps/impress/src/features/pads/members/members-grid/components/MemberAction.tsx index b63989a2..74cac1f2 100644 --- a/src/frontend/apps/impress/src/features/pads/members/members-grid/components/MemberAction.tsx +++ b/src/frontend/apps/impress/src/features/pads/members/members-grid/components/MemberAction.tsx @@ -65,7 +65,7 @@ export const MemberAction = ({ color="primary-text" icon={delete} > - {t('Remove from group')} + {t('Remove from group')} diff --git a/src/frontend/apps/impress/src/features/pads/members/members-grid/components/ModalDelete.tsx b/src/frontend/apps/impress/src/features/pads/members/members-grid/components/ModalDelete.tsx new file mode 100644 index 00000000..0ad9bb76 --- /dev/null +++ b/src/frontend/apps/impress/src/features/pads/members/members-grid/components/ModalDelete.tsx @@ -0,0 +1,135 @@ +import { + Button, + Modal, + ModalSize, + VariantType, + useToastProvider, +} from '@openfun/cunningham-react'; +import { t } from 'i18next'; +import { useRouter } from 'next/navigation'; + +import IconUser from '@/assets/icons/icon-user.svg'; +import { Box, Text, TextErrors } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; +import { Access, Pad, Role } from '@/features/pads/pad-management'; + +import { useDeleteDocAccess } from '../api'; +import IconRemoveMember from '../assets/icon-remove-member.svg'; +import { useWhoAmI } from '../hooks/useWhoAmI'; + +interface ModalDeleteProps { + access: Access; + currentRole: Role; + onClose: () => void; + doc: Pad; +} + +export const ModalDelete = ({ access, onClose, doc }: ModalDeleteProps) => { + const { toast } = useToastProvider(); + const { colorsTokens } = useCunninghamTheme(); + const router = useRouter(); + + const { isMyself, isLastOwner, isOtherOwner } = useWhoAmI(access); + const isNotAllowed = isOtherOwner || isLastOwner; + + const { + mutate: removeDocAccess, + error: errorUpdate, + isError: isErrorUpdate, + } = useDeleteDocAccess({ + onSuccess: () => { + toast( + t('The member has been removed from the document'), + VariantType.SUCCESS, + { + duration: 4000, + }, + ); + + // If we remove ourselves, we redirect to the home page + // because we are no longer part of the team + isMyself ? router.push('/') : onClose(); + }, + }); + + return ( + onClose()}> + {t('Cancel')} + + } + onClose={onClose} + rightActions={ + + } + size={ModalSize.MEDIUM} + title={ + + + + {t('Remove the member')} + + + } + > + + + {t('Are you sure you want to remove this member from the document?')} + + + {isErrorUpdate && ( + + )} + + {(isLastOwner || isOtherOwner) && ( + + warning + {isLastOwner && + t( + 'You are the last owner, you cannot be removed from your document.', + )} + {isOtherOwner && t('You cannot remove other owner.')} + + )} + + + + {access.user.email} + + + + ); +};