diff --git a/docs/pages/api-docs/data-grid/data-grid.md b/docs/pages/api-docs/data-grid/data-grid.md index 1926f22659ca6..93630d4f604a6 100644 --- a/docs/pages/api-docs/data-grid/data-grid.md +++ b/docs/pages/api-docs/data-grid/data-grid.md @@ -44,6 +44,7 @@ import { DataGrid } from '@material-ui/data-grid'; | hideFooterRowCount | boolean | false | If `true`, the row count in the footer is hidden. | | hideFooterSelectedRowCount | boolean | false | If `true`, the selected row count in the footer is hidden. | | icons | IconsOptions | | Set of icons used in the grid. | +| infniteLoadingMode | GridFeatureMode | 'client' | Infnite loading can be processed on the server or client-side. Set it to 'client' if you would like to handle the infnite loading on the client-side. Set it to 'server' if you would like to handle the infnite loading on the server-side. | | isCellEditable | (params: GridCellParams) => boolean | | Callback fired when a cell is rendered, returns true if the cell is editable. | | isRowSelectable | (params: GridRowParams) => boolean | | Determines if a row can be selected. | | loading | boolean | false | If `true`, a loading overlay is displayed. | diff --git a/docs/pages/api-docs/data-grid/grid-api.md b/docs/pages/api-docs/data-grid/grid-api.md index ea39eac9082cd..efa1fba889ced 100644 --- a/docs/pages/api-docs/data-grid/grid-api.md +++ b/docs/pages/api-docs/data-grid/grid-api.md @@ -57,6 +57,7 @@ import { GridApi } from '@material-ui/x-grid'; | hideColumnMenu | () => void | Hides the column menu that is open. | | hideFilterPanel | () => void | Hides the filter panel. | | hidePreferences | () => void | Hides the preferences panel. | +| insertRows | (params: GridInsertRowParams) => void | Inserts a new subset of Rows. | | isCellEditable | (params: GridCellParams) => boolean | Controls if a cell is editable. | | isColumnVisibleInWindow | (colIndex: number) => boolean | Checks if a column at the index given by `colIndex` is currently visible in the viewport. | | publishEvent | (name: string, ...args: any[]) => void | Emits an event. | diff --git a/docs/pages/api-docs/data-grid/x-grid.md b/docs/pages/api-docs/data-grid/x-grid.md index 86773eeba8518..cf03d3d6bc241 100644 --- a/docs/pages/api-docs/data-grid/x-grid.md +++ b/docs/pages/api-docs/data-grid/x-grid.md @@ -48,6 +48,7 @@ import { XGrid } from '@material-ui/x-grid'; | hideFooterRowCount | boolean | false | If `true`, the row count in the footer is hidden. | | hideFooterSelectedRowCount | boolean | false | If `true`, the selected row count in the footer is hidden. | | icons | IconsOptions | | Set of icons used in the grid. | +| infniteLoadingMode | GridFeatureMode | 'client' | Infnite loading can be processed on the server or client-side. Set it to 'client' if you would like to handle the infnite loading on the client-side. Set it to 'server' if you would like to handle the infnite loading on the server-side. | | isCellEditable | (params: GridCellParams) => boolean | | Callback fired when a cell is rendered, returns true if the cell is editable. | | isRowSelectable | (params: GridRowParams) => boolean | | Determines if a row can be selected. | | loading | boolean | false | If `true`, a loading overlay is displayed.. | diff --git a/docs/src/pages/components/data-grid/events/events.json b/docs/src/pages/components/data-grid/events/events.json index cc88648d1a25c..58f73e20a9900 100644 --- a/docs/src/pages/components/data-grid/events/events.json +++ b/docs/src/pages/components/data-grid/events/events.json @@ -181,5 +181,13 @@ { "name": "columnVisibilityChange", "description": "Fired when a column visibility changes. Called with a GridColumnVisibilityChangeParams object." + }, + { + "name": "virtualPageChange", + "description": "Fired when the virtual page changes. Called with a GridVirtualPageChangeParams object." + }, + { + "name": "fetchRows", + "description": "Fired when the virtual page changes. Called with a GridFetchRowsParams object." } ] diff --git a/docs/src/pages/components/data-grid/rows/ServerInfiniteLoadingGrid.js b/docs/src/pages/components/data-grid/rows/ServerInfiniteLoadingGrid.js new file mode 100644 index 0000000000000..bf5ab5c14af2d --- /dev/null +++ b/docs/src/pages/components/data-grid/rows/ServerInfiniteLoadingGrid.js @@ -0,0 +1,55 @@ +import * as React from 'react'; +import { XGrid } from '@material-ui/x-grid'; +import { + useDemoData, + getRealData, + getCommodityColumns, +} from '@material-ui/x-grid-data-generator'; + +async function sleep(duration) { + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, duration); + }); +} + +const loadServerRows = async (newRowLength) => { + const newData = await getRealData(newRowLength, getCommodityColumns()); + // Simulate network throttle + await sleep(Math.random() * 100 + 100); + + return newData.rows; +}; + +export default function InfiniteLoadingGrid() { + const { data } = useDemoData({ + dataSet: 'Commodity', + rowLength: 10, + maxColumns: 6, + }); + + const handleFetchRows = async (params) => { + const newRowsBatch = await loadServerRows(params.viewportPageSize); + + params.api.current.insertRows({ + startIndex: params.startIndex, + pageSize: params.viewportPageSize, + newRows: newRowsBatch, + }); + }; + + return ( +
+ +
+ ); +} diff --git a/docs/src/pages/components/data-grid/rows/ServerInfiniteLoadingGrid.tsx b/docs/src/pages/components/data-grid/rows/ServerInfiniteLoadingGrid.tsx new file mode 100644 index 0000000000000..425c0dcf93b92 --- /dev/null +++ b/docs/src/pages/components/data-grid/rows/ServerInfiniteLoadingGrid.tsx @@ -0,0 +1,57 @@ +import * as React from 'react'; +import { GridFetchRowsParams, GridRowData, XGrid } from '@material-ui/x-grid'; +import { + useDemoData, + getRealData, + getCommodityColumns, +} from '@material-ui/x-grid-data-generator'; + +async function sleep(duration) { + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, duration); + }); +} + +const loadServerRows = async (newRowLength: number): Promise => { + const newData = await getRealData(newRowLength, getCommodityColumns()); + // Simulate network throttle + await sleep(Math.random() * 100 + 100); + + return newData.rows; +}; + +export default function InfiniteLoadingGrid() { + const { data } = useDemoData({ + dataSet: 'Commodity', + rowLength: 10, + maxColumns: 6, + }); + + const handleFetchRows = async (params: GridFetchRowsParams) => { + const newRowsBatch: GridRowData[] = await loadServerRows( + params.viewportPageSize, + ); + + params.api.current.insertRows({ + startIndex: params.startIndex, + pageSize: params.viewportPageSize, + newRows: newRowsBatch, + }); + }; + + return ( +
+ +
+ ); +} diff --git a/docs/src/pages/components/data-grid/rows/rows.md b/docs/src/pages/components/data-grid/rows/rows.md index d0cfd7521c888..5c63e275e5f16 100644 --- a/docs/src/pages/components/data-grid/rows/rows.md +++ b/docs/src/pages/components/data-grid/rows/rows.md @@ -35,6 +35,19 @@ In addition, the area in which the callback provided to the `onRowsScrollEnd` is {{"demo": "pages/components/data-grid/rows/InfiniteLoadingGrid.js", "bg": "inline", "disableAd": true}} +### Unstable Server-side infinite loading [](https://material-ui.com/store/items/material-ui-pro/) + +By default, infinite loading works on the client-side. +To switch it to server-side, set `infniteLoadingMode="server"`. +Then the `rowCount` needs to be set and the number of initially loaded rows needs to be less than the `rowCount` value. +In addition, you need to handle the `onFetchRows` callback to fetch the rows for the corresponding index. +Finally, you need to use the `apiRef.current.insertRows()` to tell the DataGrid where to insert the newly fetched rows. + +**Note**: in order for the filtering and sorting to work you need to set their modes to `server`. +You can find out more information about how to do that on the [server-side filter page](/components/data-grid/filtering/#server-side-filter) and on the [server-side sorting page](/components/data-grid/sorting/#server-side-sorting). + +{{"demo": "pages/components/data-grid/rows/ServerInfiniteLoadingGrid.js", "bg": "inline", "disableAd": true}} + ### apiRef [](https://material-ui.com/store/items/material-ui-pro/) The second way to update rows is to use the apiRef. diff --git a/packages/grid/_modules_/grid/components/GridViewport.tsx b/packages/grid/_modules_/grid/components/GridViewport.tsx index 0ed7f2e839a60..cf2ce0b8760df 100644 --- a/packages/grid/_modules_/grid/components/GridViewport.tsx +++ b/packages/grid/_modules_/grid/components/GridViewport.tsx @@ -17,6 +17,7 @@ import { GridEmptyCell } from './cell/GridEmptyCell'; import { GridRenderingZone } from './GridRenderingZone'; import { GridRow } from './GridRow'; import { GridRowCells } from './cell/GridRowCells'; +import { GridSkeletonRowCells } from './cell/GridSkeletonRowCells'; import { GridStickyContainer } from './GridStickyContainer'; import { gridContainerSizesSelector, @@ -52,6 +53,7 @@ export const GridViewport: ViewportType = React.forwardRef( renderState.renderContext.firstRowIdx, renderState.renderContext.lastRowIdx!, ); + return renderedRows.map(([id, row], idx) => ( ( rowIndex={renderState.renderContext!.firstRowIdx! + idx} > - + {id.toString().indexOf('null-') === 0 ? ( + + ) : ( + + )} )); diff --git a/packages/grid/_modules_/grid/components/cell/GridSkeletonCell.tsx b/packages/grid/_modules_/grid/components/cell/GridSkeletonCell.tsx new file mode 100644 index 0000000000000..8b9af01d50197 --- /dev/null +++ b/packages/grid/_modules_/grid/components/cell/GridSkeletonCell.tsx @@ -0,0 +1,43 @@ +import * as React from 'react'; +import clsx from 'clsx'; +import Skeleton from '@material-ui/lab/Skeleton'; +import { GRID_SKELETON_CELL_CSS_CLASS } from '../../constants/cssClassesConstants'; + +export interface GridSkeletonCellProps { + colIndex: number; + height: number; + rowIndex: number; + showRightBorder?: boolean; + width: number; +} + +export const GridSkeletonCell = React.memo(function GridSkeletonCell(props: GridSkeletonCellProps) { + const { colIndex, height, rowIndex, showRightBorder, width } = props; + + const cellRef = React.useRef(null); + const cssClasses = clsx(GRID_SKELETON_CELL_CSS_CLASS, { + 'MuiDataGrid-withBorder': showRightBorder, + }); + + const style = { + minWidth: width, + maxWidth: width, + lineHeight: `${height - 1}px`, + minHeight: height, + maxHeight: height, + }; + + return ( +
+ +
+ ); +}); diff --git a/packages/grid/_modules_/grid/components/cell/GridSkeletonRowCells.tsx b/packages/grid/_modules_/grid/components/cell/GridSkeletonRowCells.tsx new file mode 100644 index 0000000000000..847a196662a19 --- /dev/null +++ b/packages/grid/_modules_/grid/components/cell/GridSkeletonRowCells.tsx @@ -0,0 +1,61 @@ +import * as React from 'react'; +import { GridColumns } from '../../models/index'; +import { GridApiContext } from '../GridApiContext'; +import { gridDensityRowHeightSelector } from '../../hooks/features/density/densitySelector'; +import { useGridSelector } from '../../hooks/features/core/useGridSelector'; +import { GridSkeletonCell } from './GridSkeletonCell'; + +interface SkeletonRowCellsProps { + columns: GridColumns; + extendRowFullWidth: boolean; + firstColIdx: number; + hasScrollX: boolean; + hasScrollY: boolean; + lastColIdx: number; + rowIndex: number; + showCellRightBorder: boolean; +} + +export const GridSkeletonRowCells = React.memo(function GridSkeletonRowCells( + props: SkeletonRowCellsProps, +) { + const { + columns, + firstColIdx, + hasScrollX, + hasScrollY, + lastColIdx, + rowIndex, + showCellRightBorder, + } = props; + const apiRef = React.useContext(GridApiContext); + const rowHeight = useGridSelector(apiRef, gridDensityRowHeightSelector); + + const skeletonCellsProps = columns.slice(firstColIdx, lastColIdx + 1).map((column, colIdx) => { + const colIndex = firstColIdx + colIdx; + const isLastColumn = colIndex === columns.length - 1; + const removeLastBorderRight = isLastColumn && hasScrollX && !hasScrollY; + const showRightBorder = !isLastColumn + ? showCellRightBorder + : !removeLastBorderRight && !props.extendRowFullWidth; + + const skeletonCellProps = { + field: column.field, + width: column.width!, + height: rowHeight, + showRightBorder, + rowIndex, + colIndex, + }; + + return skeletonCellProps; + }); + + return ( + + {skeletonCellsProps.map((skeletonCellProps) => ( + + ))} + + ); +}); diff --git a/packages/grid/_modules_/grid/components/cell/index.ts b/packages/grid/_modules_/grid/components/cell/index.ts index 8d5f70eb20a5c..0b4303bb6c6ad 100644 --- a/packages/grid/_modules_/grid/components/cell/index.ts +++ b/packages/grid/_modules_/grid/components/cell/index.ts @@ -2,3 +2,5 @@ export * from './GridCell'; export * from './GridEditInputCell'; export * from './GridEmptyCell'; export * from './GridRowCells'; +export * from './GridSkeletonRowCells'; +export * from './GridSkeletonCell'; diff --git a/packages/grid/_modules_/grid/components/containers/GridRootStyles.ts b/packages/grid/_modules_/grid/components/containers/GridRootStyles.ts index 784380e846376..25ff3adb53410 100644 --- a/packages/grid/_modules_/grid/components/containers/GridRootStyles.ts +++ b/packages/grid/_modules_/grid/components/containers/GridRootStyles.ts @@ -84,7 +84,7 @@ export const useStyles = makeStyles( alignItems: 'center', overflow: 'hidden', }, - '& .MuiDataGrid-columnHeader, & .MuiDataGrid-cell': { + '& .MuiDataGrid-columnHeader, & .MuiDataGrid-cell, & .MuiDataGrid-skeletonCell': { WebkitTapHighlightColor: 'transparent', lineHeight: null, padding: '0 10px', @@ -244,7 +244,7 @@ export const useStyles = makeStyles( }, }, }, - '& .MuiDataGrid-cell': { + '& .MuiDataGrid-cell, & .MuiDataGrid-skeletonCell': { display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', diff --git a/packages/grid/_modules_/grid/constants/cssClassesConstants.ts b/packages/grid/_modules_/grid/constants/cssClassesConstants.ts index f79b283f2f869..3bccf44586bb1 100644 --- a/packages/grid/_modules_/grid/constants/cssClassesConstants.ts +++ b/packages/grid/_modules_/grid/constants/cssClassesConstants.ts @@ -12,3 +12,4 @@ export const GRID_COLUMN_HEADER_SEPARATOR_RESIZABLE_CSS_CLASS = `${GRID_CSS_CLAS export const GRID_COLUMN_HEADER_TITLE_CSS_CLASS = `${GRID_CSS_CLASS_PREFIX}-columnHeaderTitleContainer`; export const GRID_COLUMN_HEADER_DROP_ZONE_CSS_CLASS = `${GRID_CSS_CLASS_PREFIX}-columnHeaderDropZone`; export const GRID_COLUMN_HEADER_DRAGGING_CSS_CLASS = `${GRID_CSS_CLASS_PREFIX}-${GRID_COLUMN_HEADER_CSS_CLASS_SUFFIX}--dragging`; +export const GRID_SKELETON_CELL_CSS_CLASS = `${GRID_CSS_CLASS_PREFIX}-skeletonCell`; diff --git a/packages/grid/_modules_/grid/constants/eventsConstants.ts b/packages/grid/_modules_/grid/constants/eventsConstants.ts index 53d00deadcec4..73301c9fe0502 100644 --- a/packages/grid/_modules_/grid/constants/eventsConstants.ts +++ b/packages/grid/_modules_/grid/constants/eventsConstants.ts @@ -452,3 +452,15 @@ export const GRID_STATE_CHANGE = 'stateChange'; * @event */ export const GRID_COLUMN_VISIBILITY_CHANGE = 'columnVisibilityChange'; + +/** + * Fired when the virtual page changes. Called with a [[GridVirtualPageChangeParams]] object. + * @event + */ +export const GRID_VIRTUAL_PAGE_CHANGE = 'virtualPageChange'; + +/** + * Fired when the virtual page changes. Called with a [[GridFetchRowsParams]] object. + * @event + */ +export const GRID_FETCH_ROWS = 'fetchRows'; diff --git a/packages/grid/_modules_/grid/hooks/features/export/serializers/csvSerializer.ts b/packages/grid/_modules_/grid/hooks/features/export/serializers/csvSerializer.ts index 9738ff87b849e..1f3614d65c838 100644 --- a/packages/grid/_modules_/grid/hooks/features/export/serializers/csvSerializer.ts +++ b/packages/grid/_modules_/grid/hooks/features/export/serializers/csvSerializer.ts @@ -43,7 +43,7 @@ interface BuildCSVOptions { export function buildCSV(options: BuildCSVOptions): string { const { columns, rows, selectedRows, getCellParams, delimiterCharacter } = options; - let rowIds = [...rows.keys()]; + let rowIds = [...rows.keys()].filter((id) => id.toString().indexOf('null-') !== 0); const selectedRowIds = Object.keys(selectedRows); if (selectedRowIds.length) { diff --git a/packages/grid/_modules_/grid/hooks/features/filter/useGridFilter.ts b/packages/grid/_modules_/grid/hooks/features/filter/useGridFilter.ts index b4e5fc4bfa224..b5f81b32af50b 100644 --- a/packages/grid/_modules_/grid/hooks/features/filter/useGridFilter.ts +++ b/packages/grid/_modules_/grid/hooks/features/filter/useGridFilter.ts @@ -102,6 +102,10 @@ export const useGridFilter = ( const rows = sortedGridRowsSelector(state); rows.forEach((row: GridRowModel, id: GridRowId) => { + if (id.toString().indexOf('null-') === 0) { + return; + } + const params = apiRef.current.getCellParams(id, newFilterItem.columnField!); const isShown = applyFilterOnRow(params); diff --git a/packages/grid/_modules_/grid/hooks/features/infiniteLoader/useGridInfiniteLoader.ts b/packages/grid/_modules_/grid/hooks/features/infiniteLoader/useGridInfiniteLoader.ts index 6e1d217f46b13..1e948c1688e8b 100644 --- a/packages/grid/_modules_/grid/hooks/features/infiniteLoader/useGridInfiniteLoader.ts +++ b/packages/grid/_modules_/grid/hooks/features/infiniteLoader/useGridInfiniteLoader.ts @@ -2,20 +2,44 @@ import * as React from 'react'; import { optionsSelector } from '../../utils/optionsSelector'; import { GridApiRef } from '../../../models/api/gridApiRef'; import { useGridSelector } from '../core/useGridSelector'; -import { GRID_ROWS_SCROLL, GRID_ROWS_SCROLL_END } from '../../../constants/eventsConstants'; +import { + GRID_FETCH_ROWS, + GRID_FILTER_MODEL_CHANGE, + GRID_ROWS_SCROLL, + GRID_ROWS_SCROLL_END, + GRID_SORT_MODEL_CHANGE, + GRID_VIRTUAL_PAGE_CHANGE, +} from '../../../constants/eventsConstants'; import { gridContainerSizesSelector } from '../../root/gridContainerSizesSelector'; import { useGridApiEventHandler, useGridApiOptionHandler } from '../../root/useGridApiEventHandler'; import { GridRowScrollEndParams } from '../../../models/params/gridRowScrollEndParams'; import { visibleGridColumnsSelector } from '../columns/gridColumnsSelector'; +import { GridVirtualPageChangeParams } from '../../../models/params/gridVirtualPageChangeParams'; +import { GridSortModelParams } from '../../../models/params/gridSortModelParams'; +import { GridFilterModelParams } from '../../../models/params/gridFilterModelParams'; +import { useLogger } from '../../utils/useLogger'; +import { renderStateSelector } from '../virtualization/renderingStateSelector'; +import { unorderedGridRowIdsSelector } from '../rows/gridRowsSelector'; +import { gridSortModelSelector } from '../sorting/gridSortingSelector'; +import { filterGridStateSelector } from '../filter/gridFilterSelector'; +import { GridFetchRowsParams } from '../../../models/params/gridFetchRowsParams'; +import { GridRowId } from '../../../models/gridRows'; +import { GridFeatureModeConstant } from '../../../models/gridFeatureMode'; export const useGridInfiniteLoader = (apiRef: GridApiRef): void => { + const logger = useLogger('useGridInfiniteLoader'); const options = useGridSelector(apiRef, optionsSelector); const containerSizes = useGridSelector(apiRef, gridContainerSizesSelector); const visibleColumns = useGridSelector(apiRef, visibleGridColumnsSelector); + const renderState = useGridSelector(apiRef, renderStateSelector); + const allRows = useGridSelector(apiRef, unorderedGridRowIdsSelector); + const sortModel = useGridSelector(apiRef, gridSortModelSelector); + const filterState = useGridSelector(apiRef, filterGridStateSelector); const isInScrollBottomArea = React.useRef(false); const handleGridScroll = React.useCallback(() => { - if (!containerSizes) { + logger.debug('Checking if scroll position reached bottom'); + if (!containerSizes || options.infniteLoadingMode !== GridFeatureModeConstant.client) { return; } @@ -29,19 +53,106 @@ export const useGridInfiniteLoader = (apiRef: GridApiRef): void => { if ( scrollPositionBottom >= containerSizes.dataContainerSizes.height && - !isInScrollBottomArea.current + !isInScrollBottomArea.current && + !options.rowCount ) { - const rowScrollEndParam: GridRowScrollEndParams = { + const rowScrollEndParams: GridRowScrollEndParams = { api: apiRef, visibleColumns, viewportPageSize: containerSizes.viewportPageSize, virtualRowsCount: containerSizes.virtualRowsCount, }; - apiRef.current.publishEvent(GRID_ROWS_SCROLL_END, rowScrollEndParam); + apiRef.current.publishEvent(GRID_ROWS_SCROLL_END, rowScrollEndParams); isInScrollBottomArea.current = true; } - }, [options, containerSizes, apiRef, visibleColumns]); + }, [logger, options, containerSizes, apiRef, visibleColumns]); + + const handleGridVirtualPageChange = React.useCallback( + (params: GridVirtualPageChangeParams) => { + logger.debug('Virtual page changed'); + if (!containerSizes || options.infniteLoadingMode !== GridFeatureModeConstant.server) { + return; + } + + let nextPage: number = params.nextPage; + if (params.currentPage < params.nextPage && params.nextPage !== containerSizes.lastPage) { + nextPage += 1; + } + + const newRowsBatchStartIndex = nextPage * containerSizes.viewportPageSize; + const remainingIndexes = + allRows.length - (newRowsBatchStartIndex + containerSizes.viewportPageSize); + let newRowsBatchLength = containerSizes.viewportPageSize; + + if (remainingIndexes < containerSizes.renderingZonePageSize) { + newRowsBatchLength = containerSizes.renderingZonePageSize; + } + + const toBeLoadedRange: Array = [...allRows].splice( + newRowsBatchStartIndex, + newRowsBatchLength, + ); + + if (!toBeLoadedRange.includes(null)) { + return; + } + + const fetchRowsParams: GridFetchRowsParams = { + startIndex: newRowsBatchStartIndex, + viewportPageSize: newRowsBatchLength, + sortModel, + filterModel: filterState, + api: apiRef, + }; + apiRef.current.publishEvent(GRID_FETCH_ROWS, fetchRowsParams); + }, + [logger, options, allRows, sortModel, containerSizes, filterState, apiRef], + ); + + const handleGridSortModelChange = React.useCallback( + (params: GridSortModelParams) => { + logger.debug('Sort model changed'); + if (!containerSizes || options.infniteLoadingMode !== GridFeatureModeConstant.server) { + return; + } + + const newRowsBatchStartIndex = renderState.virtualPage * containerSizes.viewportPageSize; + const fetchRowsParams: GridFetchRowsParams = { + startIndex: newRowsBatchStartIndex, + viewportPageSize: containerSizes.viewportPageSize, + sortModel: params.sortModel, + filterModel: filterState, + api: apiRef, + }; + apiRef.current.publishEvent(GRID_FETCH_ROWS, fetchRowsParams); + }, + [logger, options, renderState, containerSizes, filterState, apiRef], + ); + + const handleGridFilterModelChange = React.useCallback( + (params: GridFilterModelParams) => { + logger.debug('Filter model changed'); + if (!containerSizes || options.infniteLoadingMode !== GridFeatureModeConstant.server) { + return; + } + + const newRowsBatchStartIndex = renderState.virtualPage * containerSizes.viewportPageSize; + const fetchRowsParams: GridFetchRowsParams = { + startIndex: newRowsBatchStartIndex, + viewportPageSize: containerSizes.viewportPageSize, + sortModel, + filterModel: params.filterModel, + api: apiRef, + }; + apiRef.current.publishEvent(GRID_FETCH_ROWS, fetchRowsParams); + }, + [logger, options, containerSizes, sortModel, renderState, apiRef], + ); useGridApiEventHandler(apiRef, GRID_ROWS_SCROLL, handleGridScroll); + useGridApiEventHandler(apiRef, GRID_VIRTUAL_PAGE_CHANGE, handleGridVirtualPageChange); + useGridApiEventHandler(apiRef, GRID_SORT_MODEL_CHANGE, handleGridSortModelChange); + useGridApiEventHandler(apiRef, GRID_FILTER_MODEL_CHANGE, handleGridFilterModelChange); useGridApiOptionHandler(apiRef, GRID_ROWS_SCROLL_END, options.onRowsScrollEnd); + useGridApiOptionHandler(apiRef, GRID_FETCH_ROWS, options.onFetchRows); }; diff --git a/packages/grid/_modules_/grid/hooks/features/rows/gridRowsState.ts b/packages/grid/_modules_/grid/hooks/features/rows/gridRowsState.ts index 2509273373dbc..3d041836bdd4c 100644 --- a/packages/grid/_modules_/grid/hooks/features/rows/gridRowsState.ts +++ b/packages/grid/_modules_/grid/hooks/features/rows/gridRowsState.ts @@ -6,8 +6,8 @@ export interface InternalGridRowsState { totalRowCount: number; } -export const getInitialGridRowState: () => InternalGridRowsState = () => ({ +export const getInitialGridRowState: (rowCount?: number) => InternalGridRowsState = (rowCount) => ({ idRowsLookup: {}, - allRows: [], + allRows: rowCount !== undefined ? new Array(rowCount).fill(null) : [], totalRowCount: 0, }); diff --git a/packages/grid/_modules_/grid/hooks/features/rows/useGridRows.ts b/packages/grid/_modules_/grid/hooks/features/rows/useGridRows.ts index a9501c1a7c0df..a41a1f4ef6033 100644 --- a/packages/grid/_modules_/grid/hooks/features/rows/useGridRows.ts +++ b/packages/grid/_modules_/grid/hooks/features/rows/useGridRows.ts @@ -7,6 +7,7 @@ import { import { GridComponentProps } from '../../../GridComponentProps'; import { GridApiRef } from '../../../models/api/gridApiRef'; import { GridRowApi } from '../../../models/api/gridRowApi'; +import { GridFeatureMode, GridFeatureModeConstant } from '../../../models/gridFeatureMode'; import { checkGridRowIdIsValid, GridRowModel, @@ -15,9 +16,12 @@ import { GridRowsProp, GridRowIdGetter, GridRowData, + GridInsertRowParams, } from '../../../models/gridRows'; import { useGridApiMethod } from '../../root/useGridApiMethod'; +import { optionsSelector } from '../../utils/optionsSelector'; import { useLogger } from '../../utils/useLogger'; +import { useGridSelector } from '../core/useGridSelector'; import { useGridState } from '../core/useGridState'; import { getInitialGridRowState, InternalGridRowsState } from './gridRowsState'; @@ -35,15 +39,21 @@ export function convertGridRowsPropToState( rows: GridRowsProp, totalRowCount?: number, rowIdGetter?: GridRowIdGetter, + infniteLoadingMode?: GridFeatureMode, ): InternalGridRowsState { + const numberOfRows = totalRowCount && totalRowCount > rows.length ? totalRowCount : rows.length; + const initialRowState = + infniteLoadingMode === GridFeatureModeConstant.server + ? getInitialGridRowState(numberOfRows) + : getInitialGridRowState(); const state: InternalGridRowsState = { - ...getInitialGridRowState(), - totalRowCount: totalRowCount && totalRowCount > rows.length ? totalRowCount : rows.length, + ...initialRowState, + totalRowCount: numberOfRows, }; - rows.forEach((rowData) => { + rows.forEach((rowData, index) => { const id = getGridRowId(rowData, rowIdGetter); - state.allRows.push(id); + state.allRows[index] = id; state.idRowsLookup[id] = rowData; }); @@ -55,6 +65,7 @@ export const useGridRows = ( { rows, getRowId }: Pick, ): void => { const logger = useLogger('useGridRows'); + const { infniteLoadingMode } = useGridSelector(apiRef, optionsSelector); const [gridState, setGridState, updateComponent] = useGridState(apiRef); const updateTimeout = React.useRef(); @@ -86,10 +97,11 @@ export const useGridRows = ( rows, state.options.rowCount, getRowId, + infniteLoadingMode, ); return { ...state, rows: internalRowsState.current }; }); - }, [getRowId, rows, setGridState]); + }, [getRowId, rows, setGridState, infniteLoadingMode]); const getRowIndexFromId = React.useCallback( (id: GridRowId): number => { @@ -114,6 +126,48 @@ export const useGridRows = ( [apiRef], ); + const insertRows = React.useCallback( + ({ startIndex, pageSize, newRows, rowCount }: GridInsertRowParams) => { + logger.debug(`Insert rows from index:${startIndex}`); + + const newRowsToState = convertGridRowsPropToState( + newRows, + newRows.length, + getRowId, + infniteLoadingMode, + ); + + setGridState((state) => { + // rowCount can be 0 + const allRows = + rowCount !== undefined ? new Array(rowCount).fill(null) : state.rows.allRows; + const allRowsUpdated = allRows.map((row, index) => { + if (index >= startIndex && index < startIndex + pageSize) { + return newRowsToState.allRows[index - startIndex]; + } + return row; + }); + + const idRowsLookupUpdated = + rowCount !== undefined + ? newRowsToState.idRowsLookup + : { ...state.rows.idRowsLookup, ...newRowsToState.idRowsLookup }; + + internalRowsState.current = { + allRows: allRowsUpdated, + idRowsLookup: idRowsLookupUpdated, + totalRowCount: rowCount !== undefined ? rowCount : state.rows.totalRowCount, + }; + return { ...state, rows: internalRowsState.current }; + }); + + forceUpdate(); + apiRef.current.updateViewport(); + apiRef.current.applySorting(); + }, + [logger, apiRef, getRowId, setGridState, forceUpdate, infniteLoadingMode], + ); + const setRows = React.useCallback( (allNewRows: GridRowModel[]) => { logger.debug(`updating all rows, new length ${allNewRows.length}`); @@ -227,6 +281,7 @@ export const useGridRows = ( getAllRowIds, setRows, updateRows, + insertRows, }; useGridApiMethod(apiRef, rowApi, 'GridRowApi'); }; diff --git a/packages/grid/_modules_/grid/hooks/features/sorting/gridSortingSelector.ts b/packages/grid/_modules_/grid/hooks/features/sorting/gridSortingSelector.ts index a9f44a9b9e35b..ddf15ba6ebc04 100644 --- a/packages/grid/_modules_/grid/hooks/features/sorting/gridSortingSelector.ts +++ b/packages/grid/_modules_/grid/hooks/features/sorting/gridSortingSelector.ts @@ -33,7 +33,9 @@ export const sortedGridRowsSelector = createSelector< (sortedIds: GridRowId[], idRowsLookup: GridRowsLookup) => { const map = new Map(); sortedIds.forEach((id) => { - map.set(id, idRowsLookup[id]); + // This was taken from the useId in the core + const normalizeId = id !== null ? id : `null-${Math.round(Math.random() * 1e5)}`; + map.set(normalizeId, idRowsLookup[id]); }); return map; }, diff --git a/packages/grid/_modules_/grid/hooks/features/sorting/useGridSorting.ts b/packages/grid/_modules_/grid/hooks/features/sorting/useGridSorting.ts index 6971fda635de3..e270d8fa22245 100644 --- a/packages/grid/_modules_/grid/hooks/features/sorting/useGridSorting.ts +++ b/packages/grid/_modules_/grid/hooks/features/sorting/useGridSorting.ts @@ -172,6 +172,7 @@ export const useGridSorting = (apiRef: GridApiRef, { rows }: { rows: GridRowsPro if (sortModel.length > 0) { const comparatorList = buildComparatorList(sortModel); logger.debug('Sorting rows with ', sortModel); + sorted = rowIds .map((id) => { return comparatorList.map((colComparator) => { diff --git a/packages/grid/_modules_/grid/hooks/features/virtualization/useGridVirtualRows.ts b/packages/grid/_modules_/grid/hooks/features/virtualization/useGridVirtualRows.ts index b30e7972a8dbd..b9ec68e43c8cd 100644 --- a/packages/grid/_modules_/grid/hooks/features/virtualization/useGridVirtualRows.ts +++ b/packages/grid/_modules_/grid/hooks/features/virtualization/useGridVirtualRows.ts @@ -1,5 +1,9 @@ import * as React from 'react'; -import { GRID_SCROLL, GRID_ROWS_SCROLL } from '../../../constants/eventsConstants'; +import { + GRID_SCROLL, + GRID_ROWS_SCROLL, + GRID_VIRTUAL_PAGE_CHANGE, +} from '../../../constants/eventsConstants'; import { GridApiRef } from '../../../models/api/gridApiRef'; import { GridVirtualizationApi } from '../../../models/api/gridVirtualizationApi'; import { GridCellIndexCoordinates } from '../../../models/gridCell'; @@ -147,6 +151,12 @@ export const useGridVirtualRows = (apiRef: GridApiRef): void => { if (containerProps.isVirtualized && page !== nextPage) { setRenderingState({ virtualPage: nextPage }); logger.debug(`Changing page from ${page} to ${nextPage}`); + + apiRef.current.publishEvent(GRID_VIRTUAL_PAGE_CHANGE, { + currentPage: page, + nextPage, + api: apiRef, + }); requireRerender = true; } else { if (!containerProps.isVirtualized && page > 0) { diff --git a/packages/grid/_modules_/grid/models/api/gridRowApi.ts b/packages/grid/_modules_/grid/models/api/gridRowApi.ts index 4315ef9512cdd..52870cb8c1e0e 100644 --- a/packages/grid/_modules_/grid/models/api/gridRowApi.ts +++ b/packages/grid/_modules_/grid/models/api/gridRowApi.ts @@ -1,4 +1,4 @@ -import { GridRowModel, GridRowId, GridRowModelUpdate } from '../gridRows'; +import { GridRowModel, GridRowId, GridRowModelUpdate, GridInsertRowParams } from '../gridRows'; /** * The Row API interface that is available in the grid `apiRef`. @@ -47,4 +47,9 @@ export interface GridRowApi { * @returns {GridRowModel} The row data. */ getRow: (id: GridRowId) => GridRowModel; + /** + * Inserts a new subset of Rows. + * @param {GridInsertRowParams} params The new rows. + */ + insertRows: (params: GridInsertRowParams) => void; } diff --git a/packages/grid/_modules_/grid/models/gridOptions.tsx b/packages/grid/_modules_/grid/models/gridOptions.tsx index 616786fa0ad7b..17d7161fbe11a 100644 --- a/packages/grid/_modules_/grid/models/gridOptions.tsx +++ b/packages/grid/_modules_/grid/models/gridOptions.tsx @@ -29,6 +29,7 @@ import { GridColumnOrderChangeParams } from './params/gridColumnOrderChangeParam import { GridResizeParams } from './params/gridResizeParams'; import { GridColumnResizeParams } from './params/gridColumnResizeParams'; import { GridColumnVisibilityChangeParams } from './params/gridColumnVisibilityChangeParams'; +import { GridFetchRowsParams } from './params/gridFetchRowsParams'; import { GridClasses } from './gridClasses'; export type MuiEvent = (E | React.SyntheticEvent) & { @@ -180,6 +181,12 @@ export interface GridOptions { * @default false */ hideFooterSelectedRowCount?: boolean; + /** + * Infnite loading can be processed on the server or client-side. + * Set it to 'client' if you would like to handle the infnite loading on the client-side. + * Set it to 'server' if you would like to handle the infnite loading on the server-side. + */ + infniteLoadingMode?: GridFeatureMode; /** * Callback fired when a cell is rendered, returns true if the cell is editable. */ @@ -351,6 +358,10 @@ export interface GridOptions { * @param param With all properties from [[GridFilterModelParams]]. */ onFilterModelChange?: (params: GridFilterModelParams) => void; + /** + * Callback fired when rowCount is set and virtual pages change. + */ + onFetchRows?: (params: GridFetchRowsParams) => void; /** * Callback fired when the current page has changed. * @param param With all properties from [[GridPageChangeParams]]. @@ -509,6 +520,7 @@ export const DEFAULT_GRID_OPTIONS: GridOptions = { density: GridDensityTypes.Standard, filterMode: GridFeatureModeConstant.client, headerHeight: 56, + infniteLoadingMode: GridFeatureModeConstant.client, localeText: GRID_DEFAULT_LOCALE_TEXT, pageSize: 100, paginationMode: GridFeatureModeConstant.client, diff --git a/packages/grid/_modules_/grid/models/gridRows.ts b/packages/grid/_modules_/grid/models/gridRows.ts index 03c91268c4fb1..419aae7ab271f 100644 --- a/packages/grid/_modules_/grid/models/gridRows.ts +++ b/packages/grid/_modules_/grid/models/gridRows.ts @@ -46,3 +46,25 @@ export function checkGridRowIdIsValid( return true; } + +/** + * The type of api.current.insertRows params. + */ +export interface GridInsertRowParams { + /** + * The start index from which rows needs to be loaded. + */ + startIndex: number; + /** + * The page size. + */ + pageSize: number; + /** + * Rows to be inserted. + */ + newRows: GridRowModel[]; + /** + * The updated row count. + */ + rowCount?: number; +} diff --git a/packages/grid/_modules_/grid/models/params/gridFetchRowsParams.ts b/packages/grid/_modules_/grid/models/params/gridFetchRowsParams.ts new file mode 100644 index 0000000000000..c8b1a22eb2a7f --- /dev/null +++ b/packages/grid/_modules_/grid/models/params/gridFetchRowsParams.ts @@ -0,0 +1,28 @@ +import { GridFilterModel } from '../../hooks/features/filter/gridFilterModelState'; +import { GridSortModel } from '../gridSortModel'; + +/** + * Object passed as parameter to the [[onFetchRows]] option. + */ +export interface GridFetchRowsParams { + /** + * The start index from which rows needs to be loaded. + */ + startIndex: number; + /** + * The viewport page size. + */ + viewportPageSize: number; + /** + * The sort model used to sort the grid. + */ + sortModel: GridSortModel; + /** + * The filter model. + */ + filterModel: GridFilterModel; + /** + * GridApiRef that let you manipulate the grid. + */ + api: any; +} diff --git a/packages/grid/_modules_/grid/models/params/gridVirtualPageChangeParams.ts b/packages/grid/_modules_/grid/models/params/gridVirtualPageChangeParams.ts new file mode 100644 index 0000000000000..52bd9f23d0489 --- /dev/null +++ b/packages/grid/_modules_/grid/models/params/gridVirtualPageChangeParams.ts @@ -0,0 +1,17 @@ +/** + * Object passed as parameter of the virtual page change event. + */ +export interface GridVirtualPageChangeParams { + /** + * The current page. + */ + currentPage: number; + /** + * The next page. + */ + nextPage: number; + /** + * Api that let you manipulate the grid. + */ + api: any; +} diff --git a/packages/grid/_modules_/grid/models/params/index.ts b/packages/grid/_modules_/grid/models/params/index.ts index 1a46417fabdd4..6fd7d543e6592 100644 --- a/packages/grid/_modules_/grid/models/params/index.ts +++ b/packages/grid/_modules_/grid/models/params/index.ts @@ -1,5 +1,6 @@ export * from './gridCellParams'; export * from './gridEditCellParams'; +export * from './gridFetchRowsParams'; export * from './gridColumnHeaderParams'; export * from './gridColumnOrderChangeParams'; export * from './gridColumnResizeParams'; diff --git a/packages/grid/x-grid/src/tests/rows.XGrid.test.tsx b/packages/grid/x-grid/src/tests/rows.XGrid.test.tsx index 02d981294cdf8..e357ea1116591 100644 --- a/packages/grid/x-grid/src/tests/rows.XGrid.test.tsx +++ b/packages/grid/x-grid/src/tests/rows.XGrid.test.tsx @@ -1,12 +1,17 @@ import * as React from 'react'; -import { createClientRenderStrictMode } from 'test/utils'; -import { useFakeTimers } from 'sinon'; +import { + createClientRenderStrictMode, + // @ts-expect-error need to migrate helpers to TypeScript + fireEvent, +} from 'test/utils'; +import { useFakeTimers, spy } from 'sinon'; import { expect } from 'chai'; -import { getCell, getColumnValues } from 'test/utils/helperFn'; +import { getCell, getColumnHeaderCell, getColumnValues } from 'test/utils/helperFn'; import { GridApiRef, GridComponentProps, GridRowData, + GRID_SKELETON_CELL_CSS_CLASS, useGridApiRef, XGrid, XGridProps, @@ -448,4 +453,131 @@ describe(' - Rows', () => { }); }); }); + + describe('Infinite loader', () => { + before(function beforeHook() { + if (isJSDOM) { + // Need layouting + this.skip(); + } + }); + + let apiRef: GridApiRef; + const TestCaseInfiniteLoading = ( + props: Partial & { nbRows?: number; nbCols?: number; height?: number }, + ) => { + apiRef = useGridApiRef(); + const data = useData(props.nbRows || 100, props.nbCols || 10); + + return ( +
+ +
+ ); + }; + + it('should call onRowsScrollEnd when scroll reaches the bottom of the grid', () => { + const handleRowsScrollEnd = spy(); + render(); + + const gridWindow = document.querySelector('.MuiDataGrid-window')!; + gridWindow.scrollTop = 10e6; // scroll to the bottom + gridWindow.dispatchEvent(new Event('scroll')); + + expect(handleRowsScrollEnd.callCount).to.equal(1); + }); + + it('should call onFetchRows when scroll reaches the bottom of the grid', () => { + const handleFetchRows = spy(); + render( + , + ); + + const gridWindow = document.querySelector('.MuiDataGrid-window')!; + gridWindow.scrollTop = 10e6; // scroll to the bottom + gridWindow.dispatchEvent(new Event('scroll')); + + expect(handleFetchRows.callCount).to.equal(1); + }); + + it('should call onFetchRows when sorting is applied', () => { + const handleFetchRows = spy(); + render( + , + ); + + fireEvent.click(getColumnHeaderCell(0)); + expect(handleFetchRows.callCount).to.equal(1); + }); + + it('should render skeleton cell if rowCount is bigger than the number of rows', () => { + render( + , + ); + + const gridWindow = document.querySelector('.MuiDataGrid-window')!; + gridWindow.scrollTop = 10e6; // scroll to the bottom + gridWindow.dispatchEvent(new Event('scroll')); + + const lastCell = document.querySelector('[role="row"]:last-child [role="cell"]:first-child')!; + + expect(lastCell.classList.contains(GRID_SKELETON_CELL_CSS_CLASS)).to.equal(true); + }); + + it('should update allRows accordingly when apiRef.current.insertRows is called', () => { + render( + , + ); + + const pageSize = 3; + const startIndex = 7; + const endIndex = startIndex + pageSize; + const newRows: GridRowData[] = [ + { id: 'new-1', currencyPair: '' }, + { id: 'new-2', currencyPair: '' }, + { id: 'new-3', currencyPair: '' }, + ]; + + const initialAllRows = apiRef!.current!.getState().rows!.allRows; + expect(initialAllRows.slice(startIndex, endIndex)).to.deep.equal([null, null, null]); + + apiRef!.current!.insertRows({ startIndex, pageSize, newRows }); + + const updatedAllRows = apiRef!.current!.getState().rows!.allRows; + expect(updatedAllRows.slice(startIndex, endIndex)).to.deep.equal(['new-1', 'new-2', 'new-3']); + }); + }); }); diff --git a/packages/storybook/src/stories/grid-rows.stories.tsx b/packages/storybook/src/stories/grid-rows.stories.tsx index 629339f96e720..1052e6968da8e 100644 --- a/packages/storybook/src/stories/grid-rows.stories.tsx +++ b/packages/storybook/src/stories/grid-rows.stories.tsx @@ -20,8 +20,9 @@ import { GridEditCellPropsParams, GridEditRowModelParams, GRID_CELL_EDIT_ENTER, + GridFetchRowsParams, } from '@material-ui/x-grid'; -import { useDemoData } from '@material-ui/x-grid-data-generator'; +import { getCommodityColumns, getRealData, useDemoData } from '@material-ui/x-grid-data-generator'; import { action } from '@storybook/addon-actions'; import { randomInt } from '../data/random-generator'; @@ -930,6 +931,53 @@ export function DeferRendering() { return ; } +async function sleep(duration) { + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, duration); + }); +} + +const loadServerRows = async (newRowLength: number): Promise => { + const newData = await getRealData(newRowLength, getCommodityColumns()); + // Simulate network throttle + await sleep(Math.random() * 100 + 100); + + return newData.rows; +}; + +export function ServerSideInfiniteLoading() { + const { data } = useDemoData({ + dataSet: 'Commodity', + rowLength: 20, + maxColumns: 20, + }); + + const handleFetchRows = async (params: GridFetchRowsParams) => { + const newRowsBatch: GridRowData[] = await loadServerRows(params.viewportPageSize); + + params.api.current.insertRows({ + startIndex: params.startIndex, + pageSize: params.viewportPageSize, + newRows: newRowsBatch, + }); + }; + + return ( +
+ +
+ ); +} + export const ZeroHeightGrid = () => (