diff --git a/CHANGELOG.md b/CHANGELOG.md index ac490d64ab..29645d353b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,13 +11,14 @@ and this project adheres to - ♿️(frontend) restore skip to content link after header redesign #2510 - 🌐(i18n) rename cn_CN to zh_CN, add eo_PL and zh_TW locales #2486 -### Fixed - -- 🐛(frontend) redirect homepage to login when homepage feat is disabled #2521 - ### Changed - ♿️(frontend) use semantic `
` structure in document info card #2379 +- ♻️(frontend) refacto of the grid documents #2534 + +### Fixed + +- 🐛(frontend) redirect homepage to login when homepage feat is disabled #2521 ## [v5.4.1] - 2026-07-09 diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-create.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-create.spec.ts index 59096517b9..3f91787fa3 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-create.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-create.spec.ts @@ -18,10 +18,9 @@ test.describe('Doc Create', () => { test('it creates a doc', async ({ page, browserName }) => { const [docTitle] = await createDoc(page, 'my-new-doc', browserName, 1); - await page.waitForFunction( - () => document.title.match(/my-new-doc - Docs/), - { timeout: 5000 }, - ); + await page.waitForFunction(() => document.title.match(/my-new-doc/), { + timeout: 5000, + }); await page.getByRole('button', { name: 'Back to homepage' }).click(); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-move.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-move.spec.ts index 85ffe6e6e1..c57fe07431 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-move.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-move.spec.ts @@ -22,31 +22,32 @@ test.describe('Doc grid move', () => { browserName, }) => { await page.goto('/'); - await createDoc(page, 'Draggable doc', browserName, 1); - await page.getByRole('button', { name: 'Back to homepage' }).click(); - await createDoc(page, 'Droppable doc', browserName, 1); - await page.getByRole('button', { name: 'Back to homepage' }).click(); - - const response = await page.waitForResponse( - (response) => - response.url().endsWith('documents/?page=1') && - response.status() === 200, + const [draggableTitle] = await createDoc( + page, + 'Draggable doc', + browserName, + 1, ); - const responseJson = await response.json(); + const [droppableTitle] = await createDoc( + page, + 'Droppable doc', + browserName, + 1, + ); + await page.getByRole('button', { name: 'Back to homepage' }).click(); - const items = responseJson.results; + const draggableRow = await getGridRow(page, draggableTitle); + const droppableRow = await getGridRow(page, droppableTitle); const docsGrid = page.getByTestId('docs-grid'); await expect(docsGrid).toBeVisible(); await expect(page.getByTestId('grid-loader')).toBeHidden(); - const draggableElement = page.getByTestId(`draggable-doc-${items[1].id}`); - const dropZone = page.getByTestId(`droppable-doc-${items[0].id}`); - await expect(draggableElement).toBeVisible(); - await expect(dropZone).toBeVisible(); + await expect(draggableRow).toBeVisible(); + await expect(droppableRow).toBeVisible(); // Get the position of the elements - const draggableBoundingBox = await draggableElement.boundingBox(); - const dropZoneBoundingBox = await dropZone.boundingBox(); + const draggableBoundingBox = await draggableRow.boundingBox(); + const dropZoneBoundingBox = await droppableRow.boundingBox(); expect(draggableBoundingBox).toBeDefined(); expect(dropZoneBoundingBox).toBeDefined(); @@ -71,10 +72,11 @@ test.describe('Doc grid move', () => { const dragOverlay = page.getByTestId('drag-doc-overlay'); await expect(dragOverlay).toBeVisible(); - await expect(dragOverlay).toHaveText(items[1].title as string); + await expect(dragOverlay).toHaveText(draggableTitle); await page.mouse.up(); await expect(dragOverlay).toBeHidden(); + await expect(page.getByText(draggableTitle)).toBeHidden(); }); test("it checks can't drop when we have not the minimum role", async ({ 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 index 3cef7cd755..49cb3209ae 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid.spec.ts @@ -298,34 +298,6 @@ test.describe('Documents filters', () => { }); test.describe('Documents Grid', () => { - test('checks all the elements are visible', async ({ page }) => { - void page.goto('/'); - - let docs: SmallDoc[]; - const response = await page.waitForResponse( - (response) => - response.url().endsWith('documents/?page=1') && - response.status() === 200, - ); - const result = await response.json(); - docs = result.results as SmallDoc[]; - - await expect(page.getByTestId('grid-loader')).toBeHidden(); - await expect(page.locator('h2').getByText('All docs')).toBeVisible(); - - const thead = page.getByTestId('docs-grid-header'); - await expect(thead.getByText(/Name/i)).toBeVisible(); - await expect(thead.getByText(/Updated at/i)).toBeVisible(); - - await Promise.all( - docs.map(async (doc) => { - await expect( - page.getByTestId(`docs-grid-name-${doc.id}`), - ).toBeVisible(); - }), - ); - }); - test('opens a document with keyboard (Tab + Enter)', async ({ page, browserName, @@ -353,14 +325,14 @@ test.describe('Documents Grid', () => { let docs: SmallDoc[]; const responsePromisePage1 = page.waitForResponse((response) => { return ( - response.url().endsWith(`/documents/?page=1`) && + response.url().endsWith(`/documents/?page=1&ordering=-updated_at`) && response.status() === 200 ); }); const responsePromisePage2 = page.waitForResponse( (response) => - response.url().endsWith(`/documents/?page=2`) && + response.url().endsWith(`/documents/?page=2&ordering=-updated_at`) && response.status() === 200, ); @@ -390,4 +362,42 @@ test.describe('Documents Grid', () => { }), ); }); + + test('it checks the sorting feature', async ({ page, browserName }) => { + await page.goto('/'); + + const [docA] = await createDoc(page, 'a-sorting-feat-aaa', browserName); + const [docB] = await createDoc(page, 'b-sorting-feat-bbb', browserName); + const [docZ] = await createDoc(page, 'z-sorting-feat-zzz', browserName); + + await page.getByRole('button', { name: 'Back to homepage' }).click(); + + const rowFilter = (text: string) => + page.getByTestId('docs-grid').getByRole('listitem').filter({ + hasText: text, + }); + + const row = rowFilter('sorting-feat'); + + // By default, the documents are sorted by descending order (last modified first) + await expect(row.nth(0).getByTestId('doc-title')).toHaveText(docZ); + await expect(row.nth(1).getByTestId('doc-title')).toHaveText(docB); + await expect(row.nth(2).getByTestId('doc-title')).toHaveText(docA); + + // Sort by ascending order - should be empty + await page.getByRole('button', { name: 'Sorted by Last modified' }).click(); + await expect(row).toHaveCount(0); + + // Sort by title ascending + await page.getByRole('button', { name: 'Sort by Name' }).click(); + await expect(rowFilter(docA)).toHaveCount(1); + await expect(rowFilter(docB)).toHaveCount(1); + await expect(rowFilter(docZ)).toHaveCount(0); + + // Sort by title descending + await page.getByRole('button', { name: 'Sorted by Name' }).click(); + await expect(rowFilter(docZ)).toHaveCount(1); + await expect(rowFilter(docA)).toHaveCount(0); + await expect(rowFilter(docB)).toHaveCount(0); + }); }); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-member-create.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-member-create.spec.ts index 37817bc126..351f6f393d 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-member-create.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-member-create.spec.ts @@ -104,7 +104,7 @@ test.describe('Document create member', () => { ).toBeVisible(); // Select email and verify tag - const email = randomName('test@test.fr', browserName, 1)[0]; + const email = randomName('test@test.fr', browserName, 1, true)[0]; await inputSearch.fill(email); await quickSearchContent.getByText(email).click(); await expect(list.getByText(email)).toBeVisible(); @@ -164,7 +164,7 @@ test.describe('Document create member', () => { const inputSearch = page.getByTestId('quick-search-input'); - const [email] = randomName('test@test.fr', browserName, 1); + const [email] = randomName('test@test.fr', browserName, 1, true); await inputSearch.fill(email); await page.getByTestId(`search-user-row-${email}`).click(); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts index b42785719e..2e9bf818e6 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts @@ -88,9 +88,16 @@ export const getOtherBrowserName = (browserName: BrowserName) => { return otherBrowserName; }; -export const randomName = (name: string, browserName: string, length: number) => +export const randomName = ( + name: string, + browserName: string, + length: number, + reverseName = false, +) => Array.from({ length }, (_el, index) => { - return `${browserName}-${Math.floor(Math.random() * 10000)}-${index}-${name}`; + return reverseName + ? `${browserName}-${Math.floor(Math.random() * 10000)}-${index}-${name}` + : `${name}-${browserName}-${Math.floor(Math.random() * 10000)}-${index}`; }); export const openHeaderMenu = async (page: Page) => { @@ -203,9 +210,8 @@ export const getGridRow = async (page: Page, title: string) => { await expect(docsGrid).toBeVisible(); await expect(page.getByTestId('grid-loader')).toBeHidden(); - const rows = docsGrid.getByRole('listitem'); - - const row = rows + const row = docsGrid + .getByRole('listitem') .filter({ hasText: title, }) diff --git a/src/frontend/apps/e2e/__tests__/app-impress/utils-signin.ts b/src/frontend/apps/e2e/__tests__/app-impress/utils-signin.ts index 1c7efeadf2..9dd7c28fa8 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/utils-signin.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/utils-signin.ts @@ -24,15 +24,7 @@ export const customSignIn = async ( fromHome = true, ) => { // Check if already signed in (Silent login or session still valid) - if ( - await page - .locator('header') - .first() - .getByRole('button', { - name: 'Logout', - }) - .isVisible() - ) { + if (await page.getByLabel('User menu').isVisible()) { return; } diff --git a/src/frontend/apps/impress/src/assets/icons/ui-kit/Docs.svg b/src/frontend/apps/impress/src/assets/icons/ui-kit/Docs.svg new file mode 100644 index 0000000000..e465be1eca --- /dev/null +++ b/src/frontend/apps/impress/src/assets/icons/ui-kit/Docs.svg @@ -0,0 +1,311 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/frontend/apps/impress/src/assets/icons/ui-kit/Subdocs.svg b/src/frontend/apps/impress/src/assets/icons/ui-kit/Subdocs.svg new file mode 100644 index 0000000000..c249407867 --- /dev/null +++ b/src/frontend/apps/impress/src/assets/icons/ui-kit/Subdocs.svg @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/frontend/apps/impress/src/assets/icons/ui-kit/arrow-up-down.svg b/src/frontend/apps/impress/src/assets/icons/ui-kit/arrow-up-down.svg index 5567837d9c..e451b510c9 100644 --- a/src/frontend/apps/impress/src/assets/icons/ui-kit/arrow-up-down.svg +++ b/src/frontend/apps/impress/src/assets/icons/ui-kit/arrow-up-down.svg @@ -1,4 +1,10 @@ - - - + + + diff --git a/src/frontend/apps/impress/src/assets/icons/ui-kit/bin.svg b/src/frontend/apps/impress/src/assets/icons/ui-kit/bin.svg new file mode 100644 index 0000000000..6a6fd9f153 --- /dev/null +++ b/src/frontend/apps/impress/src/assets/icons/ui-kit/bin.svg @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/frontend/apps/impress/src/assets/icons/ui-kit/clock.svg b/src/frontend/apps/impress/src/assets/icons/ui-kit/clock.svg index 1f9490559b..06bc5be0dd 100644 --- a/src/frontend/apps/impress/src/assets/icons/ui-kit/clock.svg +++ b/src/frontend/apps/impress/src/assets/icons/ui-kit/clock.svg @@ -1,3 +1,12 @@ - - + + + diff --git a/src/frontend/apps/impress/src/assets/icons/ui-kit/pinned.svg b/src/frontend/apps/impress/src/assets/icons/ui-kit/pinned.svg new file mode 100644 index 0000000000..d711089621 --- /dev/null +++ b/src/frontend/apps/impress/src/assets/icons/ui-kit/pinned.svg @@ -0,0 +1,23 @@ + + + + + diff --git a/src/frontend/apps/impress/src/components/ErrorPage.tsx b/src/frontend/apps/impress/src/components/ErrorPage.tsx index baf380345b..a034ea1b5c 100644 --- a/src/frontend/apps/impress/src/components/ErrorPage.tsx +++ b/src/frontend/apps/impress/src/components/ErrorPage.tsx @@ -79,6 +79,7 @@ export const ErrorPage = ({ maxWidth: '100%', height: 'auto', }} + loading="eager" /> ( +export const Loading = ({ + loaderProps, + ...props +}: BoxProps & { loaderProps?: LoaderProps }) => ( - + ); diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/AlertRestore.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/AlertRestore.tsx index 22c6d61dee..b77a6cb174 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/AlertRestore.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/AlertRestore.tsx @@ -91,7 +91,7 @@ export const AlertRestore = ({ doc }: { doc: Doc }) => { /> } > - Restore + {t('Restore')} )} diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/components/SimpleDocItem.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/components/SimpleDocItem.tsx index 1dc0c2e0b6..fb3dd9e168 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/components/SimpleDocItem.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/components/SimpleDocItem.tsx @@ -1,15 +1,14 @@ import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; -import ArrowSVG from '@/assets/icons/ui-kit/arrow-corner-down-right.svg'; -import { Box, Text } from '@/components'; -import { useCunninghamTheme } from '@/cunningham'; +import { Box, Icon, Text } from '@/components'; import { useDate } from '@/hooks/useDate'; +import DocsIcon from '@/icons/Docs.svg'; +import SubdocsIcon from '@/icons/Subdocs.svg'; +import ArrowIcon from '@/icons/arrow-corner-down-right.svg'; +import PinnedIcon from '@/icons/pinned.svg'; import { useResponsiveStore } from '@/stores'; -import ChildDocument from '../assets/child-document.svg'; -import PinnedDocumentIcon from '../assets/pinned-document.svg'; -import SimpleFileIcon from '../assets/simple-document.svg'; import { useDocUtils, useTrans } from '../hooks'; import { Doc } from '../types'; @@ -39,7 +38,6 @@ export const SimpleDocItem = ({ }: SimpleDocItemProps) => { const { t } = useTranslation(); const { isSmallMobile } = useResponsiveStore(); - const { spacingsTokens } = useCunninghamTheme(); const { untitledDocument } = useTrans(); const { isChild } = useDocUtils(doc); const { relativeDate, formatDate } = useDate(); @@ -55,45 +53,66 @@ export const SimpleDocItem = ({ return ( - + + ) : isChild ? ( + + } + $shrink="0" + /> + ) : ( + + } + $shrink="0" + /> + )} {breadcrumb && ( - 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 index 4948fa5ef5..9dbe888c36 100644 --- 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 @@ -5,17 +5,18 @@ import { InView } from 'react-intersection-observer'; import { css } from 'styled-components'; import AllDocs from '@/assets/icons/doc-all.svg'; -import { Box, Card, Icon, Text } from '@/components'; -import { useInfiniteDocs } from '@/docs/doc-management/api/useDocs'; +import { Box, Card, Icon, Loading, Text } from '@/components'; +import { FadeComponent } from '@/components/Effect'; import { useImport } from '@/docs/doc-management/hooks/useImport'; -import { DocDefaultFilter } from '@/docs/doc-management/types'; +import { DocDefaultFilter, DocsOrdering } from '@/docs/doc-management/types'; +import DocsIcon from '@/icons/Docs.svg'; +import BinIcon from '@/icons/bin.svg'; import { useResponsiveStore } from '@/stores'; -import { useInfiniteDocsTrashbin } from '../api'; -import { useResponsiveDocGrid } from '../hooks/useResponsiveDocGrid'; +import { useDocsGridQuery } from '../api/useDocsGridQuery'; import { DocGridContentList } from './DocGridContentList'; -import { DocsGridLoader } from './DocsGridLoader'; +import { DocsGridColumnName } from './DocsGridColumnName'; type DocsGridProps = { target?: DocDefaultFilter; @@ -42,17 +43,13 @@ export const DocsGrid = ({ target === DocDefaultFilter.MY_DOCS) && isImportEnabled; - const { isDesktop } = useResponsiveStore(); - const { flexLeft, flexRight } = useResponsiveDocGrid(); + const { isDesktop, isSmallMobile } = useResponsiveStore(); - const { - data, - isFetching, - isRefetching, - isLoading, - fetchNextPage, - hasNextPage, - } = useDocsQuery(target); + const [ordering, setOrdering] = useState('-updated_at'); + const canSort = target !== DocDefaultFilter.TRASHBIN; + + const { data, isFetching, isLoading, fetchNextPage, hasNextPage } = + useDocsGridQuery(target, canSort ? ordering : undefined); const docs = useMemo(() => { const allDocs = data?.pages.flatMap((page) => page.results) ?? []; @@ -81,18 +78,21 @@ export const DocsGrid = ({ - - - {!hasDocs && !loading && ( - - - {t('No documents found')} - - - )} - {hasDocs && ( - - - - + + {!hasDocs && !loading && } + + + + + - {hasNextPage && !loading && ( - - {!isFetching && hasNextPage && ( - - )} - - )} - - )} + + {loading && ( + + )} + {hasNextPage && !loading && ( + + {!isFetching && hasNextPage && ( + + )} + + )} + ); }; -const DocGridTitleBar = ({ target }: { target: DocDefaultFilter }) => { +const DocGridTitleBar = ({ + target, + isImportPending, +}: { + target: DocDefaultFilter; + isImportPending: boolean; +}) => { const { t } = useTranslation(); const { isDesktop } = useResponsiveStore(); @@ -197,9 +196,6 @@ const DocGridTitleBar = ({ target }: { target: DocDefaultFilter }) => { vertical: 'sm', horizontal: isDesktop ? 'md' : 'xs', }} - $css={css` - border-bottom: 1px solid var(--c--contextuals--border--surface--primary); - `} $align="center" $justify="space-between" > @@ -208,33 +204,52 @@ const DocGridTitleBar = ({ target }: { target: DocDefaultFilter }) => { {title} + {isImportPending && } ); }; -const useDocsQuery = (target: DocDefaultFilter) => { - const trashbinQuery = useInfiniteDocsTrashbin( - { - page: 1, - }, - { - enabled: target === DocDefaultFilter.TRASHBIN, - }, - ); +const DocGridNoDocs = ({ target }: { target: DocDefaultFilter }) => { + const { t } = useTranslation(); - const docsQuery = useInfiniteDocs( - { - page: 1, - ...(target && - target !== DocDefaultFilter.ALL_DOCS && { - is_creator_me: target === DocDefaultFilter.MY_DOCS, - }), - }, - { - enabled: target !== DocDefaultFilter.TRASHBIN, - }, + return ( + + {[ + DocDefaultFilter.ALL_DOCS, + DocDefaultFilter.MY_DOCS, + DocDefaultFilter.SHARED_WITH_ME, + ].includes(target) && ( + <> + + + {t('No doc yet')} + + {[DocDefaultFilter.ALL_DOCS, DocDefaultFilter.MY_DOCS].includes( + target, + ) && ( + + {t('Your docs will appear here.')} + + )} + {target === DocDefaultFilter.SHARED_WITH_ME && ( + + {t('Your shared docs will appear here.')} + + )} + + )} + {target === DocDefaultFilter.TRASHBIN && ( + <> + + + {t('No doc deleted')} + + + {t('Deleted docs will appear here.')} + + + )} + ); - - return target === DocDefaultFilter.TRASHBIN ? trashbinQuery : docsQuery; }; diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridActions.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridActions.tsx index 430df47114..d4a2b02c41 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridActions.tsx +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridActions.tsx @@ -1,4 +1,8 @@ -import { Button } from '@gouvfr-lasuite/cunningham-react'; +import { + Button, + VariantType, + useToastProvider, +} from '@gouvfr-lasuite/cunningham-react'; import { DropdownMenu, DropdownMenuItem } from '@gouvfr-lasuite/ui-kit'; import dynamic from 'next/dynamic'; import { useState } from 'react'; @@ -12,6 +16,7 @@ import KeepSVG from '@/assets/icons/ui-kit/keep.svg'; import KeepOffSVG from '@/assets/icons/ui-kit/keep_off.svg'; import LeaveSVG from '@/assets/icons/ui-kit/leave.svg'; import MoreSVG from '@/assets/icons/ui-kit/more_horiz.svg'; +import { Icon } from '@/components/Icon'; import { Doc, KEY_LIST_DOC, @@ -19,11 +24,14 @@ import { useCreateFavoriteDoc, useDeleteFavoriteDoc, useDuplicateDoc, + useRestoreDoc, useTrans, } from '@/docs/doc-management'; import { focusMainContentStart } from '@/layouts/utils'; import { useFocusStore } from '@/stores'; +import { KEY_LIST_DOC_TRASHBIN } from '../api/useDocsTrashbin'; + import { DocMoveModal } from './DocMoveModal'; const DocShareModal = dynamic( @@ -54,17 +62,27 @@ const ConfirmationLeaveModal = dynamic( interface DocsGridActionsProps { doc: Doc; + isInTrashbin?: boolean; } -export const DocsGridActions = ({ doc }: DocsGridActionsProps) => { +export const DocsGridActions = ({ + doc, + isInTrashbin, +}: DocsGridActionsProps) => { + return isInTrashbin ? ( + + ) : ( + + ); +}; + +const DocsGridActionsGlobal = ({ doc }: { doc: Doc }) => { const { t } = useTranslation(); - const { restoreFocus, addLastFocus } = useFocusStore(); - const [openDropdown, setOpenDropdown] = useState(false); + const { restoreFocus } = useFocusStore(); const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false); const [isModalLeaveOpen, setIsModalLeaveOpen] = useState(false); const [isModalShareOpen, setIsModalShareOpen] = useState(false); const [isModalMoveOpen, setIsModalMoveOpen] = useState(false); - const { untitledDocument } = useTrans(); const { mutate: duplicateDoc } = useDuplicateDoc({ onSuccess: () => { @@ -150,33 +168,7 @@ export const DocsGridActions = ({ doc }: DocsGridActionsProps) => { return ( <> - true} - onOpenChange={setOpenDropdown} - > - } - color="neutral" - variant="tertiary" - onClick={(e) => { - e.stopPropagation(); - e.preventDefault(); - setOpenDropdown((o) => !o); - addLastFocus(e.currentTarget); - }} - /> - - + {isModalRemoveOpen && ( { @@ -217,3 +209,99 @@ export const DocsGridActions = ({ doc }: DocsGridActionsProps) => { ); }; + +interface DocsGridTrashbinActionsProps { + doc: Doc; +} + +export const DocsGridTrashbinActions = ({ + doc, +}: DocsGridTrashbinActionsProps) => { + const { t } = useTranslation(); + const { toast } = useToastProvider(); + const { mutate: restoreDoc, error } = useRestoreDoc({ + listInvalidQueries: [KEY_LIST_DOC, KEY_LIST_DOC_TRASHBIN], + options: { + onSuccess: (_data) => { + toast(t('The document has been restored.'), VariantType.SUCCESS, { + duration: 4000, + }); + }, + onError: () => { + toast( + t('An error occurred while restoring the document: {{error}}', { + error: error?.message, + }), + VariantType.ERROR, + { + duration: 4000, + }, + ); + }, + }, + }); + + const options: DropdownMenuItem[] = [ + { + label: t('Restore'), + icon: ( + + ), + callback: () => { + restoreDoc({ + docId: doc.id, + }); + }, + testId: `docs-grid-actions-restore-${doc.id}`, + }, + ]; + + if (!doc.abilities.restore) { + return null; + } + + return ; +}; + +interface DocsGridDropdownProps { + doc: Doc; + options: DropdownMenuItem[]; +} + +const DocsGridDropdown = ({ doc, options }: DocsGridDropdownProps) => { + const { t } = useTranslation(); + const [openDropdown, setOpenDropdown] = useState(false); + const { addLastFocus } = useFocusStore(); + const { untitledDocument } = useTrans(); + + return ( + true} + onOpenChange={setOpenDropdown} + > + } + color="neutral" + variant="tertiary" + onClick={(e) => { + e.stopPropagation(); + e.preventDefault(); + setOpenDropdown((o) => !o); + addLastFocus(e.currentTarget); + }} + /> + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridColumnName.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridColumnName.tsx new file mode 100644 index 0000000000..467d911d95 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridColumnName.tsx @@ -0,0 +1,132 @@ +import { Button } from '@gouvfr-lasuite/cunningham-react'; +import { ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { css } from 'styled-components'; + +import { Box, Text } from '@/components'; +import { DocDefaultFilter, DocsOrdering } from '@/docs/doc-management/types'; +import ArrowUpDownIcon from '@/icons/arrow-up-down.svg'; +import ClockIcon from '@/icons/clock.svg'; +import { useResponsiveStore } from '@/stores'; + +type DocsGridColumnNameProps = { + ordering: DocsOrdering; + setOrdering: React.Dispatch>; + target?: DocDefaultFilter; +}; + +export const DocsGridColumnName = ({ + target = DocDefaultFilter.ALL_DOCS, + ordering, + setOrdering, +}: DocsGridColumnNameProps) => { + const { t } = useTranslation(); + const { isSmallMobile } = useResponsiveStore(); + + const canSort = target !== DocDefaultFilter.TRASHBIN; + + const toggleOrdering = (field: 'title' | 'updated_at') => { + setOrdering((prevOrdering) => + prevOrdering === field ? (`-${field}` as DocsOrdering) : field, + ); + }; + + return ( + + + {canSort ? ( + toggleOrdering('title')} + /> + ) : ( + + {t('Name')} + + )} + + {!isSmallMobile && ( + + {canSort ? ( + + {' '} + {t('Last modified')} + + } + ariaLabel={t('Last modified')} + ordering={ordering} + field="updated_at" + onClick={() => toggleOrdering('updated_at')} + /> + ) : ( + + {t('Days remaining')} + + )} + + )} + + ); +}; + +const DocGridSortButton = ({ + label, + ariaLabel, + field, + ordering, + onClick, +}: { + label: ReactNode; + ariaLabel: string; + field: 'title' | 'updated_at'; + ordering: DocsOrdering; + onClick: () => void; +}) => { + const { t } = useTranslation(); + const isDesc = ordering === `-${field}`; + const isActive = ordering === field || isDesc; + + return ( + + + {label} + + } + variant="tertiary" + color={isActive ? 'brand' : 'neutral'} + /> + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItem.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItem.tsx index 2afa56896c..9139d81793 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItem.tsx +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItem.tsx @@ -1,9 +1,10 @@ import { Tooltip } from '@gouvfr-lasuite/cunningham-react'; import { useSearchParams } from 'next/navigation'; -import { KeyboardEvent } from 'react'; +import type { KeyboardEvent } from 'react'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; +import type { BoxType } from '@/components'; import { Box, Icon, StyledLink, Text } from '@/components'; import { useConfig } from '@/core'; import { useCunninghamTheme } from '@/cunningham'; @@ -12,27 +13,27 @@ import { useLeftPanelStore } from '@/features/left-panel'; import { useDate } from '@/hooks'; import { useResponsiveStore } from '@/stores'; -import { useResponsiveDocGrid } from '../hooks/useResponsiveDocGrid'; - import { DocsGridActions } from './DocsGridActions'; import { DocsGridItemSharedButton } from './DocsGridItemSharedButton'; -import { DocsGridTrashbinActions } from './DocsGridTrashbinActions'; -type DocsGridItemProps = { +type DocsGridItemProps = BoxType & { doc: Doc; dragMode?: boolean; }; -export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => { +export const DocsGridItem = ({ + doc, + dragMode = false, + $css, + ...boxProps +}: DocsGridItemProps) => { const searchParams = useSearchParams(); const target = searchParams.get('target'); const isInTrashbin = target === 'trashbin'; const { untitledDocument } = useTrans(); const { t } = useTranslation(); - const { isDesktop, isLargeScreen } = useResponsiveStore(); - const { flexLeft, flexRight } = useResponsiveDocGrid(); - const { spacingsTokens } = useCunninghamTheme(); + const { isSmallMobile, isLargeScreen } = useResponsiveStore(); const dateToDisplay = useDateToDisplay(doc, isInTrashbin); const { openPanel } = useLeftPanelStore(); @@ -53,15 +54,45 @@ export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => { }; return ( - <> + { }; } `} - className="--docs--doc-grid-item" - aria-label={t('Open document: {{title}}', { - title: doc.title || untitledDocument, - })} > { - + {!isSmallMobile && ( { date: dateToDisplay, })} > - + + )} - - {isDesktop && ( - - )} - {isInTrashbin ? ( - - ) : ( - - )} - + e.stopPropagation()} + > + {!isSmallMobile && ( + + )} + - + ); }; @@ -144,7 +162,7 @@ export const DocsGridItemTitle = ({ withTooltip: boolean; }) => { const { t } = useTranslation(); - const { isDesktop } = useResponsiveStore(); + const { isDesktop, isSmallMobile } = useResponsiveStore(); const { spacingsTokens } = useCunninghamTheme(); const isPublic = doc.link_reach === LinkReach.PUBLIC; const isAuthenticated = doc.link_reach === LinkReach.AUTHENTICATED; @@ -159,7 +177,11 @@ export const DocsGridItemTitle = ({ $padding={{ right: isDesktop ? 'md' : '3xs' }} $maxWidth="100%" > - + {isShared && ( { export const DocsGridItemDate = ({ doc, - isDesktop, isInTrashbin, }: { doc: Doc; - isDesktop: boolean; isInTrashbin: boolean; }) => { const dateToDisplay = useDateToDisplay(doc, isInTrashbin); - if (!isDesktop) { - return null; - } - return ( { - if (!isLoading) { - return null; - } - - return ( - <> - - - - - - ); -}; diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridTrashbinActions.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridTrashbinActions.tsx deleted file mode 100644 index 14f68e3578..0000000000 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridTrashbinActions.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { - VariantType, - useToastProvider, -} from '@gouvfr-lasuite/cunningham-react'; -import { useTranslation } from 'react-i18next'; -import { css } from 'styled-components'; - -import { DropdownMenu, DropdownMenuOption, Icon } from '@/components'; -import { - Doc, - KEY_LIST_DOC, - useRestoreDoc, - useTrans, -} from '@/docs/doc-management'; - -import { KEY_LIST_DOC_TRASHBIN } from '../api'; - -interface DocsGridTrashbinActionsProps { - doc: Doc; -} - -export const DocsGridTrashbinActions = ({ - doc, -}: DocsGridTrashbinActionsProps) => { - const { t } = useTranslation(); - const { untitledDocument } = useTrans(); - const { toast } = useToastProvider(); - const { mutate: restoreDoc, error } = useRestoreDoc({ - listInvalidQueries: [KEY_LIST_DOC, KEY_LIST_DOC_TRASHBIN], - options: { - onSuccess: (_data) => { - toast(t('The document has been restored.'), VariantType.SUCCESS, { - duration: 4000, - }); - }, - onError: () => { - toast( - t('An error occurred while restoring the document: {{error}}', { - error: error?.message, - }), - VariantType.ERROR, - { - duration: 4000, - }, - ); - }, - }, - }); - - const options: DropdownMenuOption[] = [ - { - label: t('Restore'), - icon: ( - - ), - callback: () => { - restoreDoc({ - docId: doc.id, - }); - }, - testId: `docs-grid-actions-restore-${doc.id}`, - }, - ]; - - const documentTitle = doc.title || untitledDocument; - const menuLabel = t('Open the menu of actions for the document: {{title}}', { - title: documentTitle, - }); - - return ( - - - - ); -}; diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/Draggable.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/Draggable.tsx deleted file mode 100644 index bd72a1248c..0000000000 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/Draggable.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Data, useDraggable } from '@dnd-kit/core'; -import { PropsWithChildren } from 'react'; - -type DraggableProps = { - id: string; - data?: Data; - disabled?: boolean; -}; - -export const Draggable = (props: PropsWithChildren>) => { - const { attributes, listeners, setNodeRef } = useDraggable({ - id: props.id, - data: props.data, - disabled: props.disabled, - }); - - return ( -
- {props.children} -
- ); -}; diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/Droppable.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/Droppable.tsx deleted file mode 100644 index 38e3465185..0000000000 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/Droppable.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { Data, useDroppable } from '@dnd-kit/core'; -import { PropsWithChildren, useEffect } from 'react'; -import { css } from 'styled-components'; - -import { Box } from '@/components'; -import { Doc } from '@/docs/doc-management'; - -type DroppableProps = { - id: string; - onOver?: (isOver: boolean, data?: Data) => void; - data?: Data; - enabledDrop?: boolean; - canDrop?: boolean; -}; - -export const Droppable = ({ - onOver, - canDrop, - data, - children, - id, -}: PropsWithChildren) => { - const { isOver, setNodeRef } = useDroppable({ - id, - data, - }); - - const enableHover = canDrop && isOver; - - useEffect(() => { - onOver?.(isOver, data); - }, [isOver, data, onOver]); - - return ( - - {children} - - ); -}; diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/__tests__/DocsGridItemDate.test.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/__tests__/DocsGridItemDate.test.tsx index 21476f3012..2e78d0f585 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/__tests__/DocsGridItemDate.test.tsx +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/__tests__/DocsGridItemDate.test.tsx @@ -16,23 +16,6 @@ describe('DocsGridItemDate', () => { fetchMock.mockGlobal(); }); - it('should not render date when not on desktop', () => { - render( - , - { - wrapper: AppWrapper, - }, - ); - - expect(screen.queryByText('1 minute ago')).not.toBeInTheDocument(); - }); - [ { updated_at: DateTime.now().minus({ seconds: 1 }).toISO(), @@ -63,7 +46,6 @@ describe('DocsGridItemDate', () => { updated_at, } as Doc } - isDesktop={true} isInTrashbin={false} />, { wrapper: AppWrapper }, @@ -85,7 +67,6 @@ describe('DocsGridItemDate', () => { updated_at: DateTime.now().minus({ days: 5 }).toISO(), } as Doc } - isDesktop={true} isInTrashbin={false} />, { wrapper: AppWrapper }, @@ -133,7 +114,6 @@ describe('DocsGridItemDate', () => { updated_at, } as Doc } - isDesktop={true} isInTrashbin={true} />, { wrapper: AppWrapper }, 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 index 540d636ccf..bca88917aa 100644 --- 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 @@ -1,3 +1,2 @@ export * from './DocsGrid'; export * from './DocsGridActions'; -export * from './DocsGridLoader'; diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/hooks/useResponsiveDocGrid.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/hooks/useResponsiveDocGrid.tsx deleted file mode 100644 index bedbdd293d..0000000000 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/hooks/useResponsiveDocGrid.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { useMemo } from 'react'; - -import { useResponsiveStore } from '@/stores'; - -export const useResponsiveDocGrid = () => { - const { isDesktop, screenWidth } = useResponsiveStore(); - - const flexLeft = useMemo(() => { - if (!isDesktop) { - return 1; - } else if (screenWidth <= 1100) { - return 6; - } else if (screenWidth < 1200) { - return 8; - } - return 8; - }, [isDesktop, screenWidth]); - - const flexRight = useMemo(() => { - if (!isDesktop) { - return undefined; - } else if (screenWidth <= 1200) { - return 5; - } - return 4; - }, [isDesktop, screenWidth]); - - return { flexLeft, flexRight }; -}; diff --git a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanel.tsx b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanel.tsx index 078b20ace1..032eedfc29 100644 --- a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanel.tsx +++ b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanel.tsx @@ -37,9 +37,10 @@ export const LeftPanel = ({ isResizable }: { isResizable?: boolean }) => { data-testid="left-panel" aria-label={t('Left panel')} $width={isResizable ? '100%' : '300px'} + $height="100dvh" + $overflow="hidden" $css={css` - height: 100dvh; - overflow: hidden; + z-index: 1; background-color: var(--c--contextuals--background--surface--primary); box-shadow: 10px 0px 10px 0px rgba(0, 0, 0, 0.05); transition: diff --git a/src/frontend/apps/impress/src/pages/404.tsx b/src/frontend/apps/impress/src/pages/404.tsx index ade6ec72d4..8939d425b1 100644 --- a/src/frontend/apps/impress/src/pages/404.tsx +++ b/src/frontend/apps/impress/src/pages/404.tsx @@ -46,6 +46,7 @@ const Page: NextPageWithLayout = () => { maxWidth: '100%', height: 'auto', }} + loading="eager" /> {page} diff --git a/src/frontend/apps/impress/src/pages/globals.css b/src/frontend/apps/impress/src/pages/globals.css index c122ac8eec..a535235145 100644 --- a/src/frontend/apps/impress/src/pages/globals.css +++ b/src/frontend/apps/impress/src/pages/globals.css @@ -24,6 +24,12 @@ body > #__next > .c__app > div:has(> .c__loader) { box-sizing: border-box; } +*:focus-visible { + outline: 2px solid var(--c--globals--colors--brand-400); + outline-offset: calc(-1 * var(--c--globals--spacings--4xs)); + border-radius: var(--c--globals--spacings--2xs); +} + main ::-webkit-scrollbar, .ReactModalPortal ::-webkit-scrollbar { width: 20px;