diff --git a/CHANGELOG.md b/CHANGELOG.md index 85a745ba..da25ad2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid.spec.ts new file mode 100644 index 00000000..b827a942 --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid.spec.ts @@ -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(); + }); +}); diff --git a/src/frontend/apps/impress/src/cunningham/cunningham-style.css b/src/frontend/apps/impress/src/cunningham/cunningham-style.css index ddf9ec49..84c54bde 100644 --- a/src/frontend/apps/impress/src/cunningham/cunningham-style.css +++ b/src/frontend/apps/impress/src/cunningham/cunningham-style.css @@ -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; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx index c9d5b18d..d75c5b30 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx @@ -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; @@ -25,7 +28,7 @@ export type DocsResponse = APIList; export const getDocs = async ({ ordering, page, -}: DocsAPIParams): Promise => { +}: DocsParams): Promise => { 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, - QueryKey, - number - >, + params: DocsParams, + queryConfig?: UseQueryOptions, ) { - return useInfiniteQuery< - DocsResponse, - APIError, - InfiniteData, - 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({ + queryKey: [KEY_LIST_DOC, params], + queryFn: () => getDocs(params), ...queryConfig, }); } diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGrid.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGrid.tsx new file mode 100644 index 00000000..191bd09d --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGrid.tsx @@ -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([]); + const { page, pageSize, setPagesCount } = pagination; + const [docs, setDocs] = useState([]); + + 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 ( + + + + {t('Documents')} + + + {error && } + + { + return ( + + {row.is_public && ( + + {row.is_public ? 'Public' : ''} + + )} + + ); + }, + }, + { + headerName: t('Document name'), + field: 'title', + renderCell: ({ row }) => { + return ( + + + {row.title} + + + ); + }, + }, + { + headerName: t('Created at'), + field: 'created_at', + renderCell: ({ row }) => { + const created_at = DateTime.fromISO(row.created_at) + .setLocale(i18n.language) + .toLocaleString(format); + + return ( + + {created_at} + + ); + }, + }, + { + headerName: t('Updated at'), + field: 'updated_at', + renderCell: ({ row }) => { + const updated_at = DateTime.fromISO(row.updated_at) + .setLocale(i18n.language) + .toLocaleString(format); + + return ( + + {updated_at} + + ); + }, + }, + { + headerName: t('Your role'), + id: 'your_role', + renderCell: ({ row }) => { + return ( + + {transRole(currentDocRole(row))} + + ); + }, + }, + { + headerName: t('Users number'), + id: 'users_number', + renderCell: ({ row }) => { + return ( + + {row.accesses.length} + + ); + }, + }, + ]} + rows={docs} + isLoading={isLoading} + pagination={pagination} + onSortModelChange={setSortModel} + sortModel={sortModel} + /> + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridContainer.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridContainer.tsx new file mode 100644 index 00000000..1393d860 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridContainer.tsx @@ -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 ( + + + + + + + + + + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/index.ts b/src/frontend/apps/impress/src/features/docs/docs-grid/components/index.ts new file mode 100644 index 00000000..5847f905 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/index.ts @@ -0,0 +1 @@ +export * from './DocsGridContainer'; diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/conf.ts b/src/frontend/apps/impress/src/features/docs/docs-grid/conf.ts new file mode 100644 index 00000000..bfab9067 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/conf.ts @@ -0,0 +1 @@ +export const PAGE_SIZE = 20; diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/index.ts b/src/frontend/apps/impress/src/features/docs/docs-grid/index.ts new file mode 100644 index 00000000..07635cbb --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/index.ts @@ -0,0 +1 @@ +export * from './components'; diff --git a/src/frontend/apps/impress/src/pages/docs/index.tsx b/src/frontend/apps/impress/src/pages/docs/index.tsx index e1a76c9c..86414d94 100644 --- a/src/frontend/apps/impress/src/pages/docs/index.tsx +++ b/src/frontend/apps/impress/src/pages/docs/index.tsx @@ -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 ( - - - {t('Create a new document')} - - - ); + return ; }; Page.getLayout = function getLayout(page: ReactElement) { - return {page}; + return {page}; }; export default Page;