🐛(frontend) fix tree pagination

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.
This commit is contained in:
Anthony LC
2026-03-30 12:14:14 +02:00
parent 79e909cf64
commit 6691167a40
6 changed files with 229 additions and 9 deletions
+5
View File
@@ -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
@@ -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');
@@ -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<Doc>) => {
if (props.node.data.value.nodeType === TreeViewNodeTypeEnum.VIEW_MORE) {
return <DocSubPageLoadMore {...props} />;
}
if (!isDocNode(props.node.data.value)) {
return <TreeViewItem {...props} />;
}
return <DocSubPageItemContent {...props} />;
};
const DocSubPageLoadMore = (props: TreeViewNodeProps<Doc>) => {
const treeContext = useTreeContext<Doc>();
const loaderRef = useRef<HTMLDivElement>(null);
const inFlightRef = useRef<boolean>(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 (
<Box
ref={loaderRef}
$align="center"
$justify="center"
$padding={{ vertical: 'xs' }}
>
<Spinner size="sm" />
</Box>
);
};
const DocSubPageItemContent = (props: TreeViewNodeProps<Doc>) => {
const doc = props.node.data.value as Doc;
const treeContext = useTreeContext<Doc>();
const { untitledDocument } = useTrans();
@@ -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}
@@ -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<Doc>,
): value is TreeViewDataType<Doc> & Doc => {
return !value.nodeType || value.nodeType === TreeViewNodeTypeEnum.NODE;
};
export const subPageToTree = (children: Doc[]): TreeViewDataType<Doc>[] => {
children.forEach((child) => {
child.childrenCount = child.numchild ?? 0;
@@ -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,
},
};
}}
>