diff --git a/src/frontend/apps/e2e/__tests__/app-impress/pad-member-grid.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/pad-member-grid.spec.ts
new file mode 100644
index 00000000..b03192ca
--- /dev/null
+++ b/src/frontend/apps/e2e/__tests__/app-impress/pad-member-grid.spec.ts
@@ -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();
+ });
+});
diff --git a/src/frontend/apps/impress/src/cunningham/cunningham-style.css b/src/frontend/apps/impress/src/cunningham/cunningham-style.css
index bcfa7dc3..ddf9ec49 100644
--- a/src/frontend/apps/impress/src/cunningham/cunningham-style.css
+++ b/src/frontend/apps/impress/src/cunningham/cunningham-style.css
@@ -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;
+}
diff --git a/src/frontend/apps/impress/src/features/pads/members/members-add/api/useCreateDocAccess.tsx b/src/frontend/apps/impress/src/features/pads/members/members-add/api/useCreateDocAccess.tsx
index 71590ebb..8f301754 100644
--- a/src/frontend/apps/impress/src/features/pads/members/members-add/api/useCreateDocAccess.tsx
+++ b/src/frontend/apps/impress/src/features/pads/members/members-add/api/useCreateDocAccess.tsx
@@ -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],
+ });
},
});
}
diff --git a/src/frontend/apps/impress/src/features/pads/members/members-grid/__tests__/MemberGrid.test.tsx b/src/frontend/apps/impress/src/features/pads/members/members-grid/__tests__/MemberGrid.test.tsx
new file mode 100644
index 00000000..af9e1a36
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/pads/members/members-grid/__tests__/MemberGrid.test.tsx
@@ -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(, {
+ 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(, {
+ 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(, {
+ 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(, {
+ 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(, {
+ 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(, {
+ 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();
+ });
+ });
+});
diff --git a/src/frontend/apps/impress/src/features/pads/members/members-grid/api/index.ts b/src/frontend/apps/impress/src/features/pads/members/members-grid/api/index.ts
new file mode 100644
index 00000000..385fe981
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/pads/members/members-grid/api/index.ts
@@ -0,0 +1,3 @@
+export * from './useDeleteDocAccess';
+export * from './useDocAccesses';
+export * from './useUpdateDocAccess';
diff --git a/src/frontend/apps/impress/src/features/pads/members/members-grid/api/useDocAccesses.tsx b/src/frontend/apps/impress/src/features/pads/members/members-grid/api/useDocAccesses.tsx
new file mode 100644
index 00000000..ec63c995
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/pads/members/members-grid/api/useDocAccesses.tsx
@@ -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;
+
+export const getDocAccesses = async ({
+ page,
+ docId,
+ ordering,
+}: DocAccessesAPIParams): Promise => {
+ 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;
+};
+
+export const KEY_LIST_DOC_ACCESSES = 'docs-accesses';
+
+export function useDocAccesses(
+ params: DocAccessesAPIParams,
+ queryConfig?: UseQueryOptions,
+) {
+ return useQuery({
+ queryKey: [KEY_LIST_DOC_ACCESSES, params],
+ queryFn: () => getDocAccesses(params),
+ ...queryConfig,
+ });
+}
diff --git a/src/frontend/apps/impress/src/features/pads/members/members-grid/components/MemberGrid.tsx b/src/frontend/apps/impress/src/features/pads/members/members-grid/components/MemberGrid.tsx
new file mode 100644
index 00000000..4b773f6b
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/pads/members/members-grid/components/MemberGrid.tsx
@@ -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 = {
+ '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} 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([]);
+ 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 (
+
+ {error && }
+
+ {
+ return (
+
+ );
+ },
+ },
+ ]}
+ rows={accesses}
+ isLoading={isLoading}
+ pagination={pagination}
+ onSortModelChange={setSortModel}
+ sortModel={sortModel}
+ />
+
+ );
+};
diff --git a/src/frontend/apps/impress/src/features/pads/members/members-grid/components/ModalGridMembers.tsx b/src/frontend/apps/impress/src/features/pads/members/members-grid/components/ModalGridMembers.tsx
new file mode 100644
index 00000000..1da038d6
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/pads/members/members-grid/components/ModalGridMembers.tsx
@@ -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 (
+
+
+ group
+
+
+ {t('Members of the document')}
+
+
+ }
+ >
+
+
+
+
+
+ );
+};
diff --git a/src/frontend/apps/impress/src/features/pads/members/members-grid/components/index.ts b/src/frontend/apps/impress/src/features/pads/members/members-grid/components/index.ts
new file mode 100644
index 00000000..32e4e61e
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/pads/members/members-grid/components/index.ts
@@ -0,0 +1 @@
+export * from './ModalGridMembers';
diff --git a/src/frontend/apps/impress/src/features/pads/members/members-grid/conf.ts b/src/frontend/apps/impress/src/features/pads/members/members-grid/conf.ts
new file mode 100644
index 00000000..bfab9067
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/pads/members/members-grid/conf.ts
@@ -0,0 +1 @@
+export const PAGE_SIZE = 20;
diff --git a/src/frontend/apps/impress/src/features/pads/members/members-grid/index.ts b/src/frontend/apps/impress/src/features/pads/members/members-grid/index.ts
new file mode 100644
index 00000000..0ef46430
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/pads/members/members-grid/index.ts
@@ -0,0 +1,2 @@
+export * from './api';
+export * from './components';
diff --git a/src/frontend/apps/impress/src/features/pads/pad-management/api/usePad.tsx b/src/frontend/apps/impress/src/features/pads/pad-management/api/usePad.tsx
index 9bf859f0..7990f891 100644
--- a/src/frontend/apps/impress/src/features/pads/pad-management/api/usePad.tsx
+++ b/src/frontend/apps/impress/src/features/pads/pad-management/api/usePad.tsx
@@ -9,7 +9,7 @@ export type PadParams = {
};
export const getPad = async ({ id }: PadParams): Promise => {
- 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));
diff --git a/src/frontend/apps/impress/src/features/pads/pad-tools/components/PadToolBox.tsx b/src/frontend/apps/impress/src/features/pads/pad-tools/components/PadToolBox.tsx
index 0b3bb6d1..ed530719 100644
--- a/src/frontend/apps/impress/src/features/pads/pad-tools/components/PadToolBox.tsx
+++ b/src/frontend/apps/impress/src/features/pads/pad-tools/components/PadToolBox.tsx
@@ -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) => {
>
{pad.abilities.manage_accesses && (
-
+ <>
+
+
+ >
)}
{pad.abilities.partial_update && (
@@ -92,6 +109,7 @@ export const PadToolBox = ({ pad }: PadToolBoxProps) => {
}}
color="primary-text"
icon={delete}
+ size="small"
>
{t('Delete document')}
@@ -103,11 +121,18 @@ export const PadToolBox = ({ pad }: PadToolBoxProps) => {
}}
color="primary-text"
icon={picture_as_pdf}
+ size="small"
>
{t('Generate PDF')}
+ {isModalGridMembersOpen && (
+ setIsModalGridMembersOpen(false)}
+ doc={pad}
+ />
+ )}
{isModalAddMembersOpen && (
setIsModalAddMembersOpen(false)}
diff --git a/src/frontend/apps/impress/src/pages/globals.css b/src/frontend/apps/impress/src/pages/globals.css
index b033f2c7..20e745a5 100644
--- a/src/frontend/apps/impress/src/pages/globals.css
+++ b/src/frontend/apps/impress/src/pages/globals.css
@@ -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;
}