From 724bbe550c3b4752f062332882986ee520a6be2e Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Mon, 25 Mar 2024 11:15:28 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(app-desk)=20modal=20delete=20member?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We can now delete a member from a team. We take care of usecases like: - it is the last owner of the team (cannot delete) - other owner of the team (cannot delete) - role hierarchy --- .../members/assets/icon-remove-member.svg | 8 + .../members/components/MemberAction.tsx | 54 ++++-- .../members/components/MemberGrid.tsx | 2 +- .../members/components/ModalDelete.tsx | 144 +++++++++++++++ .../__tests__/app-desk/member-delete.spec.ts | 170 ++++++++++++++++++ 5 files changed, 362 insertions(+), 16 deletions(-) create mode 100644 src/frontend/apps/desk/src/features/members/assets/icon-remove-member.svg create mode 100644 src/frontend/apps/desk/src/features/members/components/ModalDelete.tsx create mode 100644 src/frontend/apps/e2e/__tests__/app-desk/member-delete.spec.ts diff --git a/src/frontend/apps/desk/src/features/members/assets/icon-remove-member.svg b/src/frontend/apps/desk/src/features/members/assets/icon-remove-member.svg new file mode 100644 index 0000000..4316c35 --- /dev/null +++ b/src/frontend/apps/desk/src/features/members/assets/icon-remove-member.svg @@ -0,0 +1,8 @@ + + + diff --git a/src/frontend/apps/desk/src/features/members/components/MemberAction.tsx b/src/frontend/apps/desk/src/features/members/components/MemberAction.tsx index fc35c28..6a06da5 100644 --- a/src/frontend/apps/desk/src/features/members/components/MemberAction.tsx +++ b/src/frontend/apps/desk/src/features/members/components/MemberAction.tsx @@ -2,26 +2,28 @@ import { Button } from '@openfun/cunningham-react'; import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { DropButton, IconOptions, Text } from '@/components'; -import { Role } from '@/features/teams'; +import { Box, DropButton, IconOptions, Text } from '@/components'; +import { Role, Team } from '@/features/teams'; import { Access } from '../types'; +import { ModalDelete } from './ModalDelete'; import { ModalRole } from './ModalRole'; interface MemberActionProps { access: Access; currentRole: Role; - teamId: string; + team: Team; } export const MemberAction = ({ access, currentRole, - teamId, + team, }: MemberActionProps) => { const { t } = useTranslation(); const [isModalRoleOpen, setIsModalRoleOpen] = useState(false); + const [isModalDeleteOpen, setIsModalDeleteOpen] = useState(false); const [isDropOpen, setIsDropOpen] = useState(false); if ( @@ -43,23 +45,45 @@ export const MemberAction = ({ onOpenChange={(isOpen) => setIsDropOpen(isOpen)} isOpen={isDropOpen} > - + + + + {isModalRoleOpen && ( setIsModalRoleOpen(false)} - teamId={teamId} + teamId={team.id} + /> + )} + {isModalDeleteOpen && ( + setIsModalDeleteOpen(false)} + team={team} /> )} diff --git a/src/frontend/apps/desk/src/features/members/components/MemberGrid.tsx b/src/frontend/apps/desk/src/features/members/components/MemberGrid.tsx index bbf0f8f..ed720c7 100644 --- a/src/frontend/apps/desk/src/features/members/components/MemberGrid.tsx +++ b/src/frontend/apps/desk/src/features/members/components/MemberGrid.tsx @@ -162,7 +162,7 @@ export const MemberGrid = ({ team, currentRole }: MemberGridProps) => { renderCell: ({ row }) => { return ( diff --git a/src/frontend/apps/desk/src/features/members/components/ModalDelete.tsx b/src/frontend/apps/desk/src/features/members/components/ModalDelete.tsx new file mode 100644 index 0000000..c058c14 --- /dev/null +++ b/src/frontend/apps/desk/src/features/members/components/ModalDelete.tsx @@ -0,0 +1,144 @@ +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 { useAuthStore } from '@/features/auth'; +import { Role, Team } from '@/features/teams/'; + +import { useDeleteTeamAccess } from '../api/useDeleteTeamAccess'; +import IconRemoveMember from '../assets/icon-remove-member.svg'; +import { Access } from '../types'; + +interface ModalDeleteProps { + access: Access; + currentRole: Role; + onClose: () => void; + team: Team; +} + +export const ModalDelete = ({ access, onClose, team }: ModalDeleteProps) => { + const { userData } = useAuthStore(); + const { toast } = useToastProvider(); + const { colorsTokens } = useCunninghamTheme(); + const router = useRouter(); + + const isMyself = userData?.id === access.user.id; + + const { + mutate: removeTeamAccess, + error: errorUpdate, + isError: isErrorUpdate, + } = useDeleteTeamAccess({ + onSuccess: () => { + toast( + t('The member has been removed from the team'), + 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(); + }, + }); + + const rolesAllowed = access.abilities.set_role_to; + const isLastOwner = + !rolesAllowed.length && access.role === Role.OWNER && isMyself; + + const isOtherOwner = access.role === Role.OWNER && userData?.id && !isMyself; + + const isNotAllowed = isOtherOwner || isLastOwner; + + 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 {{team}} group?', + { team: team.name }, + )} + + + {isErrorUpdate && ( + + )} + + {(isLastOwner || isOtherOwner) && ( + + warning + {isLastOwner && + t( + 'You are the last owner, you cannot be removed from your team.', + )} + {isOtherOwner && t('You cannot remove other owner.')} + + )} + + + + {access.user.name} + + + + ); +}; diff --git a/src/frontend/apps/e2e/__tests__/app-desk/member-delete.spec.ts b/src/frontend/apps/e2e/__tests__/app-desk/member-delete.spec.ts new file mode 100644 index 0000000..b1ffb11 --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-desk/member-delete.spec.ts @@ -0,0 +1,170 @@ +import { expect, test } from '@playwright/test'; + +import { addNewMember, createTeam, 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 createTeam(page, 'member-delete-1', browserName, 1); + + const table = page.getByLabel('List members card').getByRole('table'); + + const cells = table.getByRole('row').nth(1).getByRole('cell'); + await expect(cells.nth(1)).toHaveText( + new RegExp(`E2E ${browserName}`, 'i'), + ); + await cells.nth(4).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 team.', + ), + ).toBeVisible(); + await expect(page.getByRole('button', { name: 'Validate' })).toBeDisabled(); + }); + + test('it deletes himself when it is not the last owner', async ({ + page, + browserName, + }) => { + await createTeam(page, 'member-delete-1', browserName, 1); + + await addNewMember(page, 0, 'Owner'); + + 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(`E2E ${browserName}`, 'i') }) + .getByRole('cell'); + await cells.nth(4).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 team`), + ).toBeVisible(); + await expect( + page.getByRole('button', { name: `Create a new team` }), + ).toBeVisible(); + }); + + test('it cannot delete owner member', async ({ page, browserName }) => { + await createTeam(page, 'member-delete-1', browserName, 1); + + const username = await addNewMember(page, 0, 'Owner'); + + 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.nth(4).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 createTeam(page, 'member-delete-1', browserName, 1); + + const username = await addNewMember(page, 0, 'Admin'); + + 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.nth(4).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 team`), + ).toBeVisible(); + await expect(table.getByText(username)).toBeHidden(); + }); + + test('it cannot delete owner member when admin', async ({ + page, + browserName, + }) => { + await createTeam(page, 'member-delete-1', browserName, 1); + + const username = await addNewMember(page, 0, 'Owner'); + + 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(`E2E ${browserName}`, 'i') }) + .getByRole('cell'); + await myCells.nth(4).getByLabel('Member options').click(); + + // Change role to Admin + await page.getByText('Update the role').click(); + const radioGroup = page.getByLabel('Radio buttons to update the roles'); + await radioGroup.getByRole('radio', { name: 'Admin' }).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 createTeam(page, 'member-delete-1', browserName, 1); + + // To not be the only owner + await addNewMember(page, 0, 'Owner'); + + const username = await addNewMember(page, 1, 'Admin', 'something'); + + 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(`E2E ${browserName}`, 'i') }) + .getByRole('cell'); + await myCells.nth(4).getByLabel('Member options').click(); + + // Change role to Admin + await page.getByText('Update the role').click(); + const radioGroup = page.getByLabel('Radio buttons to update the roles'); + await radioGroup.getByRole('radio', { name: 'Admin' }).click(); + await page.getByRole('button', { name: 'Validate' }).click(); + + const cells = table + .getByRole('row') + .filter({ hasText: username }) + .getByRole('cell'); + await cells.nth(4).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 team`), + ).toBeVisible(); + await expect(table.getByText(username)).toBeHidden(); + }); +});