From cce0331b689f99bab878e342f630c46471885121 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Fri, 31 May 2024 17:15:38 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20add=20modal=20to=20update?= =?UTF-8?q?=20role=20of=20a=20member?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add modal to update role of a member. --- src/frontend/apps/impress/src/api/fetchApi.ts | 2 +- .../__tests__/MemberAction.test.tsx | 101 ++++++ .../members-grid/__tests__/ModalRole.test.tsx | 309 ++++++++++++++++++ .../members-grid/api/useUpdateDocAccess.ts | 67 ++++ .../members-grid/components/MemberAction.tsx | 90 +++++ .../members-grid/components/ModalRole.tsx | 130 ++++++++ .../members/members-grid/hooks/useWhoAmI.tsx | 20 ++ 7 files changed, 718 insertions(+), 1 deletion(-) create mode 100644 src/frontend/apps/impress/src/features/pads/members/members-grid/__tests__/MemberAction.test.tsx create mode 100644 src/frontend/apps/impress/src/features/pads/members/members-grid/__tests__/ModalRole.test.tsx create mode 100644 src/frontend/apps/impress/src/features/pads/members/members-grid/api/useUpdateDocAccess.ts create mode 100644 src/frontend/apps/impress/src/features/pads/members/members-grid/components/MemberAction.tsx create mode 100644 src/frontend/apps/impress/src/features/pads/members/members-grid/components/ModalRole.tsx create mode 100644 src/frontend/apps/impress/src/features/pads/members/members-grid/hooks/useWhoAmI.tsx diff --git a/src/frontend/apps/impress/src/api/fetchApi.ts b/src/frontend/apps/impress/src/api/fetchApi.ts index fc224367..fcf27c0c 100644 --- a/src/frontend/apps/impress/src/api/fetchApi.ts +++ b/src/frontend/apps/impress/src/api/fetchApi.ts @@ -15,7 +15,6 @@ function getCSRFToken() { export const fetchAPI = async (input: string, init?: RequestInit) => { const apiUrl = `${baseApiUrl()}${input}`; - const { logout } = useAuthStore.getState(); const csrfToken = getCSRFToken(); @@ -30,6 +29,7 @@ export const fetchAPI = async (input: string, init?: RequestInit) => { }); if (response.status === 401) { + const { logout } = useAuthStore.getState(); logout(); } diff --git a/src/frontend/apps/impress/src/features/pads/members/members-grid/__tests__/MemberAction.test.tsx b/src/frontend/apps/impress/src/features/pads/members/members-grid/__tests__/MemberAction.test.tsx new file mode 100644 index 00000000..adece224 --- /dev/null +++ b/src/frontend/apps/impress/src/features/pads/members/members-grid/__tests__/MemberAction.test.tsx @@ -0,0 +1,101 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import fetchMock from 'fetch-mock'; + +import { Access, Pad, Role } from '@/features/pads/pad-management'; +import { AppWrapper } from '@/tests/utils'; + +import { MemberAction } from '../components/MemberAction'; + +const access: Access = { + id: '789', + role: Role.ADMIN, + user: { + id: '11', + email: 'user1@test.com', + }, + team: '', + abilities: { + set_role_to: [Role.READER, Role.ADMIN], + } as any, +}; + +const doc = { + id: '123456', + title: 'teamName', +} as Pad; + +describe('MemberAction', () => { + afterEach(() => { + fetchMock.restore(); + }); + + it('checks the render when owner', async () => { + render( + , + { + wrapper: AppWrapper, + }, + ); + + expect( + await screen.findByLabelText('Open the member options modal'), + ).toBeInTheDocument(); + }); + + it('checks the render when reader', () => { + render( + , + { + wrapper: AppWrapper, + }, + ); + + expect( + screen.queryByLabelText('Open the member options modal'), + ).not.toBeInTheDocument(); + }); + + it('checks the render when editor', () => { + render( + , + { + wrapper: AppWrapper, + }, + ); + + expect( + screen.queryByLabelText('Open the member options modal'), + ).not.toBeInTheDocument(); + }); + + it('checks the render when admin', async () => { + render( + , + { + wrapper: AppWrapper, + }, + ); + + expect( + await screen.findByLabelText('Open the member options modal'), + ).toBeInTheDocument(); + }); + + it('checks the render when admin to owner', () => { + render( + , + { + wrapper: AppWrapper, + }, + ); + + expect( + screen.queryByLabelText('Open the member options modal'), + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/frontend/apps/impress/src/features/pads/members/members-grid/__tests__/ModalRole.test.tsx b/src/frontend/apps/impress/src/features/pads/members/members-grid/__tests__/ModalRole.test.tsx new file mode 100644 index 00000000..027e2181 --- /dev/null +++ b/src/frontend/apps/impress/src/features/pads/members/members-grid/__tests__/ModalRole.test.tsx @@ -0,0 +1,309 @@ +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 '@/core/auth'; +import { Access, Role } from '@/features/pads/pad-management'; +import { AppWrapper } from '@/tests/utils'; + +import { ModalRole } from '../components/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, + team: '123', + user: { + id: '11', + email: 'user1@test.com', + }, + abilities: { + set_role_to: [Role.EDITOR, 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.mock(`end:/documents/123/accesses/789/`, { + status: 200, + ok: true, + }); + + const onClose = jest.fn(); + render( + , + { wrapper: AppWrapper }, + ); + + expect( + screen.getByRole('radio', { + name: 'Administrator', + }), + ).toBeChecked(); + + await userEvent.click( + screen.getByRole('radio', { + name: 'Reader', + }), + ); + + await userEvent.click( + screen.getByRole('button', { + name: 'Validate', + }), + ); + + await waitFor(() => { + expect(toast).toHaveBeenCalledWith( + 'The role has been updated', + 'success', + { + duration: 4000, + }, + ); + }); + + expect(fetchMock.lastUrl()).toContain(`/documents/123/accesses/789/`); + + expect(onClose).toHaveBeenCalled(); + }); + + it('fails to update the role', async () => { + fetchMock.patchOnce(`end:/documents/123/accesses/789/`, { + status: 500, + body: { + detail: 'The server is totally broken', + }, + }); + + render( + , + { wrapper: AppWrapper }, + ); + + await userEvent.click( + screen.getByRole('radio', { + name: 'Reader', + }), + ); + + 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 sole owner of this group.'), + ).toBeInTheDocument(); + + expect( + screen.getByText( + 'Make another member the group owner, before you can change your own role.', + ), + ).toBeInTheDocument(); + + expect( + screen.getByRole('radio', { + name: 'Administrator', + }), + ).toBeDisabled(); + + expect( + screen.getByRole('radio', { + name: 'Owner', + }), + ).toBeDisabled(); + + expect( + screen.getByRole('radio', { + name: 'Reader', + }), + ).toBeDisabled(); + + expect( + screen.getByRole('radio', { + name: 'Editor', + }), + ).toBeDisabled(); + + expect( + screen.getByRole('button', { + name: 'Validate', + }), + ).toBeDisabled(); + }); + + it('checks the render when it is another owner', () => { + useAuthStore.setState({ + userData: { + id: '12', + 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: 'Administrator', + }), + ).toBeDisabled(); + + expect( + screen.getByRole('radio', { + name: 'Owner', + }), + ).toBeDisabled(); + + expect( + screen.getByRole('radio', { + name: 'Reader', + }), + ).toBeDisabled(); + + expect( + screen.getByRole('radio', { + name: 'Editor', + }), + ).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: 'Editor', + }), + ).toBeEnabled(); + + expect( + screen.getByRole('radio', { + name: 'Reader', + }), + ).toBeEnabled(); + + expect( + screen.getByRole('radio', { + name: 'Administrator', + }), + ).toBeEnabled(); + + expect( + screen.getByRole('radio', { + name: 'Owner', + }), + ).toBeDisabled(); + }); +}); diff --git a/src/frontend/apps/impress/src/features/pads/members/members-grid/api/useUpdateDocAccess.ts b/src/frontend/apps/impress/src/features/pads/members/members-grid/api/useUpdateDocAccess.ts new file mode 100644 index 00000000..a7e10d4b --- /dev/null +++ b/src/frontend/apps/impress/src/features/pads/members/members-grid/api/useUpdateDocAccess.ts @@ -0,0 +1,67 @@ +import { + UseMutationOptions, + useMutation, + useQueryClient, +} from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; +import { Access, KEY_PAD, Role } from '@/features/pads/pad-management'; + +import { KEY_LIST_DOC_ACCESSES } from './useDocAccesses'; + +interface UpdateDocAccessProps { + docId: string; + accessId: string; + role: Role; +} + +export const updateDocAccess = async ({ + docId, + accessId, + role, +}: UpdateDocAccessProps): Promise => { + const response = await fetchAPI(`documents/${docId}/accesses/${accessId}/`, { + method: 'PATCH', + body: JSON.stringify({ + role, + }), + }); + + if (!response.ok) { + throw new APIError('Failed to update role', await errorCauses(response)); + } + + return response.json() as Promise; +}; + +type UseUpdateDocAccess = Partial; + +type UseUpdateDocAccessOptions = UseMutationOptions< + Access, + APIError, + UseUpdateDocAccess +>; + +export const useUpdateDocAccess = (options?: UseUpdateDocAccessOptions) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: updateDocAccess, + ...options, + onSuccess: (data, variables, context) => { + void queryClient.invalidateQueries({ + queryKey: [KEY_LIST_DOC_ACCESSES], + }); + void queryClient.invalidateQueries({ + queryKey: [KEY_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/components/MemberAction.tsx b/src/frontend/apps/impress/src/features/pads/members/members-grid/components/MemberAction.tsx new file mode 100644 index 00000000..b63989a2 --- /dev/null +++ b/src/frontend/apps/impress/src/features/pads/members/members-grid/components/MemberAction.tsx @@ -0,0 +1,90 @@ +import { Button } from '@openfun/cunningham-react'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Box, DropButton, IconOptions, Text } from '@/components'; +import { Access, Pad, Role } from '@/features/pads/pad-management'; + +import { ModalDelete } from './ModalDelete'; +import { ModalRole } from './ModalRole'; + +interface MemberActionProps { + access: Access; + currentRole: Role; + doc: Pad; +} + +export const MemberAction = ({ + access, + currentRole, + doc, +}: MemberActionProps) => { + const { t } = useTranslation(); + const [isModalRoleOpen, setIsModalRoleOpen] = useState(false); + const [isModalDeleteOpen, setIsModalDeleteOpen] = useState(false); + const [isDropOpen, setIsDropOpen] = useState(false); + + if ( + currentRole === Role.EDITOR || + currentRole === Role.READER || + (access.role === Role.OWNER && currentRole === Role.ADMIN) + ) { + return null; + } + + return ( + <> + + } + onOpenChange={(isOpen) => setIsDropOpen(isOpen)} + isOpen={isDropOpen} + > + + + + + + {isModalRoleOpen && ( + setIsModalRoleOpen(false)} + docId={doc.id} + /> + )} + {isModalDeleteOpen && ( + setIsModalDeleteOpen(false)} + doc={doc} + /> + )} + + ); +}; diff --git a/src/frontend/apps/impress/src/features/pads/members/members-grid/components/ModalRole.tsx b/src/frontend/apps/impress/src/features/pads/members/members-grid/components/ModalRole.tsx new file mode 100644 index 00000000..bbc27ae4 --- /dev/null +++ b/src/frontend/apps/impress/src/features/pads/members/members-grid/components/ModalRole.tsx @@ -0,0 +1,130 @@ +import { + Button, + Modal, + ModalSize, + VariantType, + useToastProvider, +} from '@openfun/cunningham-react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Box, Text, TextErrors } from '@/components'; +import { Access, Role } from '@/features/pads/pad-management'; + +import { ChooseRole } from '../../members-add/components/ChooseRole'; +import { useUpdateDocAccess } from '../api'; +import { useWhoAmI } from '../hooks/useWhoAmI'; + +interface ModalRoleProps { + access: Access; + currentRole: Role; + onClose: () => void; + docId: string; +} + +export const ModalRole = ({ + access, + currentRole, + onClose, + docId, +}: ModalRoleProps) => { + const { t } = useTranslation(); + const [localRole, setLocalRole] = useState(access.role); + const { toast } = useToastProvider(); + const { + mutate: updateDocAccess, + error: errorUpdate, + isError: isErrorUpdate, + isPending, + } = useUpdateDocAccess({ + onSuccess: () => { + toast(t('The role has been updated'), VariantType.SUCCESS, { + duration: 4000, + }); + onClose(); + }, + }); + const { isLastOwner, isOtherOwner } = useWhoAmI(access); + + const isNotAllowed = isOtherOwner || isLastOwner; + + return ( + onClose()} + disabled={isPending} + > + {t('Cancel')} + + } + onClose={() => onClose()} + closeOnClickOutside + hideCloseButton + rightActions={ + + } + size={ModalSize.MEDIUM} + title={t('Update the role')} + > + + {isErrorUpdate && ( + + )} + + {(isLastOwner || isOtherOwner) && ( + + warning + {isLastOwner && ( + + + {t('You are the sole owner of this group.')} + + + {t( + 'Make another member the group owner, before you can change your own role.', + )} + + + )} + + {isOtherOwner && t('You cannot update the role of other owner.')} + + )} + + + + + ); +}; diff --git a/src/frontend/apps/impress/src/features/pads/members/members-grid/hooks/useWhoAmI.tsx b/src/frontend/apps/impress/src/features/pads/members/members-grid/hooks/useWhoAmI.tsx new file mode 100644 index 00000000..925952d3 --- /dev/null +++ b/src/frontend/apps/impress/src/features/pads/members/members-grid/hooks/useWhoAmI.tsx @@ -0,0 +1,20 @@ +import { useAuthStore } from '@/core/auth'; +import { Access, Role } from '@/features/pads/pad-management'; + +export const useWhoAmI = (access: Access) => { + const { userData } = useAuthStore(); + + const isMyself = userData?.id === access.user.id; + 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; + + return { + isLastOwner, + isOtherOwner, + isMyself, + }; +};