♻️(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) replace docs panel with docs grid #120
|
||||||
- ♻️(frontend) create a doc from a modal #132
|
- ♻️(frontend) create a doc from a modal #132
|
||||||
|
- ♻️(frontend) manage members from the share modal #140
|
||||||
|
|
||||||
## [1.0.0] - 2024-07-02
|
## [1.0.0] - 2024-07-02
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export const createDoc = async (
|
|||||||
export const addNewMember = async (
|
export const addNewMember = async (
|
||||||
page: Page,
|
page: Page,
|
||||||
index: number,
|
index: number,
|
||||||
role: 'Admin' | 'Owner' | 'Member',
|
role: 'Administrator' | 'Owner' | 'Member' | 'Editor' | 'Reader',
|
||||||
fillText: string = 'user',
|
fillText: string = 'user',
|
||||||
) => {
|
) => {
|
||||||
const responsePromiseSearchUser = page.waitForResponse(
|
const responsePromiseSearchUser = page.waitForResponse(
|
||||||
@@ -80,8 +80,6 @@ export const addNewMember = async (
|
|||||||
response.status() === 200,
|
response.status() === 200,
|
||||||
);
|
);
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Share' }).click();
|
|
||||||
|
|
||||||
const inputSearch = page.getByLabel(/Find a member to add to the document/);
|
const inputSearch = page.getByLabel(/Find a member to add to the document/);
|
||||||
|
|
||||||
// Select a new user
|
// 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)',
|
'border-color': 'var(--c--theme--colors--primary-text)',
|
||||||
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
|
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
|
||||||
'value-color': 'var(--c--theme--colors--primary-text)',
|
'value-color': 'var(--c--theme--colors--primary-text)',
|
||||||
|
'font-size': '14px',
|
||||||
},
|
},
|
||||||
'forms-labelledbox': {
|
'forms-labelledbox': {
|
||||||
'label-color': {
|
'label-color': {
|
||||||
@@ -351,6 +352,7 @@ const config = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
'forms-select': {
|
'forms-select': {
|
||||||
|
'item-font-size': '14px',
|
||||||
'border-radius': '4px',
|
'border-radius': '4px',
|
||||||
'border-radius-hover': '4px',
|
'border-radius-hover': '4px',
|
||||||
'background-color': '#ffffff',
|
'background-color': '#ffffff',
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "prettier --check . && yarn stylelint && next build",
|
"build": "prettier --check . && yarn stylelint && next build",
|
||||||
"build:ci": "cp .env.development .env.local && yarn 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",
|
"start": "npx -y serve@latest out",
|
||||||
"lint": "tsc --noEmit && next lint",
|
"lint": "tsc --noEmit && next lint",
|
||||||
"prettier": "prettier --write .",
|
"prettier": "prettier --write .",
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { CunninghamProvider } from '@openfun/cunningham-react';
|
import { CunninghamProvider } from '@openfun/cunningham-react';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
|
||||||
|
|
||||||
import { useCunninghamTheme } from '@/cunningham';
|
import { useCunninghamTheme } from '@/cunningham';
|
||||||
import '@/i18n/initI18n';
|
import '@/i18n/initI18n';
|
||||||
@@ -27,7 +26,6 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ReactQueryDevtools />
|
|
||||||
<CunninghamProvider theme={theme}>
|
<CunninghamProvider theme={theme}>
|
||||||
<Auth>{children}</Auth>
|
<Auth>{children}</Auth>
|
||||||
</CunninghamProvider>
|
</CunninghamProvider>
|
||||||
|
|||||||
@@ -443,6 +443,13 @@ input:-webkit-autofill:focus {
|
|||||||
color: var(--c--components--button--tertiary-text--color-hover);
|
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 {
|
.c__button--danger {
|
||||||
background-color: var(--c--components--button--danger--background--color);
|
background-color: var(--c--components--button--danger--background--color);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -469,9 +469,11 @@
|
|||||||
--c--components--forms-input--value-color: var(
|
--c--components--forms-input--value-color: var(
|
||||||
--c--theme--colors--primary-text
|
--c--theme--colors--primary-text
|
||||||
);
|
);
|
||||||
|
--c--components--forms-input--font-size: 14px;
|
||||||
--c--components--forms-labelledbox--label-color--big: var(
|
--c--components--forms-labelledbox--label-color--big: var(
|
||||||
--c--theme--colors--primary-text
|
--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: 4px;
|
||||||
--c--components--forms-select--border-radius-hover: 4px;
|
--c--components--forms-select--border-radius-hover: 4px;
|
||||||
--c--components--forms-select--background-color: #fff;
|
--c--components--forms-select--background-color: #fff;
|
||||||
|
|||||||
@@ -472,11 +472,13 @@ export const tokens = {
|
|||||||
'border-color': 'var(--c--theme--colors--primary-text)',
|
'border-color': 'var(--c--theme--colors--primary-text)',
|
||||||
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
|
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
|
||||||
'value-color': 'var(--c--theme--colors--primary-text)',
|
'value-color': 'var(--c--theme--colors--primary-text)',
|
||||||
|
'font-size': '14px',
|
||||||
},
|
},
|
||||||
'forms-labelledbox': {
|
'forms-labelledbox': {
|
||||||
'label-color': { big: 'var(--c--theme--colors--primary-text)' },
|
'label-color': { big: 'var(--c--theme--colors--primary-text)' },
|
||||||
},
|
},
|
||||||
'forms-select': {
|
'forms-select': {
|
||||||
|
'item-font-size': '14px',
|
||||||
'border-radius': '4px',
|
'border-radius': '4px',
|
||||||
'border-radius-hover': '4px',
|
'border-radius-hover': '4px',
|
||||||
'background-color': '#ffffff',
|
'background-color': '#ffffff',
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
ModalShare,
|
ModalShare,
|
||||||
ModalUpdateDoc,
|
ModalUpdateDoc,
|
||||||
} from '@/features/docs/doc-management';
|
} from '@/features/docs/doc-management';
|
||||||
import { ModalGridMembers } from '@/features/docs/members/members-grid/';
|
|
||||||
|
|
||||||
import { ModalPDF } from './ModalPDF';
|
import { ModalPDF } from './ModalPDF';
|
||||||
|
|
||||||
@@ -19,7 +18,6 @@ interface DocToolBoxProps {
|
|||||||
|
|
||||||
export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isModalGridMembersOpen, setIsModalGridMembersOpen] = useState(false);
|
|
||||||
const [isModalShareOpen, setIsModalShareOpen] = useState(false);
|
const [isModalShareOpen, setIsModalShareOpen] = useState(false);
|
||||||
const [isModalUpdateOpen, setIsModalUpdateOpen] = useState(false);
|
const [isModalUpdateOpen, setIsModalUpdateOpen] = useState(false);
|
||||||
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
|
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
|
||||||
@@ -53,21 +51,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
|||||||
isOpen={isDropOpen}
|
isOpen={isDropOpen}
|
||||||
>
|
>
|
||||||
<Box>
|
<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 && (
|
{doc.abilities.partial_update && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -110,12 +93,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
|||||||
{isModalShareOpen && (
|
{isModalShareOpen && (
|
||||||
<ModalShare onClose={() => setIsModalShareOpen(false)} doc={doc} />
|
<ModalShare onClose={() => setIsModalShareOpen(false)} doc={doc} />
|
||||||
)}
|
)}
|
||||||
{isModalGridMembersOpen && (
|
|
||||||
<ModalGridMembers
|
|
||||||
onClose={() => setIsModalGridMembersOpen(false)}
|
|
||||||
doc={doc}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{isModalPDFOpen && (
|
{isModalPDFOpen && (
|
||||||
<ModalPDF onClose={() => setIsModalPDFOpen(false)} doc={doc} />
|
<ModalPDF onClose={() => setIsModalPDFOpen(false)} doc={doc} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Box, Card, Text } from '@/components';
|
|||||||
import { SideModal } from '@/components/SideModal';
|
import { SideModal } from '@/components/SideModal';
|
||||||
|
|
||||||
import { AddMembers } from '../../members/members-add';
|
import { AddMembers } from '../../members/members-add';
|
||||||
|
import { MemberList } from '../../members/members-list/components/MemberList';
|
||||||
import { Doc } from '../types';
|
import { Doc } from '../types';
|
||||||
import { currentDocRole } from '../utils';
|
import { currentDocRole } from '../utils';
|
||||||
|
|
||||||
@@ -13,6 +14,11 @@ const ModalShareStyle = createGlobalStyle`
|
|||||||
& .c__modal__scroller{
|
& .c__modal__scroller{
|
||||||
background: #FAFAFA;
|
background: #FAFAFA;
|
||||||
padding: 1.5rem .5rem;
|
padding: 1.5rem .5rem;
|
||||||
|
|
||||||
|
.c__modal__title{
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -36,10 +42,16 @@ export const ModalShare = ({ onClose, doc }: ModalShareProps) => {
|
|||||||
closeOnClickOutside
|
closeOnClickOutside
|
||||||
hideCloseButton
|
hideCloseButton
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
width="45vw"
|
width="70vw"
|
||||||
$css="min-width: 320px;"
|
$css="min-width: 320px;max-width: 777px;"
|
||||||
title={
|
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">
|
<Text $isMaterialIcon $size="48px" $theme="primary">
|
||||||
share
|
share
|
||||||
</Text>
|
</Text>
|
||||||
@@ -55,6 +67,7 @@ export const ModalShare = ({ onClose, doc }: ModalShareProps) => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<AddMembers doc={doc} currentRole={currentDocRole(doc.abilities)} />
|
<AddMembers doc={doc} currentRole={currentDocRole(doc.abilities)} />
|
||||||
|
<MemberList doc={doc} />
|
||||||
</SideModal>
|
</SideModal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
KEY_LIST_DOC,
|
KEY_LIST_DOC,
|
||||||
Role,
|
Role,
|
||||||
} from '@/features/docs/doc-management';
|
} 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';
|
import { OptionType } from '../types';
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ interface ChooseRoleProps {
|
|||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
defaultRole?: Role;
|
defaultRole?: Role;
|
||||||
setRole: (role: Role) => void;
|
setRole: (role: Role) => void;
|
||||||
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChooseRole = ({
|
export const ChooseRole = ({
|
||||||
@@ -15,13 +16,14 @@ export const ChooseRole = ({
|
|||||||
disabled,
|
disabled,
|
||||||
currentRole,
|
currentRole,
|
||||||
setRole,
|
setRole,
|
||||||
|
label,
|
||||||
}: ChooseRoleProps) => {
|
}: ChooseRoleProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const transRole = useTransRole();
|
const transRole = useTransRole();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
label={t('Choose a role')}
|
label={label || t('Choose a role')}
|
||||||
options={Object.values(Role).map((role) => ({
|
options={Object.values(Role).map((role) => ({
|
||||||
label: transRole(role),
|
label: transRole(role),
|
||||||
value: role,
|
value: role,
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export * from './AddMembers';
|
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 { APIError, APIList, errorCauses, fetchAPI } from '@/api';
|
||||||
import { Access } from '@/features/docs/doc-management';
|
import { Access } from '@/features/docs/doc-management';
|
||||||
|
|
||||||
export type DocAccessesAPIParams = {
|
export type DocAccessesParam = {
|
||||||
page: number;
|
|
||||||
docId: string;
|
docId: string;
|
||||||
ordering?: string;
|
ordering?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DocAccessesAPIParams = DocAccessesParam & {
|
||||||
|
page: number;
|
||||||
|
};
|
||||||
|
|
||||||
type AccessesResponse = APIList<Access>;
|
type AccessesResponse = APIList<Access>;
|
||||||
|
|
||||||
export const getDocAccesses = async ({
|
export const getDocAccesses = async ({
|
||||||
@@ -46,3 +56,39 @@ export function useDocAccesses(
|
|||||||
...queryConfig,
|
...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