(frontend) add members grid

Add members grid to display all members
of a doc and their roles.
This commit is contained in:
Anthony LC
2024-05-31 17:14:28 +02:00
committed by Anthony LC
parent 1779b7bab4
commit 380ac0cbcf
14 changed files with 801 additions and 16 deletions

View File

@@ -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();
});
});

View File

@@ -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;
}

View File

@@ -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],
});
},
});
}

View File

@@ -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();
});
});
});

View File

@@ -0,0 +1,3 @@
export * from './useDeleteDocAccess';
export * from './useDocAccesses';
export * from './useUpdateDocAccess';

View File

@@ -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,
});
}

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -0,0 +1 @@
export * from './ModalGridMembers';

View File

@@ -0,0 +1 @@
export const PAGE_SIZE = 20;

View File

@@ -0,0 +1,2 @@
export * from './api';
export * from './components';

View File

@@ -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));

View File

@@ -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)}

View File

@@ -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;
}