✨(frontend) add members grid
Add members grid to display all members of a doc and their roles.
This commit is contained in:
@@ -0,0 +1,114 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { createPad, keyCloakSignIn } from './common';
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page, browserName }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await keyCloakSignIn(page, browserName);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Document grid members', () => {
|
||||||
|
test('it display the grid', async ({ page, browserName }) => {
|
||||||
|
await createPad(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,
|
||||||
|
browserName,
|
||||||
|
}) => {
|
||||||
|
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 createPad(page, 'grid-no-member', browserName, 1);
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -233,7 +233,7 @@ input:-webkit-autofill:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.c__datagrid > .c__pagination {
|
.c__datagrid > .c__pagination {
|
||||||
padding-right: 1rem;
|
padding-inline: 1rem;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,3 +464,14 @@ input:-webkit-autofill:focus {
|
|||||||
.c__modal__close .c__button--tertiary-text:focus-visible {
|
.c__modal__close .c__button--tertiary-text:focus-visible {
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.c__modal__close button {
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toast
|
||||||
|
*/
|
||||||
|
.c__toast__container {
|
||||||
|
z-index: 10000;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
|
|
||||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||||
import { User } from '@/core/auth';
|
import { User } from '@/core/auth';
|
||||||
|
import { KEY_LIST_DOC_ACCESSES } from '@/features/pads/members/members-grid/';
|
||||||
import {
|
import {
|
||||||
Access,
|
Access,
|
||||||
KEY_LIST_PAD,
|
KEY_LIST_PAD,
|
||||||
@@ -55,6 +56,9 @@ export function useCreateDocAccess() {
|
|||||||
void queryClient.resetQueries({
|
void queryClient.resetQueries({
|
||||||
queryKey: [KEY_LIST_USER],
|
queryKey: [KEY_LIST_USER],
|
||||||
});
|
});
|
||||||
|
void queryClient.resetQueries({
|
||||||
|
queryKey: [KEY_LIST_DOC_ACCESSES],
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,396 @@
|
|||||||
|
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, Pad, Role } from '@/features/pads/pad-management';
|
||||||
|
import { AppWrapper } from '@/tests/utils';
|
||||||
|
|
||||||
|
import { MemberGrid } from '../components/MemberGrid';
|
||||||
|
|
||||||
|
const doc: Pad = {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './useDeleteDocAccess';
|
||||||
|
export * from './useDocAccesses';
|
||||||
|
export * from './useUpdateDocAccess';
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
|
||||||
|
import { Access } from '@/features/pads/pad-management';
|
||||||
|
|
||||||
|
export type DocAccessesAPIParams = {
|
||||||
|
page: number;
|
||||||
|
docId: string;
|
||||||
|
ordering?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AccessesResponse = APIList<Access>;
|
||||||
|
|
||||||
|
export const getDocAccesses = async ({
|
||||||
|
page,
|
||||||
|
docId,
|
||||||
|
ordering,
|
||||||
|
}: DocAccessesAPIParams): Promise<AccessesResponse> => {
|
||||||
|
let url = `documents/${docId}/accesses/?page=${page}`;
|
||||||
|
|
||||||
|
if (ordering) {
|
||||||
|
url += '&ordering=' + ordering;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetchAPI(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new APIError(
|
||||||
|
'Failed to get the doc accesses',
|
||||||
|
await errorCauses(response),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<AccessesResponse>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const KEY_LIST_DOC_ACCESSES = 'docs-accesses';
|
||||||
|
|
||||||
|
export function useDocAccesses(
|
||||||
|
params: DocAccessesAPIParams,
|
||||||
|
queryConfig?: UseQueryOptions<AccessesResponse, APIError, AccessesResponse>,
|
||||||
|
) {
|
||||||
|
return useQuery<AccessesResponse, APIError, AccessesResponse>({
|
||||||
|
queryKey: [KEY_LIST_DOC_ACCESSES, params],
|
||||||
|
queryFn: () => getDocAccesses(params),
|
||||||
|
...queryConfig,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
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 { Pad, Role, currentDocRole } from '@/features/pads/pad-management';
|
||||||
|
|
||||||
|
import { useDocAccesses } from '../api';
|
||||||
|
import { PAGE_SIZE } from '../conf';
|
||||||
|
|
||||||
|
import { MemberAction } from './MemberAction';
|
||||||
|
|
||||||
|
interface MemberGridProps {
|
||||||
|
doc: Pad;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 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,
|
||||||
|
});
|
||||||
|
|
||||||
|
const translatedRoles = {
|
||||||
|
[Role.ADMIN]: t('Administrator'),
|
||||||
|
[Role.READER]: t('Reader'),
|
||||||
|
[Role.OWNER]: t('Owner'),
|
||||||
|
[Role.EDITOR]: t('Editor'),
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 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: translatedRoles[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)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
rows={accesses}
|
||||||
|
isLoading={isLoading}
|
||||||
|
pagination={pagination}
|
||||||
|
onSortModelChange={setSortModel}
|
||||||
|
sortModel={sortModel}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Modal, ModalSize } from '@openfun/cunningham-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { createGlobalStyle } from 'styled-components';
|
||||||
|
|
||||||
|
import { Box, Text } from '@/components';
|
||||||
|
import { Pad } from '@/features/pads/pad-management';
|
||||||
|
|
||||||
|
import { MemberGrid } from './MemberGrid';
|
||||||
|
|
||||||
|
const GlobalStyle = createGlobalStyle`
|
||||||
|
.c__modal {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface ModalGridMembersProps {
|
||||||
|
onClose: () => void;
|
||||||
|
doc: Pad;
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './ModalGridMembers';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const PAGE_SIZE = 20;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './api';
|
||||||
|
export * from './components';
|
||||||
@@ -9,7 +9,7 @@ export type PadParams = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getPad = async ({ id }: PadParams): Promise<Pad> => {
|
export const getPad = async ({ id }: PadParams): Promise<Pad> => {
|
||||||
const response = await fetchAPI(`documents/${id}`);
|
const response = await fetchAPI(`documents/${id}/`);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new APIError('Failed to get the pad', await errorCauses(response));
|
throw new APIError('Failed to get the pad', await errorCauses(response));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
import { Box, DropButton, IconOptions, Text } from '@/components';
|
import { Box, DropButton, IconOptions, Text } from '@/components';
|
||||||
import { ModalAddMembers } from '@/features/pads/members/members-add';
|
import { ModalAddMembers } from '@/features/pads/members/members-add';
|
||||||
|
import { ModalGridMembers } from '@/features/pads/members/members-grid/';
|
||||||
import {
|
import {
|
||||||
ModalRemovePad,
|
ModalRemovePad,
|
||||||
ModalUpdatePad,
|
ModalUpdatePad,
|
||||||
@@ -25,6 +26,7 @@ export const PadToolBox = ({ pad }: PadToolBoxProps) => {
|
|||||||
ordering: TemplatesOrdering.BY_CREATED_ON_DESC,
|
ordering: TemplatesOrdering.BY_CREATED_ON_DESC,
|
||||||
});
|
});
|
||||||
const [isModalAddMembersOpen, setIsModalAddMembersOpen] = useState(false);
|
const [isModalAddMembersOpen, setIsModalAddMembersOpen] = useState(false);
|
||||||
|
const [isModalGridMembersOpen, setIsModalGridMembersOpen] = useState(false);
|
||||||
const [isModalUpdateOpen, setIsModalUpdateOpen] = useState(false);
|
const [isModalUpdateOpen, setIsModalUpdateOpen] = useState(false);
|
||||||
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
|
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
|
||||||
const [isModalPDFOpen, setIsModalPDFOpen] = useState(false);
|
const [isModalPDFOpen, setIsModalPDFOpen] = useState(false);
|
||||||
@@ -61,16 +63,30 @@ export const PadToolBox = ({ pad }: PadToolBoxProps) => {
|
|||||||
>
|
>
|
||||||
<Box>
|
<Box>
|
||||||
{pad.abilities.manage_accesses && (
|
{pad.abilities.manage_accesses && (
|
||||||
<Button
|
<>
|
||||||
onClick={() => {
|
<Button
|
||||||
setIsModalAddMembersOpen(true);
|
onClick={() => {
|
||||||
setIsDropOpen(false);
|
setIsModalAddMembersOpen(true);
|
||||||
}}
|
setIsDropOpen(false);
|
||||||
color="primary-text"
|
}}
|
||||||
icon={<span className="material-icons">person_add</span>}
|
color="primary-text"
|
||||||
>
|
icon={<span className="material-icons">person_add</span>}
|
||||||
<Text $theme="primary">{t('Add members')}</Text>
|
size="small"
|
||||||
</Button>
|
>
|
||||||
|
<Text $theme="primary">{t('Add members')}</Text>
|
||||||
|
</Button>
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{pad.abilities.partial_update && (
|
{pad.abilities.partial_update && (
|
||||||
<Button
|
<Button
|
||||||
@@ -80,6 +96,7 @@ export const PadToolBox = ({ pad }: PadToolBoxProps) => {
|
|||||||
}}
|
}}
|
||||||
color="primary-text"
|
color="primary-text"
|
||||||
icon={<span className="material-icons">edit</span>}
|
icon={<span className="material-icons">edit</span>}
|
||||||
|
size="small"
|
||||||
>
|
>
|
||||||
<Text $theme="primary">{t('Update document')}</Text>
|
<Text $theme="primary">{t('Update document')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -92,6 +109,7 @@ export const PadToolBox = ({ pad }: PadToolBoxProps) => {
|
|||||||
}}
|
}}
|
||||||
color="primary-text"
|
color="primary-text"
|
||||||
icon={<span className="material-icons">delete</span>}
|
icon={<span className="material-icons">delete</span>}
|
||||||
|
size="small"
|
||||||
>
|
>
|
||||||
<Text $theme="primary">{t('Delete document')}</Text>
|
<Text $theme="primary">{t('Delete document')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -103,11 +121,18 @@ export const PadToolBox = ({ pad }: PadToolBoxProps) => {
|
|||||||
}}
|
}}
|
||||||
color="primary-text"
|
color="primary-text"
|
||||||
icon={<span className="material-icons">picture_as_pdf</span>}
|
icon={<span className="material-icons">picture_as_pdf</span>}
|
||||||
|
size="small"
|
||||||
>
|
>
|
||||||
<Text $theme="primary">{t('Generate PDF')}</Text>
|
<Text $theme="primary">{t('Generate PDF')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</DropButton>
|
</DropButton>
|
||||||
|
{isModalGridMembersOpen && (
|
||||||
|
<ModalGridMembers
|
||||||
|
onClose={() => setIsModalGridMembersOpen(false)}
|
||||||
|
doc={pad}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{isModalAddMembersOpen && (
|
{isModalAddMembersOpen && (
|
||||||
<ModalAddMembers
|
<ModalAddMembers
|
||||||
onClose={() => setIsModalAddMembersOpen(false)}
|
onClose={() => setIsModalAddMembersOpen(false)}
|
||||||
|
|||||||
@@ -9,21 +9,25 @@ body {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
main ::-webkit-scrollbar {
|
main ::-webkit-scrollbar,
|
||||||
|
.ReactModalPortal ::-webkit-scrollbar {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
main ::-webkit-scrollbar-track {
|
main ::-webkit-scrollbar-track,
|
||||||
|
.ReactModalPortal ::-webkit-scrollbar-track {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
main ::-webkit-scrollbar-thumb {
|
main ::-webkit-scrollbar-thumb,
|
||||||
|
.ReactModalPortal ::-webkit-scrollbar-thumb {
|
||||||
background-color: #d6dee1;
|
background-color: #d6dee1;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
border: 6px solid transparent;
|
border: 6px solid transparent;
|
||||||
background-clip: content-box;
|
background-clip: content-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
main ::-webkit-scrollbar-thumb:hover {
|
main ::-webkit-scrollbar-thumb:hover,
|
||||||
|
.ReactModalPortal ::-webkit-scrollbar-thumb:hover {
|
||||||
background-color: #a8bbbf;
|
background-color: #a8bbbf;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user