✨(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 {
|
||||
padding-right: 1rem;
|
||||
padding-inline: 1rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@@ -464,3 +464,14 @@ input:-webkit-autofill:focus {
|
||||
.c__modal__close .c__button--tertiary-text:focus-visible {
|
||||
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 { User } from '@/core/auth';
|
||||
import { KEY_LIST_DOC_ACCESSES } from '@/features/pads/members/members-grid/';
|
||||
import {
|
||||
Access,
|
||||
KEY_LIST_PAD,
|
||||
@@ -55,6 +56,9 @@ export function useCreateDocAccess() {
|
||||
void queryClient.resetQueries({
|
||||
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> => {
|
||||
const response = await fetchAPI(`documents/${id}`);
|
||||
const response = await fetchAPI(`documents/${id}/`);
|
||||
|
||||
if (!response.ok) {
|
||||
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 { ModalAddMembers } from '@/features/pads/members/members-add';
|
||||
import { ModalGridMembers } from '@/features/pads/members/members-grid/';
|
||||
import {
|
||||
ModalRemovePad,
|
||||
ModalUpdatePad,
|
||||
@@ -25,6 +26,7 @@ export const PadToolBox = ({ pad }: PadToolBoxProps) => {
|
||||
ordering: TemplatesOrdering.BY_CREATED_ON_DESC,
|
||||
});
|
||||
const [isModalAddMembersOpen, setIsModalAddMembersOpen] = useState(false);
|
||||
const [isModalGridMembersOpen, setIsModalGridMembersOpen] = useState(false);
|
||||
const [isModalUpdateOpen, setIsModalUpdateOpen] = useState(false);
|
||||
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
|
||||
const [isModalPDFOpen, setIsModalPDFOpen] = useState(false);
|
||||
@@ -61,16 +63,30 @@ export const PadToolBox = ({ pad }: PadToolBoxProps) => {
|
||||
>
|
||||
<Box>
|
||||
{pad.abilities.manage_accesses && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsModalAddMembersOpen(true);
|
||||
setIsDropOpen(false);
|
||||
}}
|
||||
color="primary-text"
|
||||
icon={<span className="material-icons">person_add</span>}
|
||||
>
|
||||
<Text $theme="primary">{t('Add members')}</Text>
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsModalAddMembersOpen(true);
|
||||
setIsDropOpen(false);
|
||||
}}
|
||||
color="primary-text"
|
||||
icon={<span className="material-icons">person_add</span>}
|
||||
size="small"
|
||||
>
|
||||
<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 && (
|
||||
<Button
|
||||
@@ -80,6 +96,7 @@ export const PadToolBox = ({ pad }: PadToolBoxProps) => {
|
||||
}}
|
||||
color="primary-text"
|
||||
icon={<span className="material-icons">edit</span>}
|
||||
size="small"
|
||||
>
|
||||
<Text $theme="primary">{t('Update document')}</Text>
|
||||
</Button>
|
||||
@@ -92,6 +109,7 @@ export const PadToolBox = ({ pad }: PadToolBoxProps) => {
|
||||
}}
|
||||
color="primary-text"
|
||||
icon={<span className="material-icons">delete</span>}
|
||||
size="small"
|
||||
>
|
||||
<Text $theme="primary">{t('Delete document')}</Text>
|
||||
</Button>
|
||||
@@ -103,11 +121,18 @@ export const PadToolBox = ({ pad }: PadToolBoxProps) => {
|
||||
}}
|
||||
color="primary-text"
|
||||
icon={<span className="material-icons">picture_as_pdf</span>}
|
||||
size="small"
|
||||
>
|
||||
<Text $theme="primary">{t('Generate PDF')}</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</DropButton>
|
||||
{isModalGridMembersOpen && (
|
||||
<ModalGridMembers
|
||||
onClose={() => setIsModalGridMembersOpen(false)}
|
||||
doc={pad}
|
||||
/>
|
||||
)}
|
||||
{isModalAddMembersOpen && (
|
||||
<ModalAddMembers
|
||||
onClose={() => setIsModalAddMembersOpen(false)}
|
||||
|
||||
@@ -9,21 +9,25 @@ body {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
main ::-webkit-scrollbar {
|
||||
main ::-webkit-scrollbar,
|
||||
.ReactModalPortal ::-webkit-scrollbar {
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
main ::-webkit-scrollbar-track {
|
||||
main ::-webkit-scrollbar-track,
|
||||
.ReactModalPortal ::-webkit-scrollbar-track {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
main ::-webkit-scrollbar-thumb {
|
||||
main ::-webkit-scrollbar-thumb,
|
||||
.ReactModalPortal ::-webkit-scrollbar-thumb {
|
||||
background-color: #d6dee1;
|
||||
border-radius: 20px;
|
||||
border: 6px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
main ::-webkit-scrollbar-thumb:hover {
|
||||
main ::-webkit-scrollbar-thumb:hover,
|
||||
.ReactModalPortal ::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #a8bbbf;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user