From e15c7cb2f4d2509b39d192e0aab5cecef0076723 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Fri, 8 Mar 2024 15:18:19 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(app-desk)=20integrate=20modal=20to=20?= =?UTF-8?q?update=20roles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrate the design and functionality for updating a member's role. Managed use cases: - when the user is an admin - when the user is the last owner - when the user want to update other orner --- .../desk/src/cunningham/cunningham-style.css | 7 + .../teams/__tests__/ModalRole.test.tsx | 286 ++++++++++++++++++ .../teams/components/Member/MemberAction.tsx | 12 + .../teams/components/Member/ModalRole.tsx | 137 +++++++++ .../apps/desk/src/i18n/translations.json | 6 + .../apps/e2e/__tests__/app-desk/team.spec.ts | 37 ++- 6 files changed, 483 insertions(+), 2 deletions(-) create mode 100644 src/frontend/apps/desk/src/features/teams/__tests__/ModalRole.test.tsx create mode 100644 src/frontend/apps/desk/src/features/teams/components/Member/ModalRole.tsx diff --git a/src/frontend/apps/desk/src/cunningham/cunningham-style.css b/src/frontend/apps/desk/src/cunningham/cunningham-style.css index 6793987..83a5e89 100644 --- a/src/frontend/apps/desk/src/cunningham/cunningham-style.css +++ b/src/frontend/apps/desk/src/cunningham/cunningham-style.css @@ -434,3 +434,10 @@ input:-webkit-autofill:focus { --c--components--button--danger--background--color-disabled ); } + +/** + * Modal +*/ +.c__modal__backdrop { + z-index: 1000; +} diff --git a/src/frontend/apps/desk/src/features/teams/__tests__/ModalRole.test.tsx b/src/frontend/apps/desk/src/features/teams/__tests__/ModalRole.test.tsx new file mode 100644 index 0000000..d9efaf7 --- /dev/null +++ b/src/frontend/apps/desk/src/features/teams/__tests__/ModalRole.test.tsx @@ -0,0 +1,286 @@ +import '@testing-library/jest-dom'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import fetchMock from 'fetch-mock'; + +import { useAuthStore } from '@/features/auth'; +import { AppWrapper } from '@/tests/utils'; + +import { Access, Role } from '../api'; +import { ModalRole } from '../components/Member/ModalRole'; + +const toast = jest.fn(); +jest.mock('@openfun/cunningham-react', () => ({ + ...jest.requireActual('@openfun/cunningham-react'), + useToastProvider: () => ({ + toast, + }), +})); + +HTMLDialogElement.prototype.showModal = jest.fn(function mock( + this: HTMLDialogElement, +) { + this.open = true; +}); + +const access: Access = { + id: '789', + role: Role.ADMIN, + user: { + id: '11', + name: 'username1', + email: 'user1@test.com', + }, + abilities: { + set_role_to: [Role.MEMBER, Role.ADMIN], + } as any, +}; + +describe('ModalRole', () => { + afterEach(() => { + fetchMock.restore(); + }); + + it('checks the cancel button', async () => { + const onClose = jest.fn(); + render( + , + { + wrapper: AppWrapper, + }, + ); + + await userEvent.click( + screen.getByRole('button', { + name: 'Cancel', + }), + ); + + expect(onClose).toHaveBeenCalled(); + }); + + it('updates the role successfully', async () => { + fetchMock.patchOnce(`/api/teams/123/accesses/789/`, { + status: 200, + ok: true, + }); + + const onClose = jest.fn(); + render( + , + { wrapper: AppWrapper }, + ); + + expect( + screen.getByRole('radio', { + name: 'Admin', + }), + ).toBeChecked(); + + await userEvent.click( + screen.getByRole('radio', { + name: 'Member', + }), + ); + + await userEvent.click( + screen.getByRole('button', { + name: 'Validate', + }), + ); + + await waitFor(() => { + expect(toast).toHaveBeenCalledWith( + 'The role has been updated', + 'success', + { + duration: 4000, + }, + ); + }); + + expect(fetchMock.lastUrl()).toBe(`/api/teams/123/accesses/789/`); + + expect(onClose).toHaveBeenCalled(); + }); + + it('fails to update the role', async () => { + fetchMock.patchOnce(`/api/teams/123/accesses/789/`, { + status: 500, + body: { + detail: 'The server is totally broken', + }, + }); + + render( + , + { wrapper: AppWrapper }, + ); + + await userEvent.click( + screen.getByRole('radio', { + name: 'Member', + }), + ); + + await userEvent.click( + screen.getByRole('button', { + name: 'Validate', + }), + ); + + expect( + await screen.findByText('The server is totally broken'), + ).toBeInTheDocument(); + }); + + it('checks the render when last owner', () => { + useAuthStore.setState({ + userData: access.user, + }); + + const access2: Access = { + ...access, + role: Role.OWNER, + abilities: { + set_role_to: [], + } as any, + }; + + render( + , + { wrapper: AppWrapper }, + ); + + expect( + screen.getByText('You are the last owner, you cannot change your role.'), + ).toBeInTheDocument(); + + expect( + screen.getByRole('radio', { + name: 'Admin', + }), + ).toBeDisabled(); + + expect( + screen.getByRole('radio', { + name: 'Owner', + }), + ).toBeDisabled(); + + expect( + screen.getByRole('radio', { + name: 'Member', + }), + ).toBeDisabled(); + + expect( + screen.getByRole('button', { + name: 'Validate', + }), + ).toBeDisabled(); + }); + + it('checks the render when it is another owner', () => { + useAuthStore.setState({ + userData: { + id: '12', + name: 'username2', + email: 'username2@test.com', + }, + }); + + const access2: Access = { + ...access, + role: Role.OWNER, + }; + + render( + , + { wrapper: AppWrapper }, + ); + + expect( + screen.getByText('You cannot update the role of other owner.'), + ).toBeInTheDocument(); + + expect( + screen.getByRole('radio', { + name: 'Admin', + }), + ).toBeDisabled(); + + expect( + screen.getByRole('radio', { + name: 'Owner', + }), + ).toBeDisabled(); + + expect( + screen.getByRole('radio', { + name: 'Member', + }), + ).toBeDisabled(); + + expect( + screen.getByRole('button', { + name: 'Validate', + }), + ).toBeDisabled(); + }); + + it('checks the render when current user is admin', () => { + render( + , + { wrapper: AppWrapper }, + ); + + expect( + screen.getByRole('radio', { + name: 'Member', + }), + ).toBeEnabled(); + + expect( + screen.getByRole('radio', { + name: 'Admin', + }), + ).toBeEnabled(); + + expect( + screen.getByRole('radio', { + name: 'Owner', + }), + ).toBeDisabled(); + }); +}); diff --git a/src/frontend/apps/desk/src/features/teams/components/Member/MemberAction.tsx b/src/frontend/apps/desk/src/features/teams/components/Member/MemberAction.tsx index cc4e775..d70c7be 100644 --- a/src/frontend/apps/desk/src/features/teams/components/Member/MemberAction.tsx +++ b/src/frontend/apps/desk/src/features/teams/components/Member/MemberAction.tsx @@ -5,6 +5,8 @@ import { useTranslation } from 'react-i18next'; import { DropButton, Text } from '@/components'; import { Access, Role } from '@/features/teams/api'; +import { ModalRole } from './ModalRole'; + interface MemberActionProps { access: Access; currentRole: Role; @@ -17,6 +19,7 @@ export const MemberAction = ({ teamId, }: MemberActionProps) => { const { t } = useTranslation(); + const [isModalRoleOpen, setIsModalRoleOpen] = useState(false); const [isDropOpen, setIsDropOpen] = useState(false); if ( @@ -46,6 +49,7 @@ export const MemberAction = ({ > + {isModalRoleOpen && ( + setIsModalRoleOpen(false)} + teamId={teamId} + /> + )} ); }; diff --git a/src/frontend/apps/desk/src/features/teams/components/Member/ModalRole.tsx b/src/frontend/apps/desk/src/features/teams/components/Member/ModalRole.tsx new file mode 100644 index 0000000..117311c --- /dev/null +++ b/src/frontend/apps/desk/src/features/teams/components/Member/ModalRole.tsx @@ -0,0 +1,137 @@ +import { + Button, + Modal, + ModalSize, + Radio, + RadioGroup, + VariantType, + useToastProvider, +} from '@openfun/cunningham-react'; +import { t } from 'i18next'; +import { useState } from 'react'; + +import { Box, Text, TextErrors } from '@/components'; +import { useAuthStore } from '@/features/auth'; +import { Access, Role, useUpdateTeamAccess } from '@/features/teams/api/'; + +interface ModalRoleProps { + access: Access; + currentRole: Role; + onClose: () => void; + teamId: string; +} + +export const ModalRole = ({ + access, + currentRole, + onClose, + teamId, +}: ModalRoleProps) => { + const [localRole, setLocalRole] = useState(access.role); + const { userData } = useAuthStore(); + const { toast } = useToastProvider(); + const { + mutate: updateTeamAccess, + error: errorUpdate, + isError: isErrorUpdate, + } = useUpdateTeamAccess({ + onSuccess: () => { + toast(t('The role has been updated'), VariantType.SUCCESS, { + duration: 4000, + }); + onClose(); + }, + }); + + const rolesAllowed = access.abilities.set_role_to; + const isLastOwner = + !rolesAllowed.length && + access.role === Role.OWNER && + userData?.id === access.user.id; + + const isOtherOwner = + access.role === Role.OWNER && + userData?.id && + userData.id !== access.user.id; + + const isNotAllowed = isOtherOwner || isLastOwner; + + return ( + onClose()}> + {t('Cancel')} + + } + onClose={() => onClose()} + rightActions={ + + } + size={ModalSize.MEDIUM} + title={t('Update the role')} + > + + {isErrorUpdate && ( + + )} + + {(isLastOwner || isOtherOwner) && ( + + warning + {isLastOwner && + t('You are the last owner, you cannot change your role.')} + {isOtherOwner && t('You cannot update the role of other owner.')} + + )} + + + setLocalRole(evt.target.value as Role)} + defaultChecked={access.role === Role.ADMIN} + disabled={isNotAllowed} + /> + setLocalRole(evt.target.value as Role)} + defaultChecked={access.role === Role.MEMBER} + disabled={isNotAllowed} + /> + setLocalRole(evt.target.value as Role)} + defaultChecked={access.role === Role.OWNER} + disabled={isNotAllowed || currentRole !== Role.OWNER} + /> + + + + ); +}; diff --git a/src/frontend/apps/desk/src/i18n/translations.json b/src/frontend/apps/desk/src/i18n/translations.json index b88aad1..3517d67 100644 --- a/src/frontend/apps/desk/src/i18n/translations.json +++ b/src/frontend/apps/desk/src/i18n/translations.json @@ -29,6 +29,12 @@ "Emails": "Emails", "Roles": "Rôles", "Member options": "Options des Membres", + "Radio buttons to update the roles": "Boutons radio pour mettre à jour les rôles", + "The role has been updated": "Le rôle a bien été mis à jour", + "Update the role": "Mettre à jour ce rôle", + "Validate": "Valider", + "You are the last owner, you cannot change your role.": "Vous êtes le dernier propriétaire, vous ne pouvez pas changer votre rôle.", + "You cannot update the role of other owner.": "Vous ne pouvez pas mettre à jour les rôles d'autre propriétaire.", "Sort the teams": "Trier les groupes", "Sort teams icon": "Icône trier les groupes", "Add a team": "Ajouter un groupe", diff --git a/src/frontend/apps/e2e/__tests__/app-desk/team.spec.ts b/src/frontend/apps/e2e/__tests__/app-desk/team.spec.ts index 11c18cc..f86da6f 100644 --- a/src/frontend/apps/e2e/__tests__/app-desk/team.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-desk/team.spec.ts @@ -41,11 +41,11 @@ test.describe('Team', () => { ).toBeVisible(); }); - test('checks the admin members is displayed correctly', async ({ + test('checks the owner member is displayed correctly', async ({ page, browserName, }) => { - await createTeam(page, 'team-admin', browserName, 1); + await createTeam(page, 'team-owner', browserName, 1); const table = page.getByLabel('List members card').getByRole('table'); @@ -62,4 +62,37 @@ test.describe('Team', () => { await expect(cells.nth(2)).toHaveText(`user@${browserName}.e2e`); await expect(cells.nth(3)).toHaveText('owner'); }); + + test('try to update the owner role but cannot because it is the last owner', async ({ + page, + browserName, + }) => { + await createTeam(page, 'team-owner-role', 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.getByText('Update the role').click(); + + await expect( + page.getByText('You are the last owner, you cannot change your role.'), + ).toBeVisible(); + + const radioGroup = page.getByLabel('Radio buttons to update the roles'); + + const radios = await radioGroup.getByRole('radio').all(); + for (const radio of radios) { + await expect(radio).toBeDisabled(); + } + + await expect( + page.getByRole('button', { + name: 'Validate', + }), + ).toBeDisabled(); + }); });