(frontend) add modal to update role of a member

Add modal to update role of a member.
This commit is contained in:
Anthony LC
2024-05-31 17:15:38 +02:00
committed by Anthony LC
parent 380ac0cbcf
commit cce0331b68
7 changed files with 718 additions and 1 deletions

View File

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

View File

@@ -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(
<MemberAction access={access} currentRole={Role.OWNER} doc={doc} />,
{
wrapper: AppWrapper,
},
);
expect(
await screen.findByLabelText('Open the member options modal'),
).toBeInTheDocument();
});
it('checks the render when reader', () => {
render(
<MemberAction access={access} currentRole={Role.READER} doc={doc} />,
{
wrapper: AppWrapper,
},
);
expect(
screen.queryByLabelText('Open the member options modal'),
).not.toBeInTheDocument();
});
it('checks the render when editor', () => {
render(
<MemberAction access={access} currentRole={Role.EDITOR} doc={doc} />,
{
wrapper: AppWrapper,
},
);
expect(
screen.queryByLabelText('Open the member options modal'),
).not.toBeInTheDocument();
});
it('checks the render when admin', async () => {
render(
<MemberAction access={access} currentRole={Role.ADMIN} doc={doc} />,
{
wrapper: AppWrapper,
},
);
expect(
await screen.findByLabelText('Open the member options modal'),
).toBeInTheDocument();
});
it('checks the render when admin to owner', () => {
render(
<MemberAction
access={{ ...access, role: Role.OWNER }}
currentRole={Role.ADMIN}
doc={doc}
/>,
{
wrapper: AppWrapper,
},
);
expect(
screen.queryByLabelText('Open the member options modal'),
).not.toBeInTheDocument();
});
});

View File

@@ -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(
<ModalRole
access={access}
currentRole={Role.ADMIN}
onClose={onClose}
docId="123"
/>,
{
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(
<ModalRole
access={access}
currentRole={Role.OWNER}
onClose={onClose}
docId="123"
/>,
{ 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(
<ModalRole
access={access}
currentRole={Role.OWNER}
onClose={jest.fn()}
docId="123"
/>,
{ 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(
<ModalRole
access={access2}
currentRole={Role.OWNER}
onClose={jest.fn()}
docId="123"
/>,
{ 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(
<ModalRole
access={access2}
currentRole={Role.OWNER}
onClose={jest.fn()}
docId="123"
/>,
{ 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(
<ModalRole
access={access}
currentRole={Role.ADMIN}
onClose={jest.fn()}
docId="123"
/>,
{ 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();
});
});

View File

@@ -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<Access> => {
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<Access>;
};
type UseUpdateDocAccess = Partial<Access>;
type UseUpdateDocAccessOptions = UseMutationOptions<
Access,
APIError,
UseUpdateDocAccess
>;
export const useUpdateDocAccess = (options?: UseUpdateDocAccessOptions) => {
const queryClient = useQueryClient();
return useMutation<Access, APIError, UpdateDocAccessProps>({
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);
}
},
});
};

View File

@@ -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 (
<>
<DropButton
button={
<IconOptions
isOpen={isDropOpen}
aria-label={t('Open the member options modal')}
/>
}
onOpenChange={(isOpen) => setIsDropOpen(isOpen)}
isOpen={isDropOpen}
>
<Box>
<Button
aria-label={t('Open the modal to update the role of this member')}
onClick={() => {
setIsModalRoleOpen(true);
setIsDropOpen(false);
}}
color="primary-text"
icon={<span className="material-icons">edit</span>}
>
<Text $theme="primary">{t('Update role')}</Text>
</Button>
<Button
aria-label={t('Open the modal to delete this member')}
onClick={() => {
setIsModalDeleteOpen(true);
setIsDropOpen(false);
}}
color="primary-text"
icon={<span className="material-icons">delete</span>}
>
<Text $theme="primary">{t('Remove from group')}</Text>
</Button>
</Box>
</DropButton>
{isModalRoleOpen && (
<ModalRole
access={access}
currentRole={currentRole}
onClose={() => setIsModalRoleOpen(false)}
docId={doc.id}
/>
)}
{isModalDeleteOpen && (
<ModalDelete
access={access}
currentRole={currentRole}
onClose={() => setIsModalDeleteOpen(false)}
doc={doc}
/>
)}
</>
);
};

View File

@@ -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 (
<Modal
isOpen
leftActions={
<Button
color="secondary"
fullWidth
onClick={() => onClose()}
disabled={isPending}
>
{t('Cancel')}
</Button>
}
onClose={() => onClose()}
closeOnClickOutside
hideCloseButton
rightActions={
<Button
color="primary"
fullWidth
onClick={() => {
updateDocAccess({
role: localRole,
docId,
accessId: access.id,
});
}}
disabled={isNotAllowed || isPending}
>
{t('Validate')}
</Button>
}
size={ModalSize.MEDIUM}
title={t('Update the role')}
>
<Box aria-label={t('Radio buttons to update the roles')}>
{isErrorUpdate && (
<TextErrors
$margin={{ bottom: 'small' }}
causes={errorUpdate.cause}
/>
)}
{(isLastOwner || isOtherOwner) && (
<Text
$theme="warning"
$direction="row"
$align="center"
$gap="0.5rem"
$margin={{ bottom: 'tiny', top: 'none' }}
as="div"
>
<span className="material-icons">warning</span>
{isLastOwner && (
<Box $align="flex-start">
<Text $theme="warning">
{t('You are the sole owner of this group.')}
</Text>
<Text $theme="warning">
{t(
'Make another member the group owner, before you can change your own role.',
)}
</Text>
</Box>
)}
{isOtherOwner && t('You cannot update the role of other owner.')}
</Text>
)}
<ChooseRole
defaultRole={access.role}
currentRole={currentRole}
disabled={isNotAllowed}
setRole={setLocalRole}
/>
</Box>
</Modal>
);
};

View File

@@ -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,
};
};