✨(frontend) add modal to update role of a member
Add modal to update role of a member.
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user