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, ], );