diff --git a/CHANGELOG.md b/CHANGELOG.md index 35b1d2090..80c1ae7ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ and this project adheres to - ♿️(frontend) add contextual browser tab titles for docs routes #2120 - ♿️(frontend) fix empty heading before section titles in HTML export #2125 +### Fixed + +- 🐛(frontend) fix tree pagination #2145 + + ## [v4.8.4] - 2026-03-25 ### Added diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts index af64a9c52..24178783c 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts @@ -9,6 +9,7 @@ import { } from './utils-common'; import { addNewMember } from './utils-share'; import { + addChild, clickOnAddRootSubPage, createRootSubPage, getTreeRow, @@ -19,6 +20,137 @@ test.describe('Doc Tree', () => { await page.goto('/'); }); + test('check the tree pagination', async ({ page, browserName }) => { + await page.route(/.*\/documents\/.*\/children\//, async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const pageId = url.searchParams.get('page') ?? '1'; + + const response = { + count: 40, + next: `http://localhost:8071/api/v1.0/documents/anything/children/?page=${parseInt(pageId) + 1}`, + previous: + parseInt(pageId) > 1 + ? `http://localhost:8071/api/v1.0/documents/anything/children/?page=${parseInt(pageId) - 1}` + : null, + results: Array.from({ length: 20 }, (_, i) => ({ + id: `doc-child-${pageId}-${i}`, + abilities: { + accesses_manage: true, + accesses_view: true, + ai_proxy: true, + ai_transform: true, + ai_translate: true, + attachment_upload: true, + media_check: true, + can_edit: true, + children_list: true, + children_create: true, + collaboration_auth: true, + comment: true, + content: true, + cors_proxy: true, + descendants: true, + destroy: true, + duplicate: true, + favorite: true, + link_configuration: true, + invite_owner: true, + mask: true, + move: true, + partial_update: true, + restore: true, + retrieve: true, + media_auth: true, + link_select_options: { + restricted: null, + authenticated: ['reader', 'commenter', 'editor'], + public: ['reader', 'commenter', 'editor'], + }, + tree: true, + update: true, + versions_destroy: true, + versions_list: true, + versions_retrieve: true, + search: true, + }, + ancestors_link_reach: 'restricted', + ancestors_link_role: null, + computed_link_reach: 'restricted', + computed_link_role: null, + created_at: '2026-03-27T14:44:12.398544Z', + creator: '40d339e9-cd97-4fdc-b65f-0a809c7e2db9', + deleted_at: null, + depth: 3, + excerpt: null, + is_favorite: false, + link_role: 'reader', + link_reach: 'restricted', + nb_accesses_ancestors: 1, + nb_accesses_direct: 0, + numchild: 0, + path: `000000p00000010000001-${pageId}-${i}`, + title: `doc-child-${pageId}-${i}`, + updated_at: '2026-03-27T14:44:26.691903Z', + user_role: 'owner', + })), + }; + + if (request.method().includes('GET')) { + await route.fulfill({ + json: response, + }); + } else { + await route.continue(); + } + }); + + const [title] = await createDoc( + page, + 'doc-tree-pagination', + browserName, + 1, + ); + + const pageParentUrl = page.url(); + + const titleChild = await addChild({ + page, + browserName, + docParent: title, + docName: 'doc-tree-pagination-child', + }); + + await addChild({ + page, + browserName, + docParent: titleChild, + docName: 'doc-tree-pagination-child-2', + }); + + await page.goto(pageParentUrl); + + await verifyDocName(page, title); + + const docTree = page.getByTestId('doc-tree'); + await expect(docTree).toBeVisible(); + await docTree.getByText('keyboard_arrow_right').click(); + await docTree + .getByRole('button', { + name: `Open document ${titleChild}`, + }) + .click(); + + await expect(docTree.getByText('doc-child-1-19')).toBeVisible(); + await expect(docTree.locator('.c__spinner')).toBeVisible(); + await docTree.getByText('doc-child-1-19').hover(); + await expect( + docTree.getByText('doc-child-2-1', { + exact: true, + }), + ).toBeVisible(); + }); + test('check the reorder of sub pages', async ({ page, browserName }) => { await createDoc(page, 'doc-tree-content', browserName, 1); const addButton = page.getByTestId('new-doc-button'); diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx index 8203bfef8..818b57ec0 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx @@ -1,11 +1,13 @@ import { + Spinner, TreeViewDataType, TreeViewItem, TreeViewNodeProps, + TreeViewNodeTypeEnum, useTreeContext, } from '@gouvfr-lasuite/ui-kit'; import { useRouter } from 'next/navigation'; -import { useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; @@ -20,6 +22,8 @@ import { import { useLeftPanelStore } from '@/features/left-panel'; import { useResponsiveStore } from '@/stores'; +import { isDocNode } from '../utils'; + import SubPageIcon from './../assets/sub-page-logo.svg'; import { DocTreeItemActions } from './DocTreeItemActions'; @@ -34,6 +38,65 @@ const ItemTextCss = css` `; export const DocSubPageItem = (props: TreeViewNodeProps) => { + if (props.node.data.value.nodeType === TreeViewNodeTypeEnum.VIEW_MORE) { + return ; + } + + if (!isDocNode(props.node.data.value)) { + return ; + } + + return ; +}; + +const DocSubPageLoadMore = (props: TreeViewNodeProps) => { + const treeContext = useTreeContext(); + const loaderRef = useRef(null); + const inFlightRef = useRef(false); + + /** + * Use IntersectionObserver to trigger loading more children when the "Load More" item comes into view. + * This allows for infinite scrolling of child nodes without needing a "Load More" button click. + * The observer is disconnected when the component unmounts to prevent memory leaks. + */ + useEffect(() => { + const el = loaderRef.current; + const parentKey = props.node.data.parentKey; + if (!el || !parentKey) { + return; + } + + const observer = new IntersectionObserver( + ([entry]) => { + if (!entry.isIntersecting || inFlightRef.current) { + return; + } + inFlightRef.current = true; + void treeContext?.treeData.handleLoadChildren(parentKey).finally(() => { + inFlightRef.current = false; + }); + }, + { threshold: 0.1 }, + ); + + observer.observe(el); + return () => observer.disconnect(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + + + ); +}; + +const DocSubPageItemContent = (props: TreeViewNodeProps) => { const doc = props.node.data.value as Doc; const treeContext = useTreeContext(); const { untitledDocument } = useTrans(); diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx index 5c2d19173..6a907aaad 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx @@ -22,7 +22,7 @@ import { TreeSkeleton } from '@/features/skeletons/components/TreeSkeleton'; import { CLASS_DOC_TITLE } from '../../doc-header'; import { KEY_DOC_TREE, useDocTree } from '../api/useDocTree'; -import { findIndexInTree } from '../utils'; +import { findIndexInTree, isDocNode } from '../utils'; import { DocSubPageItem } from './DocSubPageItem'; import { DocTreeItemActions } from './DocTreeItemActions'; @@ -406,15 +406,17 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => { undefined } canDrop={({ parentNode }) => { - const parentDoc = parentNode?.data.value as Doc; - if (!parentDoc) { + const parentValue = parentNode?.data.value; + if (!parentValue || !isDocNode(parentValue)) { return currentDoc.abilities.move && isDesktop; } - return parentDoc.abilities.move && isDesktop; + return parentValue.abilities.move && isDesktop; }} canDrag={(node) => { - const doc = node.value as Doc; - return doc.abilities.move && isDesktop; + if (!isDocNode(node.value)) { + return false; + } + return node.value.abilities.move && isDesktop; }} rootNodeId={treeContext.root.id} renderNode={DocSubPageItem} diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/utils.ts b/src/frontend/apps/impress/src/features/docs/doc-tree/utils.ts index 510baa90b..61ce11f44 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-tree/utils.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/utils.ts @@ -1,7 +1,21 @@ -import { TreeDataItem, TreeViewDataType } from '@gouvfr-lasuite/ui-kit'; +import { + TreeDataItem, + TreeViewDataType, + TreeViewNodeTypeEnum, +} from '@gouvfr-lasuite/ui-kit'; import { Doc } from '../doc-management'; +/** + * Type guard to check if a tree node value is a Doc (as opposed to a + * ui-kit synthetic node like VIEW_MORE, SEPARATOR, TITLE, or SIMPLE_NODE). + */ +export const isDocNode = ( + value: TreeViewDataType, +): value is TreeViewDataType & Doc => { + return !value.nodeType || value.nodeType === TreeViewNodeTypeEnum.NODE; +}; + export const subPageToTree = (children: Doc[]): TreeViewDataType[] => { children.forEach((child) => { child.childrenCount = child.numchild ?? 0; diff --git a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx index 3b348a458..cc5b4ad00 100644 --- a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx +++ b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx @@ -56,7 +56,11 @@ export function DocLayout() { const doc = await getDocChildren({ docId, page }); return { children: subPageToTree(doc.results), - hasMore: !!doc.next, + pagination: { + currentPage: page, + hasMore: !!doc.next, + totalCount: doc.count, + }, }; }} >