♻️(frontend) list members from side modal

We refactorize the members grid to display
it inside the share side modal.
It is not a member grid anymore but a member list
with infinite scroll. We can directly update the
role or delete a member from the each row of the
list.
This commit is contained in:
Anthony LC
2024-07-12 15:00:33 +02:00
committed by Anthony LC
parent e5de5a4345
commit 69f2641159
36 changed files with 520 additions and 1671 deletions

View File

@@ -344,6 +344,7 @@ const config = {
'border-color': 'var(--c--theme--colors--primary-text)',
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
'value-color': 'var(--c--theme--colors--primary-text)',
'font-size': '14px',
},
'forms-labelledbox': {
'label-color': {
@@ -351,6 +352,7 @@ const config = {
},
},
'forms-select': {
'item-font-size': '14px',
'border-radius': '4px',
'border-radius-hover': '4px',
'background-color': '#ffffff',

View File

@@ -6,7 +6,7 @@
"dev": "next dev",
"build": "prettier --check . && yarn stylelint && next build",
"build:ci": "cp .env.development .env.local && yarn build",
"build-theme": "cunningham -g css,ts -o src/cunningham --utility-classes",
"build-theme": "cunningham -g css,ts -o src/cunningham --utility-classes && yarn prettier",
"start": "npx -y serve@latest out",
"lint": "tsc --noEmit && next lint",
"prettier": "prettier --write .",

View File

@@ -1,6 +1,5 @@
import { CunninghamProvider } from '@openfun/cunningham-react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useCunninghamTheme } from '@/cunningham';
import '@/i18n/initI18n';
@@ -27,7 +26,6 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<ReactQueryDevtools />
<CunninghamProvider theme={theme}>
<Auth>{children}</Auth>
</CunninghamProvider>

View File

@@ -443,6 +443,13 @@ input:-webkit-autofill:focus {
color: var(--c--components--button--tertiary-text--color-hover);
}
.c__button--tertiary-text:disabled {
background-color: var(
--c--components--button--tertiary-text--background--color-disabled
);
color: var(--c--components--button--tertiary-text--color-disabled);
}
.c__button--danger {
background-color: var(--c--components--button--danger--background--color);
}

View File

@@ -469,9 +469,11 @@
--c--components--forms-input--value-color: var(
--c--theme--colors--primary-text
);
--c--components--forms-input--font-size: 14px;
--c--components--forms-labelledbox--label-color--big: var(
--c--theme--colors--primary-text
);
--c--components--forms-select--item-font-size: 14px;
--c--components--forms-select--border-radius: 4px;
--c--components--forms-select--border-radius-hover: 4px;
--c--components--forms-select--background-color: #fff;

View File

@@ -472,11 +472,13 @@ export const tokens = {
'border-color': 'var(--c--theme--colors--primary-text)',
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
'value-color': 'var(--c--theme--colors--primary-text)',
'font-size': '14px',
},
'forms-labelledbox': {
'label-color': { big: 'var(--c--theme--colors--primary-text)' },
},
'forms-select': {
'item-font-size': '14px',
'border-radius': '4px',
'border-radius-hover': '4px',
'background-color': '#ffffff',

View File

@@ -9,7 +9,6 @@ import {
ModalShare,
ModalUpdateDoc,
} from '@/features/docs/doc-management';
import { ModalGridMembers } from '@/features/docs/members/members-grid/';
import { ModalPDF } from './ModalPDF';
@@ -19,7 +18,6 @@ interface DocToolBoxProps {
export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const { t } = useTranslation();
const [isModalGridMembersOpen, setIsModalGridMembersOpen] = useState(false);
const [isModalShareOpen, setIsModalShareOpen] = useState(false);
const [isModalUpdateOpen, setIsModalUpdateOpen] = useState(false);
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
@@ -53,21 +51,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
isOpen={isDropOpen}
>
<Box>
{doc.abilities.manage_accesses && (
<>
<Button
onClick={() => {
setIsModalGridMembersOpen(true);
setIsDropOpen(false);
}}
color="primary-text"
icon={<span className="material-icons">group</span>}
size="small"
>
<Text $theme="primary">{t('Manage members')}</Text>
</Button>
</>
)}
{doc.abilities.partial_update && (
<Button
onClick={() => {
@@ -110,12 +93,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
{isModalShareOpen && (
<ModalShare onClose={() => setIsModalShareOpen(false)} doc={doc} />
)}
{isModalGridMembersOpen && (
<ModalGridMembers
onClose={() => setIsModalGridMembersOpen(false)}
doc={doc}
/>
)}
{isModalPDFOpen && (
<ModalPDF onClose={() => setIsModalPDFOpen(false)} doc={doc} />
)}

View File

@@ -6,6 +6,7 @@ import { Box, Card, Text } from '@/components';
import { SideModal } from '@/components/SideModal';
import { AddMembers } from '../../members/members-add';
import { MemberList } from '../../members/members-list/components/MemberList';
import { Doc } from '../types';
import { currentDocRole } from '../utils';
@@ -13,6 +14,11 @@ const ModalShareStyle = createGlobalStyle`
& .c__modal__scroller{
background: #FAFAFA;
padding: 1.5rem .5rem;
.c__modal__title{
padding: 0;
margin: 0;
}
}
`;
@@ -36,10 +42,16 @@ export const ModalShare = ({ onClose, doc }: ModalShareProps) => {
closeOnClickOutside
hideCloseButton
onClose={onClose}
width="45vw"
$css="min-width: 320px;"
width="70vw"
$css="min-width: 320px;max-width: 777px;"
title={
<Card $direction="row" $align="center" $padding="0.7rem" $gap="1rem">
<Card
$direction="row"
$align="center"
$margin={{ horizontal: 'tiny', top: 'none', bottom: 'big' }}
$padding="tiny"
$gap="1rem"
>
<Text $isMaterialIcon $size="48px" $theme="primary">
share
</Text>
@@ -55,6 +67,7 @@ export const ModalShare = ({ onClose, doc }: ModalShareProps) => {
}
>
<AddMembers doc={doc} currentRole={currentDocRole(doc.abilities)} />
<MemberList doc={doc} />
</SideModal>
</>
);

View File

@@ -8,7 +8,7 @@ import {
KEY_LIST_DOC,
Role,
} from '@/features/docs/doc-management';
import { KEY_LIST_DOC_ACCESSES } from '@/features/docs/members/members-grid/';
import { KEY_LIST_DOC_ACCESSES } from '@/features/docs/members/members-list';
import { OptionType } from '../types';

View File

@@ -8,6 +8,7 @@ interface ChooseRoleProps {
disabled: boolean;
defaultRole?: Role;
setRole: (role: Role) => void;
label?: string;
}
export const ChooseRole = ({
@@ -15,13 +16,14 @@ export const ChooseRole = ({
disabled,
currentRole,
setRole,
label,
}: ChooseRoleProps) => {
const { t } = useTranslation();
const transRole = useTransRole();
return (
<Select
label={t('Choose a role')}
label={label || t('Choose a role')}
options={Object.values(Role).map((role) => ({
label: transRole(role),
value: role,

View File

@@ -1 +1,2 @@
export * from './AddMembers';
export * from './ChooseRole';

View File

@@ -1,101 +0,0 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import fetchMock from 'fetch-mock';
import { Access, Doc, Role } from '@/features/docs/doc-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 Doc;
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

@@ -1,396 +0,0 @@
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 { Access, Doc, Role } from '@/features/docs/doc-management';
import { AppWrapper } from '@/tests/utils';
import { MemberGrid } from '../components/MemberGrid';
const doc: Doc = {
id: '123456',
title: 'teamName',
abilities: {
destroy: true,
manage_accesses: true,
partial_update: true,
retrieve: true,
update: true,
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
},
content: 'content',
is_public: false,
accesses: [],
created_at: '2021-09-01T12:00:00Z',
updated_at: '2021-09-01T12:00:00Z',
};
describe('MemberGrid', () => {
afterEach(() => {
fetchMock.restore();
});
it('renders with no member to display', async () => {
fetchMock.mock(`end:/documents/123456/accesses/?page=1`, {
count: 0,
results: [],
});
render(<MemberGrid doc={doc} />, {
wrapper: AppWrapper,
});
expect(screen.getByRole('status')).toBeInTheDocument();
expect(await screen.findByRole('img')).toHaveAttribute(
'alt',
'Illustration of an empty table',
);
expect(screen.getByText('This table is empty')).toBeInTheDocument();
});
it('checks the render with members', async () => {
const accesses: Access[] = [
{
id: '1',
role: Role.OWNER,
team: '123456',
user: {
id: '11',
email: 'user1@test.com',
},
abilities: {} as any,
},
{
id: '2',
team: '123456',
role: Role.EDITOR,
user: {
id: '22',
email: 'user2@test.com',
},
abilities: {} as any,
},
{
id: '3',
team: '123456',
role: Role.READER,
user: {
id: '33',
email: 'user3@test.com',
},
abilities: {} as any,
},
{
id: '4',
role: Role.ADMIN,
team: '123456',
user: {
id: '44',
email: 'user4@test.com',
},
abilities: {} as any,
},
];
fetchMock.mock(`end:/documents/123456/accesses/?page=1`, {
count: 3,
results: accesses,
});
render(<MemberGrid doc={doc} />, {
wrapper: AppWrapper,
});
expect(screen.getByRole('status')).toBeInTheDocument();
expect(await screen.findByText('user1@test.com')).toBeInTheDocument();
expect(screen.getByText('user2@test.com')).toBeInTheDocument();
expect(screen.getByText('user3@test.com')).toBeInTheDocument();
expect(screen.getByText('user4@test.com')).toBeInTheDocument();
expect(screen.getByText('Owner')).toBeInTheDocument();
expect(screen.getByText('Administrator')).toBeInTheDocument();
expect(screen.getByText('Reader')).toBeInTheDocument();
expect(screen.getByText('Editor')).toBeInTheDocument();
});
it('checks the pagination', async () => {
const regexp = new RegExp(/.*\/documents\/123456\/accesses\/\?page=.*/);
fetchMock.get(regexp, {
count: 40,
results: Array.from({ length: 20 }, (_, i) => ({
id: i,
role: Role.OWNER,
user: {
id: i,
email: `user${i}@test.com`,
},
abilities: {} as any,
})),
});
render(<MemberGrid doc={doc} />, {
wrapper: AppWrapper,
});
expect(screen.getByRole('status')).toBeInTheDocument();
expect(fetchMock.lastUrl()).toContain('/documents/123456/accesses/?page=1');
expect(
await screen.findByLabelText('You are currently on page 1'),
).toBeInTheDocument();
await userEvent.click(screen.getByLabelText('Go to page 2'));
expect(
await screen.findByLabelText('You are currently on page 2'),
).toBeInTheDocument();
expect(fetchMock.lastUrl()).toContain('/documents/123456/accesses/?page=2');
});
[
{
role: Role.OWNER,
expected: true,
},
{
role: Role.EDITOR,
expected: false,
},
{
role: Role.READER,
expected: false,
},
{
role: Role.ADMIN,
expected: true,
},
].forEach(({ role, expected }) => {
it(`checks action button when ${role}`, async () => {
const regexp = new RegExp(/.*\/documents\/123456\/accesses\/\?page=.*/);
fetchMock.get(regexp, {
count: 1,
results: [
{
id: 1,
role: Role.ADMIN,
user: {
id: 1,
email: `user1@test.com`,
},
abilities: {} as any,
},
],
});
render(<MemberGrid doc={doc} />, {
wrapper: AppWrapper,
});
expect(screen.getByRole('status')).toBeInTheDocument();
/* eslint-disable jest/no-conditional-expect */
if (expected) {
expect(
await screen.findAllByRole('button', {
name: 'Open the member options modal',
}),
).toBeDefined();
} else {
expect(
screen.queryByRole('button', {
name: 'Open the member options modal',
}),
).not.toBeInTheDocument();
}
/* eslint-enable jest/no-conditional-expect */
});
});
it('controls the render when api error', async () => {
fetchMock.mock(`end:/documents/123456/accesses/?page=1`, {
status: 500,
body: {
cause: 'All broken :(',
},
});
render(<MemberGrid doc={doc} />, {
wrapper: AppWrapper,
});
expect(screen.getByRole('status')).toBeInTheDocument();
expect(await screen.findByText('All broken :(')).toBeInTheDocument();
});
[
{
ordering: 'email',
header_name: 'Emails',
},
{
ordering: 'role',
header_name: 'Roles',
},
].forEach(({ ordering, header_name }) => {
it(`checks the sorting per ${ordering}`, async () => {
const mockedData = [
{
id: '123',
role: Role.ADMIN,
user: {
id: '123',
email: 'albert@test.com',
},
abilities: {} as any,
},
{
id: '789',
role: Role.OWNER,
user: {
id: '456',
email: 'philipp@test.com',
},
abilities: {} as any,
},
{
id: '456',
role: Role.READER,
user: {
id: '789',
email: 'fany@test.com',
},
abilities: {} as any,
},
{
id: '963',
role: Role.EDITOR,
user: {
id: '4548',
email: 'gege@test.com',
},
abilities: {} as any,
},
];
const sortedMockedData = [...mockedData].sort((a, b) => {
if (ordering === 'email') {
return a.user.email > b.user.email ? 1 : -1;
}
return a.role > b.role ? 1 : -1;
});
const reversedMockedData = [...sortedMockedData].reverse();
fetchMock.get(`end:/documents/123456/accesses/?page=1`, {
count: 4,
results: mockedData,
});
fetchMock.get(
`end:/documents/123456/accesses/?page=1&ordering=${ordering}`,
{
count: 4,
results: sortedMockedData,
},
);
fetchMock.get(
`end:/documents/123456/accesses/?page=1&ordering=-${ordering}`,
{
count: 4,
results: reversedMockedData,
},
);
render(<MemberGrid doc={doc} />, {
wrapper: AppWrapper,
});
expect(screen.getByRole('status')).toBeInTheDocument();
expect(fetchMock.lastUrl()).toContain(
`/documents/123456/accesses/?page=1`,
);
await waitFor(() => {
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
let rows = screen.getAllByRole('row');
expect(rows[1]).toHaveTextContent('albert');
expect(rows[2]).toHaveTextContent('philipp');
expect(rows[3]).toHaveTextContent('fany');
expect(rows[4]).toHaveTextContent('gege');
expect(
screen.queryByLabelText('arrow_drop_down'),
).not.toBeInTheDocument();
expect(screen.queryByLabelText('arrow_drop_up')).not.toBeInTheDocument();
await userEvent.click(screen.getByText(header_name));
expect(fetchMock.lastUrl()).toContain(
`/documents/123456/accesses/?page=1&ordering=${ordering}`,
);
await waitFor(() => {
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
rows = screen.getAllByRole('row');
expect(rows[1]).toHaveTextContent(sortedMockedData[0].user.email);
expect(rows[2]).toHaveTextContent(sortedMockedData[1].user.email);
expect(rows[3]).toHaveTextContent(sortedMockedData[2].user.email);
expect(rows[4]).toHaveTextContent(sortedMockedData[3].user.email);
expect(await screen.findByText('arrow_drop_up')).toBeInTheDocument();
await userEvent.click(screen.getByText(header_name));
expect(fetchMock.lastUrl()).toContain(
`/documents/123456/accesses/?page=1&ordering=-${ordering}`,
);
await waitFor(() => {
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
rows = screen.getAllByRole('row');
expect(rows[1]).toHaveTextContent(reversedMockedData[0].user.email);
expect(rows[2]).toHaveTextContent(reversedMockedData[1].user.email);
expect(rows[3]).toHaveTextContent(reversedMockedData[2].user.email);
expect(rows[4]).toHaveTextContent(reversedMockedData[3].user.email);
expect(await screen.findByText('arrow_drop_down')).toBeInTheDocument();
await userEvent.click(screen.getByText(header_name));
expect(fetchMock.lastUrl()).toContain(
'/documents/123456/accesses/?page=1',
);
await waitFor(() => {
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
rows = screen.getAllByRole('row');
expect(rows[1]).toHaveTextContent('albert');
expect(rows[2]).toHaveTextContent('philipp');
expect(rows[3]).toHaveTextContent('fany');
expect(rows[4]).toHaveTextContent('gege');
expect(
screen.queryByLabelText('arrow_drop_down'),
).not.toBeInTheDocument();
expect(screen.queryByLabelText('arrow_drop_up')).not.toBeInTheDocument();
});
});
});

View File

@@ -1,305 +0,0 @@
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/docs/doc-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, 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

@@ -1,92 +0,0 @@
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, Doc, Role } from '@/features/docs/doc-management';
import { ModalDelete } from './ModalDelete';
import { ModalRole } from './ModalRole';
interface MemberActionProps {
access: Access;
currentRole: Role;
doc: Doc;
}
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>}
size="small"
>
<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>}
size="small"
>
{t('Remove from group')}
</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

@@ -1,127 +0,0 @@
import { DataGrid, SortModel, usePagination } from '@openfun/cunningham-react';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, TextErrors } from '@/components';
import {
Doc,
currentDocRole,
useTransRole,
} from '@/features/docs/doc-management/';
import { useDocAccesses } from '../api';
import { PAGE_SIZE } from '../conf';
import { MemberAction } from './MemberAction';
interface MemberGridProps {
doc: Doc;
}
// FIXME : ask Cunningham to export this type
type SortModelItem = {
field: string;
sort: 'asc' | 'desc' | null;
};
const defaultOrderingMapping: Record<string, string> = {
'user.name': 'name',
'user.email': 'email',
localizedRole: 'role',
};
/**
* Formats the sorting model based on a given mapping.
* @param {SortModelItem} sortModel The sorting model item containing field and sort direction.
* @param {Record<string, string>} mapping The mapping object to map field names.
* @returns {string} The formatted sorting string.
*/
function formatSortModel(
sortModel: SortModelItem,
mapping = defaultOrderingMapping,
) {
const { field, sort } = sortModel;
const orderingField = mapping[field] || field;
return sort === 'desc' ? `-${orderingField}` : orderingField;
}
export const MemberGrid = ({ doc }: MemberGridProps) => {
const { t } = useTranslation();
const transRole = useTransRole();
const pagination = usePagination({
pageSize: PAGE_SIZE,
});
const [sortModel, setSortModel] = useState<SortModel>([]);
const { page, pageSize, setPagesCount } = pagination;
const ordering = sortModel.length ? formatSortModel(sortModel[0]) : undefined;
const { data, isLoading, error } = useDocAccesses({
docId: doc.id,
page,
ordering,
});
/*
* Bug occurs from the Cunningham Datagrid component, when applying sorting
* on null values. Sanitize empty values to ensure consistent sorting functionality.
*/
const accesses =
data?.results?.map((access) => ({
...access,
localizedRole: transRole(access.role),
email: access.user.email,
})) || [];
useEffect(() => {
setPagesCount(data?.count ? Math.ceil(data.count / pageSize) : 0);
}, [data?.count, pageSize, setPagesCount]);
return (
<Card
$padding={{ bottom: 'small' }}
$margin={{ all: 'big', top: 'none' }}
$overflow="auto"
$css={`
& table td:last-child {
text-align: right;
}
`}
aria-label={t('List members card')}
>
{error && <TextErrors causes={error.cause} />}
<DataGrid
columns={[
{
field: 'email',
headerName: t('Emails'),
},
{
field: 'localizedRole',
headerName: t('Roles'),
size: 200,
},
{
id: 'column-actions',
size: 125,
renderCell: ({ row }) => {
return (
<MemberAction
doc={doc}
access={row}
currentRole={currentDocRole(doc.abilities)}
/>
);
},
},
]}
rows={accesses}
isLoading={isLoading}
pagination={pagination}
onSortModelChange={setSortModel}
sortModel={sortModel}
/>
</Card>
);
};

View File

@@ -1,132 +0,0 @@
import {
Alert,
Button,
Modal,
ModalSize,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { useRouter } from 'next/navigation';
import { useTranslation } from 'react-i18next';
import IconUser from '@/assets/icons/icon-user.svg';
import { Box, Text, TextErrors } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Access, Doc, Role } from '@/features/docs/doc-management';
import { useDeleteDocAccess } from '../api';
import IconRemoveMember from '../assets/icon-remove-member.svg';
import { useWhoAmI } from '../hooks/useWhoAmI';
interface ModalDeleteProps {
access: Access;
currentRole: Role;
onClose: () => void;
doc: Doc;
}
export const ModalDelete = ({ access, onClose, doc }: ModalDeleteProps) => {
const { toast } = useToastProvider();
const { colorsTokens } = useCunninghamTheme();
const router = useRouter();
const { t } = useTranslation();
const { isMyself, isLastOwner, isOtherOwner } = useWhoAmI(access);
const isNotAllowed = isOtherOwner || isLastOwner;
const {
mutate: removeDocAccess,
error: errorUpdate,
isError: isErrorUpdate,
} = useDeleteDocAccess({
onSuccess: () => {
toast(
t('The member has been removed from the document'),
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();
},
});
return (
<Modal
isOpen
closeOnClickOutside
hideCloseButton
leftActions={
<Button color="secondary" fullWidth onClick={() => onClose()}>
{t('Cancel')}
</Button>
}
onClose={onClose}
rightActions={
<Button
color="primary"
fullWidth
onClick={() => {
removeDocAccess({
docId: doc.id,
accessId: access.id,
});
}}
disabled={isNotAllowed}
>
{t('Validate')}
</Button>
}
size={ModalSize.MEDIUM}
title={
<Box $align="center" $gap="1rem">
<IconRemoveMember width={48} color={colorsTokens()['primary-text']} />
<Text $size="h3" $margin="none">
{t('Remove the member')}
</Text>
</Box>
}
>
<Box aria-label={t('Radio buttons to update the roles')}>
{!isLastOwner && !isOtherOwner && !isErrorUpdate && (
<Alert canClose={false} type={VariantType.INFO}>
<Text>
{t(
'Are you sure you want to remove this member from the document?',
)}
</Text>
</Alert>
)}
{isErrorUpdate && <TextErrors causes={errorUpdate.cause} />}
{(isLastOwner || isOtherOwner) && !isErrorUpdate && (
<Alert canClose={false} type={VariantType.WARNING}>
<Text>
{isLastOwner &&
t(
'You are the last owner, you cannot be removed from your document.',
)}
{isOtherOwner && t('You cannot remove other owner.')}
</Text>
</Alert>
)}
<Text
as="p"
$padding="big"
$direction="row"
$gap="0.5rem"
$background={colorsTokens()['primary-150']}
$theme="primary"
>
<IconUser width={20} height={20} />
<Text>{access.user.email}</Text>
</Text>
</Box>
</Modal>
);
};

View File

@@ -1,47 +0,0 @@
import { Modal, ModalSize } from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import { createGlobalStyle } from 'styled-components';
import { Box, Text } from '@/components';
import { Doc } from '@/features/docs/doc-management';
import { MemberGrid } from './MemberGrid';
const GlobalStyle = createGlobalStyle`
.c__modal {
overflow: visible;
}
`;
interface ModalGridMembersProps {
onClose: () => void;
doc: Doc;
}
export const ModalGridMembers = ({ doc, onClose }: ModalGridMembersProps) => {
const { t } = useTranslation();
return (
<Modal
isOpen
onClose={onClose}
closeOnClickOutside
size={ModalSize.LARGE}
title={
<Box $align="center" $gap="1rem">
<Text className="material-icons" $size="3.5rem" $theme="primary">
group
</Text>
<Text $size="h3" $margin="none">
{t('Members of the document')}
</Text>
</Box>
}
>
<GlobalStyle />
<Box $margin={{ top: 'large' }} $maxHeight="60vh">
<MemberGrid doc={doc} />
</Box>
</Modal>
);
};

View File

@@ -1,129 +0,0 @@
import {
Alert,
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/docs/doc-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 causes={errorUpdate.cause} />}
{(isLastOwner || isOtherOwner) && (
<Box
$direction="row"
$align="center"
$gap="0.5rem"
$margin={{ bottom: 'tiny', top: 'none' }}
>
<Alert
canClose={false}
type={VariantType.WARNING}
icon={
<Text className="material-icons" $theme="warning">
warning
</Text>
}
>
{isLastOwner && (
<Box $direction="column" $gap="0.2rem">
<Text $theme="warning">
{t(
'You are the sole owner of this group, 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.')}
</Alert>
</Box>
)}
<ChooseRole
defaultRole={access.role}
currentRole={currentRole}
disabled={isNotAllowed}
setRole={setLocalRole}
/>
</Box>
</Modal>
);
};

View File

@@ -1 +0,0 @@
export * from './ModalGridMembers';

View File

@@ -1,14 +1,24 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import {
DefinedInitialDataInfiniteOptions,
InfiniteData,
QueryKey,
UseQueryOptions,
useInfiniteQuery,
useQuery,
} from '@tanstack/react-query';
import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
import { Access } from '@/features/docs/doc-management';
export type DocAccessesAPIParams = {
page: number;
export type DocAccessesParam = {
docId: string;
ordering?: string;
};
export type DocAccessesAPIParams = DocAccessesParam & {
page: number;
};
type AccessesResponse = APIList<Access>;
export const getDocAccesses = async ({
@@ -46,3 +56,39 @@ export function useDocAccesses(
...queryConfig,
});
}
/**
* @param param Used for infinite scroll pagination
* @param queryConfig
* @returns
*/
export function useDocAccessesInfinite(
param: DocAccessesParam,
queryConfig?: DefinedInitialDataInfiniteOptions<
AccessesResponse,
APIError,
InfiniteData<AccessesResponse>,
QueryKey,
number
>,
) {
return useInfiniteQuery<
AccessesResponse,
APIError,
InfiniteData<AccessesResponse>,
QueryKey,
number
>({
initialPageParam: 1,
queryKey: [KEY_LIST_DOC_ACCESSES, param],
queryFn: ({ pageParam }) =>
getDocAccesses({
...param,
page: pageParam,
}),
getNextPageParam(lastPage, allPages) {
return lastPage.next ? allPages.length + 1 : undefined;
},
...queryConfig,
});
}

View File

@@ -0,0 +1,147 @@
import {
Alert,
Button,
Loader,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { useRouter } from 'next/navigation';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, IconBG, Text, TextErrors } from '@/components';
import { Access, Role } from '@/features/docs/doc-management';
import { ChooseRole } from '@/features/docs/members/members-add/';
import { useDeleteDocAccess, useUpdateDocAccess } from '../api';
import { useWhoAmI } from '../hooks/useWhoAmI';
interface MemberItemProps {
role: Role;
currentRole: Role;
access: Access;
docId: string;
}
export const MemberItem = ({
docId,
role,
access,
currentRole,
}: MemberItemProps) => {
const { isMyself, isLastOwner, isOtherOwner } = useWhoAmI(access);
const { t } = useTranslation();
const [localRole, setLocalRole] = useState(role);
const { toast } = useToastProvider();
const router = useRouter();
const { mutate: updateDocAccess, error: errorUpdate } = useUpdateDocAccess({
onSuccess: () => {
toast(t('The role has been updated'), VariantType.SUCCESS, {
duration: 4000,
});
},
});
const { mutate: removeDocAccess, error: errorDelete } = useDeleteDocAccess({
onSuccess: () => {
toast(
t('The member has been removed from the document'),
VariantType.SUCCESS,
{
duration: 4000,
},
);
if (isMyself) {
router.push('/');
}
},
});
const isNotAllowed = isOtherOwner || isLastOwner;
if (!access.user) {
return (
<Box className="m-auto">
<Loader />
</Box>
);
}
return (
<Box $width="100%">
<Box
$align="center"
$direction="row"
$gap="1rem"
$justify="space-between"
$width="100%"
>
<Box $direction="row" $gap="1rem">
<IconBG iconName="account_circle" $size="2rem" />
<Text $justify="center">{access.user.email}</Text>
</Box>
<Box $direction="row" $gap="1rem" $align="center">
<Box $minWidth="13rem">
<ChooseRole
label={t('Role')}
defaultRole={localRole}
currentRole={currentRole}
disabled={isNotAllowed}
setRole={(role) => {
setLocalRole(role);
updateDocAccess({
docId,
accessId: access.id,
role,
});
}}
/>
</Box>
<Button
color="tertiary-text"
icon={
<Text
$isMaterialIcon
$theme={isNotAllowed ? 'greyscale' : 'primary'}
$variation={isNotAllowed ? '500' : 'text'}
>
delete
</Text>
}
disabled={isNotAllowed}
onClick={() => removeDocAccess({ docId, accessId: access.id })}
/>
</Box>
</Box>
{(errorUpdate || errorDelete) && (
<TextErrors causes={errorUpdate?.cause || errorDelete?.cause} />
)}
{(isLastOwner || isOtherOwner) && (
<Box $margin={{ top: 'tiny' }}>
<Alert
canClose={false}
type={VariantType.WARNING}
icon={
<Text className="material-icons" $theme="warning">
warning
</Text>
}
>
{isLastOwner && (
<Box $direction="column" $gap="0.2rem">
<Text $theme="warning">
{t(
'You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.',
)}
</Text>
</Box>
)}
{isOtherOwner &&
t('You cannot update the role or remove other owner.')}
</Alert>
</Box>
)}
</Box>
);
};

View File

@@ -0,0 +1,122 @@
import { Loader } from '@openfun/cunningham-react';
import React, { useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { APIError } from '@/api';
import { Box, Card, InfiniteScroll, TextErrors } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Access, Doc, currentDocRole } from '@/features/docs/doc-management';
import { useDocAccessesInfinite } from '../api';
import { MemberItem } from './MemberItem';
interface MemberListStateProps {
isLoading: boolean;
error: APIError | null;
accesses?: Access[];
doc: Doc;
}
const MemberListState = ({
accesses,
error,
isLoading,
doc,
}: MemberListStateProps) => {
const { colorsTokens } = useCunninghamTheme();
if (error) {
return <TextErrors causes={error.cause} />;
}
if (isLoading || !accesses) {
return (
<Box $align="center" className="m-l">
<Loader />
</Box>
);
}
return accesses?.map((access, index) => {
if (!access.user) {
return null;
}
return (
<Box
key={`${access.id}-${index}`}
$background={!(index % 2) ? 'white' : colorsTokens()['greyscale-000']}
$direction="row"
$padding="small"
$align="center"
$gap="1rem"
$radius="4px"
as="li"
>
<MemberItem
access={access}
role={access.role}
docId={doc.id}
currentRole={currentDocRole(doc.abilities)}
/>
</Box>
);
});
};
interface MemberListProps {
doc: Doc;
}
export const MemberList = ({ doc }: MemberListProps) => {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const {
data,
isLoading,
error,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useDocAccessesInfinite({
docId: doc.id,
});
const accesses = useMemo(() => {
return data?.pages.reduce((acc, page) => {
return acc.concat(page.results);
}, [] as Access[]);
}, [data?.pages]);
return (
<Card
$margin="tiny"
$padding="tiny"
$maxHeight="85%"
$overflow="auto"
aria-label={t('List members card')}
>
<Box ref={containerRef} $overflow="auto">
<InfiniteScroll
hasMore={hasNextPage}
isLoading={isFetchingNextPage}
next={() => {
void fetchNextPage();
}}
scrollContainer={containerRef.current}
as="ul"
className="p-0 mt-0"
role="listbox"
>
<MemberListState
isLoading={isLoading}
error={error}
accesses={accesses}
doc={doc}
/>
</InfiniteScroll>
</Box>
</Card>
);
};

View File

@@ -0,0 +1 @@
export * from './MemberList';