♻️(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

@@ -19,6 +19,7 @@ and this project adheres to
- ♻️(frontend) replace docs panel with docs grid #120
- ♻️(frontend) create a doc from a modal #132
- ♻️(frontend) manage members from the share modal #140
## [1.0.0] - 2024-07-02

View File

@@ -71,7 +71,7 @@ export const createDoc = async (
export const addNewMember = async (
page: Page,
index: number,
role: 'Admin' | 'Owner' | 'Member',
role: 'Administrator' | 'Owner' | 'Member' | 'Editor' | 'Reader',
fillText: string = 'user',
) => {
const responsePromiseSearchUser = page.waitForResponse(
@@ -80,8 +80,6 @@ export const addNewMember = async (
response.status() === 200,
);
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByLabel(/Find a member to add to the document/);
// Select a new user

View File

@@ -1,192 +0,0 @@
import { expect, test } from '@playwright/test';
import { addNewMember, createDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Members Delete', () => {
test('it cannot delete himself when it is the last owner', async ({
page,
browserName,
}) => {
await createDoc(page, 'member-delete-1', browserName, 1);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Manage members' }).click();
const table = page.getByLabel('List members card').getByRole('table');
const cells = table.getByRole('row').nth(1).getByRole('cell');
await expect(cells.nth(0)).toHaveText(
new RegExp(`user@${browserName}.e2e`, 'i'),
);
await cells.nth(2).getByLabel('Member options').click();
await page.getByLabel('Open the modal to delete this member').click();
await expect(
page.getByText(
'You are the last owner, you cannot be removed from your document.',
),
).toBeVisible();
await expect(page.getByRole('button', { name: 'Validate' })).toBeDisabled();
});
test('it deletes himself when it is not the last owner', async ({
page,
browserName,
}) => {
await createDoc(page, 'member-delete-2', browserName, 1);
await addNewMember(page, 0, 'Owner');
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Manage members' }).click();
const table = page.getByLabel('List members card').getByRole('table');
// find row where regexp match the name
const cells = table
.getByRole('row')
.filter({ hasText: new RegExp(`user@${browserName}.e2e`, 'i') })
.getByRole('cell');
await cells.nth(2).getByLabel('Member options').click();
await page.getByLabel('Open the modal to delete this member').click();
await page.getByRole('button', { name: 'Validate' }).click();
await expect(
page.getByText(`The member has been removed from the document`),
).toBeVisible();
await expect(
page.getByRole('button', { name: `Create a new document` }),
).toBeVisible();
});
test('it cannot delete owner member', async ({ page, browserName }) => {
await createDoc(page, 'member-delete-3', browserName, 1);
const username = await addNewMember(page, 0, 'Owner');
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Manage members' }).click();
const table = page.getByLabel('List members card').getByRole('table');
// find row where regexp match the name
const cells = table
.getByRole('row')
.filter({ hasText: username })
.getByRole('cell');
await cells.getByLabel('Member options').click();
await page.getByLabel('Open the modal to delete this member').click();
await expect(
page.getByText(`You cannot remove other owner.`),
).toBeVisible();
await expect(page.getByRole('button', { name: 'Validate' })).toBeDisabled();
});
test('it deletes admin member', async ({ page, browserName }) => {
await createDoc(page, 'member-delete-4', browserName, 1);
const username = await addNewMember(page, 0, 'Admin');
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Manage members' }).click();
const table = page.getByLabel('List members card').getByRole('table');
// find row where regexp match the name
const cells = table
.getByRole('row')
.filter({ hasText: username })
.getByRole('cell');
await cells.getByLabel('Member options').click();
await page.getByLabel('Open the modal to delete this member').click();
await page.getByRole('button', { name: 'Validate' }).click();
await expect(
page.getByText(`The member has been removed from the document`),
).toBeVisible();
await expect(table.getByText(username)).toBeHidden();
});
test('it cannot delete owner member when admin', async ({
page,
browserName,
}) => {
await createDoc(page, 'member-delete-5', browserName, 1);
const username = await addNewMember(page, 0, 'Owner');
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Manage members' }).click();
const table = page.getByLabel('List members card').getByRole('table');
// find row where regexp match the name
const myCells = table
.getByRole('row')
.filter({ hasText: new RegExp(`user@${browserName}.e2e`, 'i') })
.getByRole('cell');
await myCells.getByLabel('Member options').click();
// Change role to Admin
await page.getByText('Update role').click();
const radioGroup = page.getByLabel('Radio buttons to update the roles');
await radioGroup.getByRole('radio', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Validate' }).click();
const cells = table
.getByRole('row')
.filter({ hasText: username })
.getByRole('cell');
await expect(cells.getByLabel('Member options')).toBeHidden();
});
test('it deletes admin member when admin', async ({ page, browserName }) => {
await createDoc(page, 'member-delete-6', browserName, 1);
// To not be the only owner
await addNewMember(page, 0, 'Owner');
const username = await addNewMember(page, 1, 'Admin');
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Manage members' }).click();
const table = page.getByLabel('List members card').getByRole('table');
// find row where regexp match the name
const myCells = table
.getByRole('row')
.filter({ hasText: new RegExp(`user@${browserName}.e2e`, 'i') })
.getByRole('cell');
await myCells.getByLabel('Member options').click();
// Change role to Admin
await page.getByText('Update role').click();
const radioGroup = page.getByLabel('Radio buttons to update the roles');
await radioGroup.getByRole('radio', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Validate' }).click();
await expect(page.getByText(`The role has been updated`)).toBeVisible();
await expect(page.getByText(`The role has been updated`)).toBeHidden({
timeout: 5000,
});
const cells = table
.getByRole('row')
.filter({ hasText: new RegExp(username, 'i') })
.getByRole('cell');
await cells.nth(2).getByLabel('Member options').click();
await page.getByLabel('Open the modal to delete this member').click();
await page.getByRole('button', { name: 'Validate' }).click();
await expect(
page.getByText(`The member has been removed from the document`),
).toBeVisible();
await expect(table.getByText(username)).toBeHidden();
});
});

View File

@@ -1,112 +0,0 @@
import { expect, test } from '@playwright/test';
import { createDoc, goToGridDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Document grid members', () => {
test('it display the grid', async ({ page, browserName }) => {
await createDoc(page, 'grid-display', browserName, 1);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Manage members' }).click();
await expect(page.getByText('Members of the document')).toBeVisible();
const table = page.getByLabel('List members card').getByRole('table');
const thead = table.locator('thead');
await expect(thead.getByText(/Emails/i)).toBeVisible();
await expect(thead.getByText(/Roles/i)).toBeVisible();
const cells = table.getByRole('row').nth(1).getByRole('cell');
await expect(cells.nth(0)).toHaveText(`user@${browserName}.e2e`);
await expect(cells.nth(1)).toHaveText(/Owner/i);
await expect(cells.nth(2)).toHaveAccessibleName(
'Open the member options modal',
);
});
test('it display the grid with many members', async ({ page }) => {
await page.route('**/documents/*/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
!request.url().includes('page=')
) {
await route.fulfill({
json: {
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
content: '',
title: 'Mocked document',
accesses: [],
abilities: {
destroy: true,
manage_accesses: true,
partial_update: true,
},
is_public: false,
},
});
} else {
await route.continue();
}
});
await page.route(
'**/documents/b0df4343-c8bd-4c20-9ff6-fbf94fc94egg/accesses/?page=*',
async (route) => {
const request = route.request();
const url = new URL(request.url());
const pageId = url.searchParams.get('page');
const accesses = {
count: 100,
next: null,
previous: null,
results: Array.from({ length: 20 }, (_, i) => ({
id: `2ff1ec07-86c1-4534-a643-f41824a6c53a-${pageId}-${i}`,
user: {
id: `fc092149-cafa-4ffa-a29d-e4b18af751-${pageId}-${i}`,
email: `impress@impress.world-page-${pageId}-${i}`,
},
team: '',
role: 'owner',
abilities: {
destroy: false,
partial_update: true,
},
})),
};
if (request.method().includes('GET')) {
await route.fulfill({
json: accesses,
});
} else {
await route.continue();
}
},
);
await goToGridDoc(page);
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Manage members' }).click();
await expect(
page.getByText('impress@impress.world-page-1-19'),
).toBeVisible();
await page.getByLabel('Go to page 4').click();
await expect(
page.getByText('impress@impress.world-page-1-19'),
).toBeHidden();
await expect(
page.getByText('impress@impress.world-page-4-19'),
).toBeVisible();
});
});

View File

@@ -0,0 +1,164 @@
import { expect, test } from '@playwright/test';
import { waitForElementCount } from '../helpers';
import { addNewMember, createDoc, goToGridDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Document list members', () => {
test('it checks a big list of members', async ({ page }) => {
await page.route(
/.*\/documents\/.*\/accesses\/\?page=.*/,
async (route) => {
const request = route.request();
const url = new URL(request.url());
const pageId = url.searchParams.get('page');
const accesses = {
count: 100,
next: 'http://anything/?page=2',
previous: null,
results: Array.from({ length: 20 }, (_, i) => ({
id: `2ff1ec07-86c1-4534-a643-f41824a6c53a-${pageId}-${i}`,
user: {
id: `fc092149-cafa-4ffa-a29d-e4b18af751-${pageId}-${i}`,
email: `impress@impress.world-page-${pageId}-${i}`,
},
team: '',
role: 'editor',
abilities: {
destroy: false,
partial_update: true,
set_role_to: [],
},
})),
};
if (request.method().includes('GET')) {
await route.fulfill({
json: accesses,
});
} else {
await route.continue();
}
},
);
await goToGridDoc(page);
await page.getByRole('button', { name: 'Share' }).click();
const list = page.getByLabel('List members card').locator('ul');
await expect(list.locator('li')).toHaveCount(20);
await list.getByText(`impress@impress.world-page-${1}-18`).hover();
await page.mouse.wheel(0, 10);
await waitForElementCount(list.locator('li'), 21, 10000);
expect(await list.locator('li').count()).toBeGreaterThan(20);
await expect(
list.getByText(`impress@impress.world-page-1-16`),
).toBeVisible();
await expect(
list.getByText(`impress@impress.world-page-2-15`),
).toBeVisible();
});
test('it checks the role rules', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, 'Doc role rules', browserName, 1);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
const list = page.getByLabel('List members card').locator('ul');
await expect(list.getByText(`user@${browserName}.e2e`)).toBeVisible();
const soleOwner = list.getByText(
`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.`,
);
await expect(soleOwner).toBeVisible();
const username = await addNewMember(page, 0, 'Owner');
await expect(list.getByText(username)).toBeVisible();
await expect(soleOwner).toBeHidden();
const otherOwner = list.getByText(
`You cannot update the role or remove other owner.`,
);
await expect(otherOwner).toBeVisible();
const SelectRoleCurrentUser = list
.locator('li')
.filter({
hasText: `user@${browserName}.e2e`,
})
.getByRole('combobox', { name: 'Role' });
await SelectRoleCurrentUser.click();
await page.getByRole('option', { name: 'Administrator' }).click();
await expect(page.getByText('The role has been updated')).toBeVisible();
// Admin still have the right to share
await expect(page.locator('h3').getByText('Share')).toBeVisible();
await SelectRoleCurrentUser.click();
await page.getByRole('option', { name: 'Reader' }).click();
await expect(page.getByText('The role has been updated')).toBeVisible();
// Reader does not have the right to share
await expect(page.locator('h3').getByText('Share')).toBeHidden();
});
test('it checks the delete members', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, 'Doc role rules', browserName, 1);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
const list = page.getByLabel('List members card').locator('ul');
const nameMyself = `user@${browserName}.e2e`;
await expect(list.getByText(nameMyself)).toBeVisible();
const userOwner = await addNewMember(page, 0, 'Owner');
await expect(list.getByText(userOwner)).toBeVisible();
const userReader = await addNewMember(page, 0, 'Reader');
await expect(list.getByText(userReader)).toBeVisible();
await list
.locator('li')
.filter({
hasText: userReader,
})
.getByText('delete')
.click();
await expect(list.getByText(userReader)).toBeHidden();
await list
.locator('li')
.filter({
hasText: nameMyself,
})
.getByText('delete')
.click();
await expect(list.getByText(nameMyself)).toBeHidden();
await expect(
page.getByText('The member has been removed from the document').first(),
).toBeVisible();
await expect(page.getByText('Share')).toBeHidden();
});
});

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';