✨(frontend) create docs-grid feature
Create docs-grid feature. It will be used to display the list of documents in a grid view. Grid view are more useful to display lot of information, we can easily sort the information.
This commit is contained in:
@@ -12,6 +12,10 @@ and this project adheres to
|
||||
|
||||
- 🤡(demo) generate dummy documents on dev users #120
|
||||
|
||||
## Changed
|
||||
|
||||
- ♻️(frontend) replace docs panel with docs grid #120
|
||||
|
||||
## [1.0.0] - 2024-07-02
|
||||
|
||||
## Added
|
||||
|
||||
205
src/frontend/apps/e2e/__tests__/app-impress/doc-grid.spec.ts
Normal file
205
src/frontend/apps/e2e/__tests__/app-impress/doc-grid.spec.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test.describe('Documents Grid', () => {
|
||||
test('checks all the elements are visible', async ({ page }) => {
|
||||
await expect(page.locator('h2').getByText('Documents')).toBeVisible();
|
||||
|
||||
const datagrid = page
|
||||
.getByLabel('Datagrid of the documents page 1')
|
||||
.getByRole('table');
|
||||
|
||||
const thead = datagrid.locator('thead');
|
||||
await expect(thead.getByText(/Document name/i)).toBeVisible();
|
||||
await expect(thead.getByText(/Created at/i)).toBeVisible();
|
||||
await expect(thead.getByText(/Updated at/i)).toBeVisible();
|
||||
await expect(thead.getByText(/Your role/i)).toBeVisible();
|
||||
await expect(thead.getByText(/Users number/i)).toBeVisible();
|
||||
|
||||
const row1 = datagrid.getByRole('row').nth(1).getByRole('cell');
|
||||
const docName = await row1.nth(1).textContent();
|
||||
expect(docName).toBeDefined();
|
||||
|
||||
const docCreatedAt = await row1.nth(2).textContent();
|
||||
expect(docCreatedAt).toBeDefined();
|
||||
|
||||
const docUpdatedAt = await row1.nth(3).textContent();
|
||||
expect(docUpdatedAt).toBeDefined();
|
||||
|
||||
const docRole = await row1.nth(4).textContent();
|
||||
expect(
|
||||
docRole &&
|
||||
['Administrator', 'Owner', 'Reader', 'Editor'].includes(docRole),
|
||||
).toBeTruthy();
|
||||
|
||||
const docUserNumber = await row1.nth(5).textContent();
|
||||
expect(docUserNumber).toBeDefined();
|
||||
|
||||
// Open the document
|
||||
await row1.nth(1).click();
|
||||
|
||||
await expect(page.locator('h2').getByText(docName!)).toBeVisible();
|
||||
});
|
||||
|
||||
[
|
||||
{
|
||||
nameColumn: 'Document name',
|
||||
ordering: 'title',
|
||||
cellNumber: 1,
|
||||
},
|
||||
{
|
||||
nameColumn: 'Created at',
|
||||
ordering: 'created_at',
|
||||
cellNumber: 2,
|
||||
},
|
||||
{
|
||||
nameColumn: 'Updated at',
|
||||
ordering: 'updated_at',
|
||||
cellNumber: 3,
|
||||
},
|
||||
].forEach(({ nameColumn, ordering, cellNumber }) => {
|
||||
test(`checks datagrid ordering ${ordering}`, async ({ page }) => {
|
||||
const responsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes(`/documents/?page=1`) &&
|
||||
response.status() === 200,
|
||||
);
|
||||
|
||||
const responsePromiseOrderingDesc = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes(`/documents/?page=1&ordering=-${ordering}`) &&
|
||||
response.status() === 200,
|
||||
);
|
||||
|
||||
const responsePromiseOrderingAsc = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes(`/documents/?page=1&ordering=${ordering}`) &&
|
||||
response.status() === 200,
|
||||
);
|
||||
|
||||
const datagrid = page
|
||||
.getByLabel('Datagrid of the documents page 1')
|
||||
.getByRole('table');
|
||||
const thead = datagrid.locator('thead');
|
||||
|
||||
const response = await responsePromise;
|
||||
expect(response.ok()).toBeTruthy();
|
||||
|
||||
const docNameRow1 = datagrid
|
||||
.getByRole('row')
|
||||
.nth(1)
|
||||
.getByRole('cell')
|
||||
.nth(cellNumber);
|
||||
const docNameRow2 = datagrid
|
||||
.getByRole('row')
|
||||
.nth(2)
|
||||
.getByRole('cell')
|
||||
.nth(cellNumber);
|
||||
|
||||
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
|
||||
|
||||
// Initial state
|
||||
await expect(docNameRow1).toHaveText(/.*/);
|
||||
await expect(docNameRow2).toHaveText(/.*/);
|
||||
const initialDocNameRow1 = await docNameRow1.textContent();
|
||||
const initialDocNameRow2 = await docNameRow2.textContent();
|
||||
|
||||
expect(initialDocNameRow1).toBeDefined();
|
||||
expect(initialDocNameRow2).toBeDefined();
|
||||
|
||||
// Ordering ASC
|
||||
await thead.getByText(nameColumn).click();
|
||||
|
||||
const responseOrderingAsc = await responsePromiseOrderingAsc;
|
||||
expect(responseOrderingAsc.ok()).toBeTruthy();
|
||||
|
||||
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
|
||||
|
||||
await expect(docNameRow1).toHaveText(/.*/);
|
||||
await expect(docNameRow2).toHaveText(/.*/);
|
||||
const textDocNameRow1Asc = await docNameRow1.textContent();
|
||||
const textDocNameRow2Asc = await docNameRow2.textContent();
|
||||
expect(
|
||||
textDocNameRow1Asc &&
|
||||
textDocNameRow2Asc &&
|
||||
textDocNameRow1Asc.toLocaleLowerCase() <=
|
||||
textDocNameRow2Asc.toLocaleLowerCase(),
|
||||
).toBeTruthy();
|
||||
|
||||
// Ordering Desc
|
||||
await thead.getByText(nameColumn).click();
|
||||
|
||||
const responseOrderingDesc = await responsePromiseOrderingDesc;
|
||||
expect(responseOrderingDesc.ok()).toBeTruthy();
|
||||
|
||||
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
|
||||
|
||||
await expect(docNameRow1).toHaveText(/.*/);
|
||||
await expect(docNameRow2).toHaveText(/.*/);
|
||||
const textDocNameRow1Desc = await docNameRow1.textContent();
|
||||
const textDocNameRow2Desc = await docNameRow2.textContent();
|
||||
|
||||
expect(
|
||||
textDocNameRow1Desc &&
|
||||
textDocNameRow2Desc &&
|
||||
textDocNameRow1Desc.toLocaleLowerCase() >=
|
||||
textDocNameRow2Desc.toLocaleLowerCase(),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test('checks the pagination', async ({ page }) => {
|
||||
const responsePromisePage1 = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes(`/documents/?page=1`) &&
|
||||
response.status() === 200,
|
||||
);
|
||||
|
||||
const responsePromisePage2 = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes(`/documents/?page=2`) &&
|
||||
response.status() === 200,
|
||||
);
|
||||
|
||||
const datagridPage1 = page
|
||||
.getByLabel('Datagrid of the documents page 1')
|
||||
.getByRole('table');
|
||||
|
||||
const responsePage1 = await responsePromisePage1;
|
||||
expect(responsePage1.ok()).toBeTruthy();
|
||||
|
||||
const docNamePage1 = datagridPage1
|
||||
.getByRole('row')
|
||||
.nth(1)
|
||||
.getByRole('cell')
|
||||
.nth(1);
|
||||
|
||||
await expect(docNamePage1).toHaveText(/.*/);
|
||||
const textDocNamePage1 = await docNamePage1.textContent();
|
||||
|
||||
await page.getByLabel('Go to page 2').click();
|
||||
|
||||
const datagridPage2 = page
|
||||
.getByLabel('Datagrid of the documents page 2')
|
||||
.getByRole('table');
|
||||
|
||||
const responsePage2 = await responsePromisePage2;
|
||||
expect(responsePage2.ok()).toBeTruthy();
|
||||
|
||||
const docNamePage2 = datagridPage2
|
||||
.getByRole('row')
|
||||
.nth(1)
|
||||
.getByRole('cell')
|
||||
.nth(1);
|
||||
|
||||
await expect(datagridPage2.getByLabel('Loading data')).toBeHidden();
|
||||
|
||||
await expect(docNamePage2).toHaveText(/.*/);
|
||||
const textDocNamePage2 = await docNamePage2.textContent();
|
||||
|
||||
expect(textDocNamePage1 !== textDocNamePage2).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -213,8 +213,8 @@ input:-webkit-autofill:focus {
|
||||
|
||||
.c__datagrid__table__container > table tbody tr {
|
||||
border: none;
|
||||
border-top: 1px var(--c--theme--colors--greyscale-300) solid;
|
||||
border-bottom: 1px var(--c--theme--colors--greyscale-300) solid;
|
||||
border-top: 1px var(--c--theme--colors--greyscale-100) solid;
|
||||
border-bottom: 1px var(--c--theme--colors--greyscale-100) solid;
|
||||
}
|
||||
|
||||
.c__datagrid__table__container > table tbody {
|
||||
@@ -227,6 +227,14 @@ input:-webkit-autofill:focus {
|
||||
);
|
||||
}
|
||||
|
||||
.c__datagrid__table__container > table {
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
.c__datagrid__table__container > table td {
|
||||
white-space: break-spaces;
|
||||
}
|
||||
|
||||
.c__datagrid__table__container > table th:first-child,
|
||||
.c__datagrid__table__container > table td:first-child {
|
||||
padding-left: 2rem;
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
import {
|
||||
DefinedInitialDataInfiniteOptions,
|
||||
InfiniteData,
|
||||
QueryKey,
|
||||
useInfiniteQuery,
|
||||
} from '@tanstack/react-query';
|
||||
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
|
||||
import { Doc } from '@/features/docs/doc-management';
|
||||
|
||||
export enum DocsOrdering {
|
||||
BY_CREATED_ON = 'created_at',
|
||||
BY_CREATED_ON_DESC = '-created_at',
|
||||
}
|
||||
export const isDocsOrdering = (data: string): data is DocsOrdering => {
|
||||
return !!docsOrdering.find((validKey) => validKey === data);
|
||||
};
|
||||
|
||||
const docsOrdering = [
|
||||
'created_at',
|
||||
'-created_at',
|
||||
'updated_at',
|
||||
'-updated_at',
|
||||
'title',
|
||||
'-title',
|
||||
] as const;
|
||||
|
||||
export type DocsOrdering = (typeof docsOrdering)[number];
|
||||
|
||||
export type DocsParams = {
|
||||
ordering: DocsOrdering;
|
||||
};
|
||||
type DocsAPIParams = DocsParams & {
|
||||
page: number;
|
||||
ordering?: DocsOrdering;
|
||||
};
|
||||
|
||||
export type DocsResponse = APIList<Doc>;
|
||||
@@ -25,7 +28,7 @@ export type DocsResponse = APIList<Doc>;
|
||||
export const getDocs = async ({
|
||||
ordering,
|
||||
page,
|
||||
}: DocsAPIParams): Promise<DocsResponse> => {
|
||||
}: DocsParams): Promise<DocsResponse> => {
|
||||
const orderingQuery = ordering ? `&ordering=${ordering}` : '';
|
||||
const response = await fetchAPI(`documents/?page=${page}${orderingQuery}`);
|
||||
|
||||
@@ -39,32 +42,12 @@ export const getDocs = async ({
|
||||
export const KEY_LIST_DOC = 'docs';
|
||||
|
||||
export function useDocs(
|
||||
param: DocsParams,
|
||||
queryConfig?: DefinedInitialDataInfiniteOptions<
|
||||
DocsResponse,
|
||||
APIError,
|
||||
InfiniteData<DocsResponse>,
|
||||
QueryKey,
|
||||
number
|
||||
>,
|
||||
params: DocsParams,
|
||||
queryConfig?: UseQueryOptions<DocsResponse, APIError, DocsResponse>,
|
||||
) {
|
||||
return useInfiniteQuery<
|
||||
DocsResponse,
|
||||
APIError,
|
||||
InfiniteData<DocsResponse>,
|
||||
QueryKey,
|
||||
number
|
||||
>({
|
||||
initialPageParam: 1,
|
||||
queryKey: [KEY_LIST_DOC, param],
|
||||
queryFn: ({ pageParam }) =>
|
||||
getDocs({
|
||||
...param,
|
||||
page: pageParam,
|
||||
}),
|
||||
getNextPageParam(lastPage, allPages) {
|
||||
return lastPage.next ? allPages.length + 1 : undefined;
|
||||
},
|
||||
return useQuery<DocsResponse, APIError, DocsResponse>({
|
||||
queryKey: [KEY_LIST_DOC, params],
|
||||
queryFn: () => getDocs(params),
|
||||
...queryConfig,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { DataGrid, SortModel, usePagination } from '@openfun/cunningham-react';
|
||||
import { DateTime, DateTimeFormatOptions } from 'luxon';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { createGlobalStyle } from 'styled-components';
|
||||
|
||||
import { Card, StyledLink, Text, TextErrors } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import {
|
||||
Doc,
|
||||
DocsOrdering,
|
||||
currentDocRole,
|
||||
isDocsOrdering,
|
||||
useDocs,
|
||||
useTransRole,
|
||||
} from '@/features/docs/doc-management';
|
||||
|
||||
import { PAGE_SIZE } from '../conf';
|
||||
|
||||
const DocsGridStyle = createGlobalStyle`
|
||||
& .c__datagrid{
|
||||
max-height: 91%;
|
||||
}
|
||||
& .c__datagrid thead{
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #fff;
|
||||
}
|
||||
& .c__pagination__goto{
|
||||
display:none;
|
||||
}
|
||||
`;
|
||||
|
||||
type SortModelItem = {
|
||||
field: string;
|
||||
sort: 'asc' | 'desc' | null;
|
||||
};
|
||||
|
||||
function formatSortModel(sortModel: SortModelItem): DocsOrdering | undefined {
|
||||
const { field, sort } = sortModel;
|
||||
const orderingField = sort === 'desc' ? `-${field}` : field;
|
||||
|
||||
if (isDocsOrdering(orderingField)) {
|
||||
return orderingField;
|
||||
}
|
||||
}
|
||||
|
||||
const format: DateTimeFormatOptions = {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
};
|
||||
|
||||
export const DocsGrid = () => {
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
const transRole = useTransRole();
|
||||
const { t, i18n } = useTranslation();
|
||||
const pagination = usePagination({
|
||||
pageSize: PAGE_SIZE,
|
||||
});
|
||||
const [sortModel, setSortModel] = useState<SortModel>([]);
|
||||
const { page, pageSize, setPagesCount } = pagination;
|
||||
const [docs, setDocs] = useState<Doc[]>([]);
|
||||
|
||||
const ordering = sortModel.length ? formatSortModel(sortModel[0]) : undefined;
|
||||
|
||||
const { data, isLoading, error } = useDocs({
|
||||
page,
|
||||
ordering,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDocs(data?.results || []);
|
||||
}, [data?.results, t, isLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
setPagesCount(data?.count ? Math.ceil(data.count / pageSize) : 0);
|
||||
}, [data?.count, pageSize, setPagesCount]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
$padding={{ bottom: 'small', horizontal: 'big' }}
|
||||
$margin={{ all: 'big', top: 'none' }}
|
||||
$overflow="auto"
|
||||
aria-label={t(`Datagrid of the documents page {{page}}`, { page })}
|
||||
>
|
||||
<DocsGridStyle />
|
||||
<Text
|
||||
$weight="bold"
|
||||
as="h2"
|
||||
$theme="primary"
|
||||
$margin={{ bottom: 'none' }}
|
||||
>
|
||||
{t('Documents')}
|
||||
</Text>
|
||||
|
||||
{error && <TextErrors causes={error.cause} />}
|
||||
|
||||
<DataGrid
|
||||
columns={[
|
||||
{
|
||||
headerName: '',
|
||||
id: 'visibility',
|
||||
size: 95,
|
||||
renderCell: ({ row }) => {
|
||||
return (
|
||||
<StyledLink href={`/docs/${row.id}`}>
|
||||
{row.is_public && (
|
||||
<Text
|
||||
$weight="bold"
|
||||
$background={colorsTokens()['primary-600']}
|
||||
$color="white"
|
||||
$padding="xtiny"
|
||||
$radius="3px"
|
||||
>
|
||||
{row.is_public ? 'Public' : ''}
|
||||
</Text>
|
||||
)}
|
||||
</StyledLink>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
headerName: t('Document name'),
|
||||
field: 'title',
|
||||
renderCell: ({ row }) => {
|
||||
return (
|
||||
<StyledLink href={`/docs/${row.id}`}>
|
||||
<Text $weight="bold" $theme="primary">
|
||||
{row.title}
|
||||
</Text>
|
||||
</StyledLink>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
headerName: t('Created at'),
|
||||
field: 'created_at',
|
||||
renderCell: ({ row }) => {
|
||||
const created_at = DateTime.fromISO(row.created_at)
|
||||
.setLocale(i18n.language)
|
||||
.toLocaleString(format);
|
||||
|
||||
return (
|
||||
<StyledLink href={`/docs/${row.id}`}>
|
||||
<Text $weight="bold">{created_at}</Text>
|
||||
</StyledLink>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
headerName: t('Updated at'),
|
||||
field: 'updated_at',
|
||||
renderCell: ({ row }) => {
|
||||
const updated_at = DateTime.fromISO(row.updated_at)
|
||||
.setLocale(i18n.language)
|
||||
.toLocaleString(format);
|
||||
|
||||
return (
|
||||
<StyledLink href={`/docs/${row.id}`}>
|
||||
<Text $weight="bold">{updated_at}</Text>
|
||||
</StyledLink>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
headerName: t('Your role'),
|
||||
id: 'your_role',
|
||||
renderCell: ({ row }) => {
|
||||
return (
|
||||
<StyledLink href={`/docs/${row.id}`}>
|
||||
<Text $weight="bold">{transRole(currentDocRole(row))}</Text>
|
||||
</StyledLink>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
headerName: t('Users number'),
|
||||
id: 'users_number',
|
||||
renderCell: ({ row }) => {
|
||||
return (
|
||||
<StyledLink href={`/docs/${row.id}`}>
|
||||
<Text $weight="bold">{row.accesses.length}</Text>
|
||||
</StyledLink>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
rows={docs}
|
||||
isLoading={isLoading}
|
||||
pagination={pagination}
|
||||
onSortModelChange={setSortModel}
|
||||
sortModel={sortModel}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Card, StyledLink } from '@/components';
|
||||
|
||||
import { DocsGrid } from './DocsGrid';
|
||||
|
||||
export const DocsGridContainer = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Box $overflow="auto">
|
||||
<Card $margin="big" $padding="tiny">
|
||||
<Box $align="flex-end" $justify="center">
|
||||
<StyledLink href="/docs/create">
|
||||
<Button>{t('Create a new document')}</Button>
|
||||
</StyledLink>
|
||||
</Box>
|
||||
</Card>
|
||||
<DocsGrid />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './DocsGridContainer';
|
||||
@@ -0,0 +1 @@
|
||||
export const PAGE_SIZE = 20;
|
||||
@@ -0,0 +1 @@
|
||||
export * from './components';
|
||||
@@ -1,30 +1,15 @@
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { Box, StyledLink } from '@/components';
|
||||
import { DocLayout } from '@/layouts';
|
||||
import { DocsGridContainer } from '@/features/docs/docs-grid';
|
||||
import { MainLayout } from '@/layouts';
|
||||
import { NextPageWithLayout } from '@/types/next';
|
||||
|
||||
const StyledButton = styled(Button)`
|
||||
width: fit-content;
|
||||
`;
|
||||
|
||||
const Page: NextPageWithLayout = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box $align="center" $justify="center" $height="inherit">
|
||||
<StyledLink href="/docs/create">
|
||||
<StyledButton>{t('Create a new document')}</StyledButton>
|
||||
</StyledLink>
|
||||
</Box>
|
||||
);
|
||||
return <DocsGridContainer />;
|
||||
};
|
||||
|
||||
Page.getLayout = function getLayout(page: ReactElement) {
|
||||
return <DocLayout>{page}</DocLayout>;
|
||||
return <MainLayout>{page}</MainLayout>;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
Reference in New Issue
Block a user