From 6691167a407732ccfafc83eabaf6ba0744ce0362 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Fri, 27 Mar 2026 16:11:23 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B(frontend)=20fix=20tree=20paginatio?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a sub-sub-document had more than 20 children, the pagination was not working. This commit fixes the issue by ensuring that the pagination logic is correctly applied to all levels of the document tree. --- CHANGELOG.md | 5 + .../__tests__/app-impress/doc-tree.spec.ts | 132 ++++++++++++++++++ .../doc-tree/components/DocSubPageItem.tsx | 65 ++++++++- .../docs/doc-tree/components/DocTree.tsx | 14 +- .../src/features/docs/doc-tree/utils.ts | 16 ++- .../impress/src/pages/docs/[id]/index.tsx | 6 +- 6 files changed, 229 insertions(+), 9 deletions(-) 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, + }, }; }} >