diff --git a/docs/data/data-grid/server-side-data/NestedPaginationGroupingCell.js b/docs/data/data-grid/server-side-data/NestedPaginationGroupingCell.js
new file mode 100644
index 0000000000000..fcb6a9e4e9977
--- /dev/null
+++ b/docs/data/data-grid/server-side-data/NestedPaginationGroupingCell.js
@@ -0,0 +1,166 @@
+import * as React from 'react';
+import composeClasses from '@mui/utils/composeClasses';
+import Box from '@mui/material/Box';
+import Badge from '@mui/material/Badge';
+import {
+ getDataGridUtilityClass,
+ useGridSelector,
+ useGridRootProps,
+} from '@mui/x-data-grid-pro';
+import { useGridPrivateApiContext } from '@mui/x-data-grid-pro/internals';
+import { useGridSelectorV8, createSelectorV8 } from '@mui/x-data-grid/internals';
+import CircularProgress from '@mui/material/CircularProgress';
+
+export const gridDataSourceStateSelector = (state) => state.dataSource;
+
+export const gridDataSourceLoadingIdSelector = createSelectorV8(
+ gridDataSourceStateSelector,
+ (dataSource, id) => dataSource.loading[id] ?? false,
+);
+
+export const gridDataSourceErrorSelector = createSelectorV8(
+ gridDataSourceStateSelector,
+ (dataSource, id) => dataSource.errors[id],
+);
+
+const useUtilityClasses = (ownerState) => {
+ const { classes } = ownerState;
+
+ const slots = {
+ root: ['treeDataGroupingCell'],
+ toggle: ['treeDataGroupingCellToggle'],
+ loadingContainer: ['treeDataGroupingCellLoadingContainer'],
+ };
+
+ return composeClasses(slots, getDataGridUtilityClass, classes);
+};
+
+function GridTreeDataGroupingCellIcon(props) {
+ const apiRef = useGridPrivateApiContext();
+ const rootProps = useGridRootProps();
+ const classes = useUtilityClasses(rootProps);
+ const { rowNode, id, field, descendantCount, row, nestedLevelRef } = props;
+
+ const isDataLoading = useGridSelectorV8(
+ apiRef,
+ gridDataSourceLoadingIdSelector,
+ id,
+ );
+ const error = useGridSelectorV8(apiRef, gridDataSourceErrorSelector, id);
+
+ const expanded = rowNode.childrenExpanded || row.expanded;
+
+ const handleClick = (event) => {
+ if (!expanded) {
+ props.setExpandedRows((prev) => [
+ ...prev,
+ {
+ ...row,
+ groupingKey: rowNode.groupingKey,
+ expanded: true,
+ depth: nestedLevelRef.current,
+ },
+ ]);
+ if (apiRef.current.state.pagination.paginationModel.page > 0) {
+ apiRef.current.setPage(0);
+ }
+ } else if (row.expanded) {
+ props.setExpandedRows((prev) => {
+ const index = prev.findIndex((r) => r.id === id);
+ return prev.slice(0, index);
+ });
+ if (apiRef.current.state.pagination.paginationModel.page > 0) {
+ apiRef.current.setPage(0);
+ }
+ } else {
+ apiRef.current.setRowChildrenExpansion(id, !expanded);
+ }
+ apiRef.current.setCellFocus(id, field);
+ event.stopPropagation(); // TODO remove event.stopPropagation
+ };
+
+ const Icon = expanded
+ ? rootProps.slots.treeDataCollapseIcon
+ : rootProps.slots.treeDataExpandIcon;
+
+ if (isDataLoading) {
+ return (
+
+
+
+ );
+ }
+ return descendantCount > 0 ? (
+
+
+
+
+
+
+
+ ) : null;
+}
+
+export function NestedPaginationGroupingCell(props) {
+ const {
+ id,
+ field,
+ formattedValue,
+ rowNode,
+ hideDescendantCount,
+ offsetMultiplier = 2,
+ setExpandedRows,
+ nestedLevelRef,
+ } = props;
+
+ const rootProps = useGridRootProps();
+ const apiRef = useGridPrivateApiContext();
+ const rowSelector = (state) => state.rows.dataRowIdToModelLookup[id];
+ const row = useGridSelector(apiRef, rowSelector);
+ const classes = useUtilityClasses(rootProps);
+
+ let descendantCount = 0;
+ if (row) {
+ descendantCount = Math.max(
+ rootProps.unstable_dataSource?.getChildrenCount?.(row) ?? 0,
+ 0,
+ );
+ }
+
+ let depth = row.depth ? row.depth : rowNode.depth;
+ if (!row.expanded && nestedLevelRef.current > 0) {
+ depth = nestedLevelRef.current;
+ }
+
+ return (
+
+
+
+
+
+ {formattedValue === undefined
+ ? (rowNode.groupingKey ?? row.groupingKey)
+ : formattedValue}
+ {!hideDescendantCount && descendantCount > 0 ? ` (${descendantCount})` : ''}
+
+
+ );
+}
diff --git a/docs/data/data-grid/server-side-data/NestedPaginationGroupingCell.tsx b/docs/data/data-grid/server-side-data/NestedPaginationGroupingCell.tsx
new file mode 100644
index 0000000000000..a1f68a3b81e3b
--- /dev/null
+++ b/docs/data/data-grid/server-side-data/NestedPaginationGroupingCell.tsx
@@ -0,0 +1,199 @@
+import * as React from 'react';
+import composeClasses from '@mui/utils/composeClasses';
+import Box from '@mui/material/Box';
+import Badge from '@mui/material/Badge';
+import {
+ getDataGridUtilityClass,
+ GridRenderCellParams,
+ GridDataSourceGroupNode,
+ useGridSelector,
+ useGridRootProps,
+ DataGridProProcessedProps,
+ GridPrivateApiPro,
+ GridStatePro,
+} from '@mui/x-data-grid-pro';
+import { useGridPrivateApiContext } from '@mui/x-data-grid-pro/internals';
+import { useGridSelectorV8, createSelectorV8 } from '@mui/x-data-grid/internals';
+import CircularProgress from '@mui/material/CircularProgress';
+
+export const gridDataSourceStateSelector = (state: GridStatePro) => state.dataSource;
+
+export const gridDataSourceLoadingIdSelector = createSelectorV8(
+ gridDataSourceStateSelector,
+ (dataSource, id: GridRowId) => dataSource.loading[id] ?? false,
+);
+
+export const gridDataSourceErrorSelector = createSelectorV8(
+ gridDataSourceStateSelector,
+ (dataSource, id: GridRowId) => dataSource.errors[id],
+);
+
+type OwnerState = DataGridProProcessedProps;
+
+const useUtilityClasses = (ownerState: OwnerState) => {
+ const { classes } = ownerState;
+
+ const slots = {
+ root: ['treeDataGroupingCell'],
+ toggle: ['treeDataGroupingCellToggle'],
+ loadingContainer: ['treeDataGroupingCellLoadingContainer'],
+ };
+
+ return composeClasses(slots, getDataGridUtilityClass, classes);
+};
+
+interface GridTreeDataGroupingCellProps
+ extends GridRenderCellParams {
+ hideDescendantCount?: boolean;
+ /**
+ * The cell offset multiplier used for calculating cell offset (`rowNode.depth * offsetMultiplier` px).
+ * @default 2
+ */
+ offsetMultiplier?: number;
+}
+
+interface GridTreeDataGroupingCellIconProps
+ extends Pick {
+ descendantCount: number;
+}
+
+function GridTreeDataGroupingCellIcon(
+ props: GridTreeDataGroupingCellIconProps & {
+ setExpandedRows: (rows: GridValidRowModel[]) => void;
+ nestedLevelRef: React.RefObject;
+ },
+) {
+ const apiRef =
+ useGridPrivateApiContext() as React.MutableRefObject;
+ const rootProps = useGridRootProps();
+ const classes = useUtilityClasses(rootProps);
+ const { rowNode, id, field, descendantCount, row, nestedLevelRef } = props;
+
+ const isDataLoading = useGridSelectorV8(
+ apiRef,
+ gridDataSourceLoadingIdSelector,
+ id,
+ );
+ const error = useGridSelectorV8(apiRef, gridDataSourceErrorSelector, id);
+
+ const expanded = rowNode.childrenExpanded || row.expanded;
+
+ const handleClick = (event: React.MouseEvent) => {
+ if (!expanded) {
+ props.setExpandedRows((prev) => [
+ ...prev,
+ {
+ ...row,
+ groupingKey: rowNode.groupingKey,
+ expanded: true,
+ depth: nestedLevelRef.current,
+ },
+ ]);
+ if (apiRef.current.state.pagination.paginationModel.page > 0) {
+ apiRef.current.setPage(0);
+ }
+ } else if (row.expanded) {
+ props.setExpandedRows((prev) => {
+ const index = prev.findIndex((r) => r.id === id);
+ return prev.slice(0, index);
+ });
+ if (apiRef.current.state.pagination.paginationModel.page > 0) {
+ apiRef.current.setPage(0);
+ }
+ } else {
+ apiRef.current.setRowChildrenExpansion(id, !expanded);
+ }
+ apiRef.current.setCellFocus(id, field);
+ event.stopPropagation(); // TODO remove event.stopPropagation
+ };
+
+ const Icon = expanded
+ ? rootProps.slots.treeDataCollapseIcon
+ : rootProps.slots.treeDataExpandIcon;
+
+ if (isDataLoading) {
+ return (
+
+
+
+ );
+ }
+ return descendantCount > 0 ? (
+
+
+
+
+
+
+
+ ) : null;
+}
+
+export function NestedPaginationGroupingCell(
+ props: GridTreeDataGroupingCellProps & {
+ setExpandedRows: (rows: GridValidRowModel[]) => void;
+ nestedLevelRef: React.RefObject;
+ },
+) {
+ const {
+ id,
+ field,
+ formattedValue,
+ rowNode,
+ hideDescendantCount,
+ offsetMultiplier = 2,
+ setExpandedRows,
+ nestedLevelRef,
+ } = props;
+
+ const rootProps = useGridRootProps();
+ const apiRef = useGridPrivateApiContext();
+ const rowSelector = (state: GridStatePro) => state.rows.dataRowIdToModelLookup[id];
+ const row = useGridSelector(apiRef, rowSelector);
+ const classes = useUtilityClasses(rootProps);
+
+ let descendantCount = 0;
+ if (row) {
+ descendantCount = Math.max(
+ rootProps.unstable_dataSource?.getChildrenCount?.(row) ?? 0,
+ 0,
+ );
+ }
+
+ let depth = row.depth ? row.depth : rowNode.depth;
+ if (!row.expanded && nestedLevelRef.current > 0) {
+ depth = nestedLevelRef.current;
+ }
+
+ return (
+
+
+
+
+
+ {formattedValue === undefined
+ ? (rowNode.groupingKey ?? row.groupingKey)
+ : formattedValue}
+ {!hideDescendantCount && descendantCount > 0 ? ` (${descendantCount})` : ''}
+
+
+ );
+}
diff --git a/docs/data/data-grid/server-side-data/ServerSideTreeDataNestedPagination.js b/docs/data/data-grid/server-side-data/ServerSideTreeDataNestedPagination.js
new file mode 100644
index 0000000000000..9d8511f94d01b
--- /dev/null
+++ b/docs/data/data-grid/server-side-data/ServerSideTreeDataNestedPagination.js
@@ -0,0 +1,105 @@
+import * as React from 'react';
+import { DataGridPro, useGridApiRef, GridToolbar } from '@mui/x-data-grid-pro';
+import Button from '@mui/material/Button';
+import { useMockServer } from '@mui/x-data-grid-generator';
+import { NestedPaginationGroupingCell } from './NestedPaginationGroupingCell';
+
+const pageSizeOptions = [5, 10, 50];
+const dataSetOptions = {
+ dataSet: 'Employee',
+ rowLength: 1000,
+ treeData: { maxDepth: 3, groupingField: 'name', averageChildren: 20 },
+};
+
+export default function ServerSideTreeDataNestedPagination() {
+ const apiRef = useGridApiRef();
+ const [expandedRows, setExpandedRows] = React.useState([]);
+ const nestedLevelRef = React.useRef(0);
+
+ React.useEffect(() => {
+ nestedLevelRef.current = expandedRows.length;
+ }, [expandedRows]);
+
+ const { fetchRows, columns, initialState } = useMockServer(
+ dataSetOptions,
+ {},
+ false,
+ true,
+ );
+
+ const renderGroupingCell = React.useCallback(
+ (params) => (
+
+ ),
+ [setExpandedRows],
+ );
+
+ const initialStateWithPagination = React.useMemo(
+ () => ({
+ ...initialState,
+ pagination: {
+ paginationModel: {
+ pageSize: 5,
+ },
+ rowCount: 0,
+ },
+ }),
+ [initialState],
+ );
+
+ const dataSource = React.useMemo(
+ () => ({
+ getRows: async (params) => {
+ const urlParams = new URLSearchParams({
+ paginationModel: JSON.stringify(params.paginationModel),
+ filterModel: JSON.stringify(params.filterModel),
+ sortModel: JSON.stringify(params.sortModel),
+ groupKeys: JSON.stringify(
+ expandedRows?.map((row) => row.groupingKey) ?? [],
+ ),
+ });
+ const getRowsResponse = await fetchRows(
+ `https://mui.com/x/api/data-grid?${urlParams.toString()}`,
+ );
+ return {
+ rows: getRowsResponse.rows,
+ rowCount: getRowsResponse.rowCount,
+ };
+ },
+ getGroupKey: (row) => row[dataSetOptions.treeData.groupingField],
+ getChildrenCount: (row) => row.descendantCount,
+ }),
+ [fetchRows, expandedRows],
+ );
+
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/docs/data/data-grid/server-side-data/ServerSideTreeDataNestedPagination.tsx b/docs/data/data-grid/server-side-data/ServerSideTreeDataNestedPagination.tsx
new file mode 100644
index 0000000000000..8cbad03462aad
--- /dev/null
+++ b/docs/data/data-grid/server-side-data/ServerSideTreeDataNestedPagination.tsx
@@ -0,0 +1,112 @@
+import * as React from 'react';
+import {
+ DataGridPro,
+ useGridApiRef,
+ GridInitialState,
+ GridToolbar,
+ GridDataSource,
+ GridValidRowModel,
+} from '@mui/x-data-grid-pro';
+import Button from '@mui/material/Button';
+import { useMockServer } from '@mui/x-data-grid-generator';
+import { NestedPaginationGroupingCell } from './NestedPaginationGroupingCell';
+
+const pageSizeOptions = [5, 10, 50];
+const dataSetOptions = {
+ dataSet: 'Employee' as const,
+ rowLength: 1000,
+ treeData: { maxDepth: 3, groupingField: 'name', averageChildren: 20 },
+};
+
+export default function ServerSideTreeDataNestedPagination() {
+ const apiRef = useGridApiRef();
+ const [expandedRows, setExpandedRows] = React.useState([]);
+ const nestedLevelRef = React.useRef(0);
+
+ React.useEffect(() => {
+ nestedLevelRef.current = expandedRows.length;
+ }, [expandedRows]);
+
+ const { fetchRows, columns, initialState } = useMockServer(
+ dataSetOptions,
+ {},
+ false,
+ true,
+ );
+
+ const renderGroupingCell = React.useCallback(
+ (params: GridRenderCellParams) => (
+
+ ),
+ [setExpandedRows],
+ );
+
+ const initialStateWithPagination: GridInitialState = React.useMemo(
+ () => ({
+ ...initialState,
+ pagination: {
+ paginationModel: {
+ pageSize: 5,
+ },
+ rowCount: 0,
+ },
+ }),
+ [initialState],
+ );
+
+ const dataSource: GridDataSource = React.useMemo(
+ () => ({
+ getRows: async (params) => {
+ const urlParams = new URLSearchParams({
+ paginationModel: JSON.stringify(params.paginationModel),
+ filterModel: JSON.stringify(params.filterModel),
+ sortModel: JSON.stringify(params.sortModel),
+ groupKeys: JSON.stringify(
+ expandedRows?.map((row) => row.groupingKey) ?? [],
+ ),
+ });
+ const getRowsResponse = await fetchRows(
+ `https://mui.com/x/api/data-grid?${urlParams.toString()}`,
+ );
+ return {
+ rows: getRowsResponse.rows,
+ rowCount: getRowsResponse.rowCount,
+ };
+ },
+ getGroupKey: (row) => row[dataSetOptions.treeData.groupingField],
+ getChildrenCount: (row) => row.descendantCount,
+ }),
+ [fetchRows, expandedRows],
+ );
+
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/docs/data/data-grid/server-side-data/tree-data.md b/docs/data/data-grid/server-side-data/tree-data.md
index 8e77fe0542bfa..90563123f1d23 100644
--- a/docs/data/data-grid/server-side-data/tree-data.md
+++ b/docs/data/data-grid/server-side-data/tree-data.md
@@ -66,6 +66,13 @@ The following demo uses `QueryClient` from `@tanstack/react-core` as a data sour
{{"demo": "ServerSideTreeDataCustomCache.js", "bg": "inline"}}
+## Nested pagination
+
+By default, the pagination works only on the first level of the tree.
+You can enable the nested pagination in the userland by using the pinned rows feature, as shown in the following demo.
+
+{{"demo": "ServerSideTreeDataNestedPagination.js", "bg": "inline"}}
+
## API
- [DataGrid](/x/api/data-grid/data-grid/)
diff --git a/packages/x-data-grid-generator/src/hooks/serverUtils.ts b/packages/x-data-grid-generator/src/hooks/serverUtils.ts
index 0958931266435..76f370215fa4a 100644
--- a/packages/x-data-grid-generator/src/hooks/serverUtils.ts
+++ b/packages/x-data-grid-generator/src/hooks/serverUtils.ts
@@ -434,6 +434,7 @@ export const processTreeDataRows = (
queryOptions: ServerSideQueryOptions,
serverOptions: ServerOptions,
columnsWithDefaultColDef: GridColDef[],
+ nestedPagination: boolean,
): Promise => {
const { minDelay = 100, maxDelay = 300 } = serverOptions;
const pathKey = 'path';
@@ -456,7 +457,10 @@ export const processTreeDataRows = (
) as GridValidRowModel[];
// get root row count
- const rootRowCount = findTreeDataRowChildren(filteredRows, []).length;
+ const rootRowCount = findTreeDataRowChildren(
+ filteredRows,
+ nestedPagination ? queryOptions.groupKeys : [],
+ ).length;
// find direct children referring to the `parentPath`
const childRows = findTreeDataRowChildren(filteredRows, queryOptions.groupKeys);
@@ -473,8 +477,9 @@ export const processTreeDataRows = (
childRowsWithDescendantCounts = [...childRowsWithDescendantCounts].sort(rowComparator);
}
- if (queryOptions.paginationModel && queryOptions.groupKeys.length === 0) {
+ if (queryOptions.paginationModel && (queryOptions.groupKeys.length === 0 || nestedPagination)) {
// Only paginate root rows, grid should refetch root rows when `paginationModel` updates
+ // Except when nested pagination is enabled, in which case we paginate the children of the current group node
const { pageSize, page } = queryOptions.paginationModel;
if (pageSize < childRowsWithDescendantCounts.length) {
childRowsWithDescendantCounts = childRowsWithDescendantCounts.slice(
diff --git a/packages/x-data-grid-generator/src/hooks/useMockServer.ts b/packages/x-data-grid-generator/src/hooks/useMockServer.ts
index 7f16fc41c6472..0e340418218fa 100644
--- a/packages/x-data-grid-generator/src/hooks/useMockServer.ts
+++ b/packages/x-data-grid-generator/src/hooks/useMockServer.ts
@@ -80,6 +80,7 @@ export const useMockServer = (
dataSetOptions?: Partial,
serverOptions?: ServerOptions & { verbose?: boolean },
shouldRequestsFail?: boolean,
+ nestedPagination?: boolean,
): UseMockServerResponse => {
const [data, setData] = React.useState();
const [index, setIndex] = React.useState(0);
@@ -230,6 +231,7 @@ export const useMockServer = (
params,
serverOptionsWithDefault,
columnsWithDefaultColDef,
+ nestedPagination ?? false,
);
getRowsResponse = {
@@ -262,6 +264,7 @@ export const useMockServer = (
serverOptions?.useCursorPagination,
isTreeData,
columnsWithDefaultColDef,
+ nestedPagination,
],
);