♻️(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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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 .",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './AddMembers';
|
||||
export * from './ChooseRole';
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from './ModalGridMembers';
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './MemberList';
|
||||
Reference in New Issue
Block a user