diff --git a/docs/data/data-grid/server-side-data/ServerSideDataGrid.js b/docs/data/data-grid/server-side-data/ServerSideDataGrid.js new file mode 100644 index 000000000000..3c0dbeb76f86 --- /dev/null +++ b/docs/data/data-grid/server-side-data/ServerSideDataGrid.js @@ -0,0 +1,57 @@ +import * as React from 'react'; +import { DataGridPro } from '@mui/x-data-grid-pro'; +import { useMockServer } from '@mui/x-data-grid-generator'; + +function ServerSideDataGrid() { + const { columns, initialState, fetchRows } = useMockServer( + {}, + { useCursorPagination: false }, + ); + + const dataSource = React.useMemo( + () => ({ + getRows: async (params) => { + const urlParams = new URLSearchParams({ + paginationModel: encodeURIComponent( + JSON.stringify(params.paginationModel), + ), + filterModel: encodeURIComponent(JSON.stringify(params.filterModel)), + sortModel: encodeURIComponent(JSON.stringify(params.sortModel)), + }); + const getRowsResponse = await fetchRows( + `https://mui.com/x/api/data-grid?${urlParams.toString()}`, + ); + return { + rows: getRowsResponse.rows, + rowCount: getRowsResponse.rowCount, + }; + }, + }), + [fetchRows], + ); + + const initialStateWithPagination = React.useMemo( + () => ({ + ...initialState, + pagination: { + paginationModel: { pageSize: 10, page: 0 }, + rowCount: 0, + }, + }), + [initialState], + ); + + return ( +
+ +
+ ); +} + +export default ServerSideDataGrid; diff --git a/docs/data/data-grid/server-side-data/ServerSideDataGrid.tsx b/docs/data/data-grid/server-side-data/ServerSideDataGrid.tsx new file mode 100644 index 000000000000..514b77b23bf9 --- /dev/null +++ b/docs/data/data-grid/server-side-data/ServerSideDataGrid.tsx @@ -0,0 +1,57 @@ +import * as React from 'react'; +import { DataGridPro, GridDataSource } from '@mui/x-data-grid-pro'; +import { useMockServer } from '@mui/x-data-grid-generator'; + +function ServerSideDataGrid() { + const { columns, initialState, fetchRows } = useMockServer( + {}, + { useCursorPagination: false }, + ); + + const dataSource: GridDataSource = React.useMemo( + () => ({ + getRows: async (params) => { + const urlParams = new URLSearchParams({ + paginationModel: encodeURIComponent( + JSON.stringify(params.paginationModel), + ), + filterModel: encodeURIComponent(JSON.stringify(params.filterModel)), + sortModel: encodeURIComponent(JSON.stringify(params.sortModel)), + }); + const getRowsResponse = await fetchRows( + `https://mui.com/x/api/data-grid?${urlParams.toString()}`, + ); + return { + rows: getRowsResponse.rows, + rowCount: getRowsResponse.rowCount, + }; + }, + }), + [fetchRows], + ); + + const initialStateWithPagination = React.useMemo( + () => ({ + ...initialState, + pagination: { + paginationModel: { pageSize: 10, page: 0 }, + rowCount: 0, + }, + }), + [initialState], + ); + + return ( +
+ +
+ ); +} + +export default ServerSideDataGrid; diff --git a/docs/data/data-grid/server-side-data/ServerSideDataGridNoCache.js b/docs/data/data-grid/server-side-data/ServerSideDataGridNoCache.js new file mode 100644 index 000000000000..e059e7b98b00 --- /dev/null +++ b/docs/data/data-grid/server-side-data/ServerSideDataGridNoCache.js @@ -0,0 +1,60 @@ +import * as React from 'react'; +import { DataGridPro } from '@mui/x-data-grid-pro'; +import { useMockServer } from '@mui/x-data-grid-generator'; + +const pageSizeOptions = [5, 10, 50]; + +const serverOptions = { useCursorPagination: false }; +const dataSetOptions = {}; + +export default function ServerSideDataGridNoCache() { + const { fetchRows, columns, initialState } = useMockServer( + dataSetOptions, + serverOptions, + ); + + const dataSource = React.useMemo( + () => ({ + getRows: async (params) => { + const urlParams = new URLSearchParams({ + paginationModel: encodeURIComponent( + JSON.stringify(params.paginationModel), + ), + filterModel: encodeURIComponent(JSON.stringify(params.filterModel)), + sortModel: encodeURIComponent(JSON.stringify(params.sortModel)), + }); + const getRowsResponse = await fetchRows( + `https://mui.com/x/api/data-grid?${urlParams.toString()}`, + ); + return { + rows: getRowsResponse.rows, + rowCount: getRowsResponse.rowCount, + }; + }, + }), + [fetchRows], + ); + + const initialStateWithPagination = React.useMemo( + () => ({ + ...initialState, + pagination: { + paginationModel: { pageSize: 10, page: 0 }, + }, + }), + [initialState], + ); + + return ( +
+ +
+ ); +} diff --git a/docs/data/data-grid/server-side-data/ServerSideDataGridNoCache.tsx b/docs/data/data-grid/server-side-data/ServerSideDataGridNoCache.tsx new file mode 100644 index 000000000000..ca8a9fe14814 --- /dev/null +++ b/docs/data/data-grid/server-side-data/ServerSideDataGridNoCache.tsx @@ -0,0 +1,60 @@ +import * as React from 'react'; +import { DataGridPro, GridDataSource } from '@mui/x-data-grid-pro'; +import { useMockServer } from '@mui/x-data-grid-generator'; + +const pageSizeOptions = [5, 10, 50]; + +const serverOptions = { useCursorPagination: false }; +const dataSetOptions = {}; + +export default function ServerSideDataGridNoCache() { + const { fetchRows, columns, initialState } = useMockServer( + dataSetOptions, + serverOptions, + ); + + const dataSource: GridDataSource = React.useMemo( + () => ({ + getRows: async (params) => { + const urlParams = new URLSearchParams({ + paginationModel: encodeURIComponent( + JSON.stringify(params.paginationModel), + ), + filterModel: encodeURIComponent(JSON.stringify(params.filterModel)), + sortModel: encodeURIComponent(JSON.stringify(params.sortModel)), + }); + const getRowsResponse = await fetchRows( + `https://mui.com/x/api/data-grid?${urlParams.toString()}`, + ); + return { + rows: getRowsResponse.rows, + rowCount: getRowsResponse.rowCount, + }; + }, + }), + [fetchRows], + ); + + const initialStateWithPagination = React.useMemo( + () => ({ + ...initialState, + pagination: { + paginationModel: { pageSize: 10, page: 0 }, + }, + }), + [initialState], + ); + + return ( +
+ +
+ ); +} diff --git a/docs/data/data-grid/server-side-data/ServerSideDataGridNoCache.tsx.preview b/docs/data/data-grid/server-side-data/ServerSideDataGridNoCache.tsx.preview new file mode 100644 index 000000000000..ed2e75557b91 --- /dev/null +++ b/docs/data/data-grid/server-side-data/ServerSideDataGridNoCache.tsx.preview @@ -0,0 +1,8 @@ + \ No newline at end of file diff --git a/docs/data/data-grid/server-side-data/ServerSideDataGridTTL.js b/docs/data/data-grid/server-side-data/ServerSideDataGridTTL.js new file mode 100644 index 000000000000..615cf4072a62 --- /dev/null +++ b/docs/data/data-grid/server-side-data/ServerSideDataGridTTL.js @@ -0,0 +1,60 @@ +import * as React from 'react'; +import { DataGridPro, GridDataSourceCacheDefault } from '@mui/x-data-grid-pro'; +import { useMockServer } from '@mui/x-data-grid-generator'; + +const lowTTLCache = new GridDataSourceCacheDefault({ ttl: 1000 * 10 }); // 10 seconds + +function ServerSideDataGridTTL() { + const { columns, initialState, fetchRows } = useMockServer( + {}, + { useCursorPagination: false }, + ); + + const dataSource = React.useMemo( + () => ({ + getRows: async (params) => { + const urlParams = new URLSearchParams({ + paginationModel: encodeURIComponent( + JSON.stringify(params.paginationModel), + ), + filterModel: encodeURIComponent(JSON.stringify(params.filterModel)), + sortModel: encodeURIComponent(JSON.stringify(params.sortModel)), + }); + const getRowsResponse = await fetchRows( + `https://mui.com/x/api/data-grid?${urlParams.toString()}`, + ); + return { + rows: getRowsResponse.rows, + rowCount: getRowsResponse.rowCount, + }; + }, + }), + [fetchRows], + ); + + const initialStateWithPagination = React.useMemo( + () => ({ + ...initialState, + pagination: { + paginationModel: { pageSize: 10, page: 0 }, + rowCount: 0, + }, + }), + [initialState], + ); + + return ( +
+ +
+ ); +} + +export default ServerSideDataGridTTL; diff --git a/docs/data/data-grid/server-side-data/ServerSideDataGridTTL.tsx b/docs/data/data-grid/server-side-data/ServerSideDataGridTTL.tsx new file mode 100644 index 000000000000..3f8f3525e78c --- /dev/null +++ b/docs/data/data-grid/server-side-data/ServerSideDataGridTTL.tsx @@ -0,0 +1,64 @@ +import * as React from 'react'; +import { + DataGridPro, + GridDataSource, + GridDataSourceCacheDefault, +} from '@mui/x-data-grid-pro'; +import { useMockServer } from '@mui/x-data-grid-generator'; + +const lowTTLCache = new GridDataSourceCacheDefault({ ttl: 1000 * 10 }); // 10 seconds + +function ServerSideDataGridTTL() { + const { columns, initialState, fetchRows } = useMockServer( + {}, + { useCursorPagination: false }, + ); + + const dataSource: GridDataSource = React.useMemo( + () => ({ + getRows: async (params) => { + const urlParams = new URLSearchParams({ + paginationModel: encodeURIComponent( + JSON.stringify(params.paginationModel), + ), + filterModel: encodeURIComponent(JSON.stringify(params.filterModel)), + sortModel: encodeURIComponent(JSON.stringify(params.sortModel)), + }); + const getRowsResponse = await fetchRows( + `https://mui.com/x/api/data-grid?${urlParams.toString()}`, + ); + return { + rows: getRowsResponse.rows, + rowCount: getRowsResponse.rowCount, + }; + }, + }), + [fetchRows], + ); + + const initialStateWithPagination = React.useMemo( + () => ({ + ...initialState, + pagination: { + paginationModel: { pageSize: 10, page: 0 }, + rowCount: 0, + }, + }), + [initialState], + ); + + return ( +
+ +
+ ); +} + +export default ServerSideDataGridTTL; diff --git a/docs/data/data-grid/server-side-data/ServerSideErrorHandling.js b/docs/data/data-grid/server-side-data/ServerSideErrorHandling.js new file mode 100644 index 000000000000..1bf394ea930f --- /dev/null +++ b/docs/data/data-grid/server-side-data/ServerSideErrorHandling.js @@ -0,0 +1,125 @@ +import * as React from 'react'; +import { DataGridPro, useGridApiRef, GridToolbar } from '@mui/x-data-grid-pro'; +import Button from '@mui/material/Button'; +import Checkbox from '@mui/material/Checkbox'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import { alpha, styled, darken, lighten } from '@mui/material/styles'; +import { useMockServer } from '@mui/x-data-grid-generator'; + +const pageSizeOptions = [5, 10, 50]; +const serverOptions = { useCursorPagination: false }; +const datasetOptions = {}; + +function getBorderColor(theme) { + if (theme.palette.mode === 'light') { + return lighten(alpha(theme.palette.divider, 1), 0.88); + } + return darken(alpha(theme.palette.divider, 1), 0.68); +} + +const StyledDiv = styled('div')(({ theme: t }) => ({ + position: 'absolute', + zIndex: 10, + fontSize: '0.875em', + top: 0, + height: '100%', + width: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: '4px', + border: `1px solid ${getBorderColor(t)}`, + backgroundColor: t.palette.background.default, +})); + +function ErrorOverlay({ error }) { + if (!error) { + return null; + } + return {error}; +} + +export default function ServerSideErrorHandling() { + const apiRef = useGridApiRef(); + const [error, setError] = React.useState(); + const [shouldRequestsFail, setShouldRequestsFail] = React.useState(false); + + const { fetchRows, ...props } = useMockServer( + datasetOptions, + serverOptions, + shouldRequestsFail, + ); + + const dataSource = React.useMemo( + () => ({ + getRows: async (params) => { + const urlParams = new URLSearchParams({ + paginationModel: encodeURIComponent( + JSON.stringify(params.paginationModel), + ), + filterModel: encodeURIComponent(JSON.stringify(params.filterModel)), + sortModel: encodeURIComponent(JSON.stringify(params.sortModel)), + }); + const getRowsResponse = await fetchRows( + `https://mui.com/x/api/data-grid?${urlParams.toString()}`, + ); + return { + rows: getRowsResponse.rows, + rowCount: getRowsResponse.rowCount, + }; + }, + }), + [fetchRows], + ); + + const initialState = React.useMemo( + () => ({ + ...props.initialState, + pagination: { + paginationModel: { + pageSize: 5, + }, + rowCount: 0, + }, + }), + [props.initialState], + ); + + return ( +
+
+ + setShouldRequestsFail(e.target.checked)} + /> + } + label="Make the requests fail" + /> +
+
+ setError(e.message)} + unstable_dataSourceCache={null} + apiRef={apiRef} + pagination + pageSizeOptions={pageSizeOptions} + initialState={initialState} + slots={{ toolbar: GridToolbar }} + /> + {error && } +
+
+ ); +} diff --git a/docs/data/data-grid/server-side-data/ServerSideErrorHandling.tsx b/docs/data/data-grid/server-side-data/ServerSideErrorHandling.tsx new file mode 100644 index 000000000000..a030a8bf6e50 --- /dev/null +++ b/docs/data/data-grid/server-side-data/ServerSideErrorHandling.tsx @@ -0,0 +1,131 @@ +import * as React from 'react'; +import { + DataGridPro, + useGridApiRef, + GridInitialState, + GridToolbar, + GridDataSource, +} from '@mui/x-data-grid-pro'; +import Button from '@mui/material/Button'; +import Checkbox from '@mui/material/Checkbox'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import { alpha, styled, darken, lighten, Theme } from '@mui/material/styles'; +import { useMockServer } from '@mui/x-data-grid-generator'; + +const pageSizeOptions = [5, 10, 50]; +const serverOptions = { useCursorPagination: false }; +const datasetOptions = {}; + +function getBorderColor(theme: Theme) { + if (theme.palette.mode === 'light') { + return lighten(alpha(theme.palette.divider, 1), 0.88); + } + return darken(alpha(theme.palette.divider, 1), 0.68); +} + +const StyledDiv = styled('div')(({ theme: t }) => ({ + position: 'absolute', + zIndex: 10, + fontSize: '0.875em', + top: 0, + height: '100%', + width: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: '4px', + border: `1px solid ${getBorderColor(t)}`, + backgroundColor: t.palette.background.default, +})); + +function ErrorOverlay({ error }: { error: string }) { + if (!error) { + return null; + } + return {error}; +} + +export default function ServerSideErrorHandling() { + const apiRef = useGridApiRef(); + const [error, setError] = React.useState(); + const [shouldRequestsFail, setShouldRequestsFail] = React.useState(false); + + const { fetchRows, ...props } = useMockServer( + datasetOptions, + serverOptions, + shouldRequestsFail, + ); + + const dataSource: GridDataSource = React.useMemo( + () => ({ + getRows: async (params) => { + const urlParams = new URLSearchParams({ + paginationModel: encodeURIComponent( + JSON.stringify(params.paginationModel), + ), + filterModel: encodeURIComponent(JSON.stringify(params.filterModel)), + sortModel: encodeURIComponent(JSON.stringify(params.sortModel)), + }); + const getRowsResponse = await fetchRows( + `https://mui.com/x/api/data-grid?${urlParams.toString()}`, + ); + return { + rows: getRowsResponse.rows, + rowCount: getRowsResponse.rowCount, + }; + }, + }), + [fetchRows], + ); + + const initialState: GridInitialState = React.useMemo( + () => ({ + ...props.initialState, + pagination: { + paginationModel: { + pageSize: 5, + }, + rowCount: 0, + }, + }), + [props.initialState], + ); + + return ( +
+
+ + setShouldRequestsFail(e.target.checked)} + /> + } + label="Make the requests fail" + /> +
+
+ setError(e.message)} + unstable_dataSourceCache={null} + apiRef={apiRef} + pagination + pageSizeOptions={pageSizeOptions} + initialState={initialState} + slots={{ toolbar: GridToolbar }} + /> + {error && } +
+
+ ); +} diff --git a/docs/data/data-grid/server-side-data/ServerSideTreeData.js b/docs/data/data-grid/server-side-data/ServerSideTreeData.js new file mode 100644 index 000000000000..ed0d0bc8e093 --- /dev/null +++ b/docs/data/data-grid/server-side-data/ServerSideTreeData.js @@ -0,0 +1,76 @@ +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'; + +const pageSizeOptions = [5, 10, 50]; +const dataSetOptions = { + dataSet: 'Employee', + rowLength: 1000, + treeData: { maxDepth: 3, groupingField: 'name', averageChildren: 5 }, +}; + +export default function ServerSideTreeData() { + const apiRef = useGridApiRef(); + + const { fetchRows, columns, initialState } = useMockServer(dataSetOptions); + + const initialStateWithPagination = React.useMemo( + () => ({ + ...initialState, + pagination: { + paginationModel: { + pageSize: 5, + }, + rowCount: 0, + }, + }), + [initialState], + ); + + const dataSource = React.useMemo( + () => ({ + getRows: async (params) => { + const urlParams = new URLSearchParams({ + paginationModel: encodeURIComponent( + JSON.stringify(params.paginationModel), + ), + filterModel: encodeURIComponent(JSON.stringify(params.filterModel)), + sortModel: encodeURIComponent(JSON.stringify(params.sortModel)), + groupKeys: encodeURIComponent(JSON.stringify(params.groupKeys)), + }); + 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], + ); + + return ( +
+ +
+ +
+
+ ); +} diff --git a/docs/data/data-grid/server-side-data/ServerSideTreeData.tsx b/docs/data/data-grid/server-side-data/ServerSideTreeData.tsx new file mode 100644 index 000000000000..86a2e47fd1ef --- /dev/null +++ b/docs/data/data-grid/server-side-data/ServerSideTreeData.tsx @@ -0,0 +1,82 @@ +import * as React from 'react'; +import { + DataGridPro, + useGridApiRef, + GridInitialState, + GridToolbar, + GridDataSource, +} from '@mui/x-data-grid-pro'; +import Button from '@mui/material/Button'; +import { useMockServer } from '@mui/x-data-grid-generator'; + +const pageSizeOptions = [5, 10, 50]; +const dataSetOptions = { + dataSet: 'Employee' as const, + rowLength: 1000, + treeData: { maxDepth: 3, groupingField: 'name', averageChildren: 5 }, +}; + +export default function ServerSideTreeData() { + const apiRef = useGridApiRef(); + + const { fetchRows, columns, initialState } = useMockServer(dataSetOptions); + + 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: encodeURIComponent( + JSON.stringify(params.paginationModel), + ), + filterModel: encodeURIComponent(JSON.stringify(params.filterModel)), + sortModel: encodeURIComponent(JSON.stringify(params.sortModel)), + groupKeys: encodeURIComponent(JSON.stringify(params.groupKeys)), + }); + 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], + ); + + return ( +
+ +
+ +
+
+ ); +} diff --git a/docs/data/data-grid/server-side-data/ServerSideTreeData.tsx.preview b/docs/data/data-grid/server-side-data/ServerSideTreeData.tsx.preview new file mode 100644 index 000000000000..4a508197000b --- /dev/null +++ b/docs/data/data-grid/server-side-data/ServerSideTreeData.tsx.preview @@ -0,0 +1,16 @@ + +
+ +
\ No newline at end of file diff --git a/docs/data/data-grid/server-side-data/ServerSideTreeDataCustomCache.js b/docs/data/data-grid/server-side-data/ServerSideTreeDataCustomCache.js new file mode 100644 index 000000000000..920a1b2d5609 --- /dev/null +++ b/docs/data/data-grid/server-side-data/ServerSideTreeDataCustomCache.js @@ -0,0 +1,107 @@ +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 { QueryClient } from '@tanstack/query-core'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 60, + }, + }, +}); + +function getKey(params) { + return [ + params.paginationModel, + params.sortModel, + params.filterModel, + params.groupKeys, + ]; +} + +const cache = { + set: (key, value) => { + const queryKey = getKey(key); + queryClient.setQueryData(queryKey, value); + }, + get: (key) => { + const queryKey = getKey(key); + return queryClient.getQueryData(queryKey); + }, + clear: () => { + queryClient.clear(); + }, +}; + +const pageSizeOptions = [5, 10, 50]; +const dataSetOptions = { + dataSet: 'Employee', + rowLength: 1000, + treeData: { maxDepth: 3, groupingField: 'name', averageChildren: 5 }, +}; + +export default function ServerSideTreeDataCustomCache() { + const apiRef = useGridApiRef(); + + const { fetchRows, ...props } = useMockServer(dataSetOptions); + + const dataSource = React.useMemo( + () => ({ + getRows: async (params) => { + const urlParams = new URLSearchParams({ + paginationModel: encodeURIComponent( + JSON.stringify(params.paginationModel), + ), + filterModel: encodeURIComponent(JSON.stringify(params.filterModel)), + sortModel: encodeURIComponent(JSON.stringify(params.sortModel)), + groupKeys: encodeURIComponent(JSON.stringify(params.groupKeys)), + }); + 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], + ); + + const initialState = React.useMemo( + () => ({ + ...props.initialState, + pagination: { + paginationModel: { + pageSize: 5, + }, + rowCount: 0, + }, + }), + [props.initialState], + ); + + return ( +
+ +
+ +
+
+ ); +} diff --git a/docs/data/data-grid/server-side-data/ServerSideTreeDataCustomCache.tsx b/docs/data/data-grid/server-side-data/ServerSideTreeDataCustomCache.tsx new file mode 100644 index 000000000000..a3f2961d549f --- /dev/null +++ b/docs/data/data-grid/server-side-data/ServerSideTreeDataCustomCache.tsx @@ -0,0 +1,115 @@ +import * as React from 'react'; +import { + DataGridPro, + useGridApiRef, + GridInitialState, + GridToolbar, + GridDataSourceCache, + GridDataSource, + GridGetRowsParams, +} from '@mui/x-data-grid-pro'; +import Button from '@mui/material/Button'; +import { useMockServer } from '@mui/x-data-grid-generator'; +import { QueryClient } from '@tanstack/query-core'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 60, + }, + }, +}); + +function getKey(params: GridGetRowsParams) { + return [ + params.paginationModel, + params.sortModel, + params.filterModel, + params.groupKeys, + ]; +} + +const cache: GridDataSourceCache = { + set: (key: GridGetRowsParams, value) => { + const queryKey = getKey(key); + queryClient.setQueryData(queryKey, value); + }, + get: (key: GridGetRowsParams) => { + const queryKey = getKey(key); + return queryClient.getQueryData(queryKey); + }, + clear: () => { + queryClient.clear(); + }, +}; + +const pageSizeOptions = [5, 10, 50]; +const dataSetOptions = { + dataSet: 'Employee' as 'Employee', + rowLength: 1000, + treeData: { maxDepth: 3, groupingField: 'name', averageChildren: 5 }, +}; + +export default function ServerSideTreeDataCustomCache() { + const apiRef = useGridApiRef(); + + const { fetchRows, ...props } = useMockServer(dataSetOptions); + + const dataSource: GridDataSource = React.useMemo( + () => ({ + getRows: async (params) => { + const urlParams = new URLSearchParams({ + paginationModel: encodeURIComponent( + JSON.stringify(params.paginationModel), + ), + filterModel: encodeURIComponent(JSON.stringify(params.filterModel)), + sortModel: encodeURIComponent(JSON.stringify(params.sortModel)), + groupKeys: encodeURIComponent(JSON.stringify(params.groupKeys)), + }); + 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], + ); + + const initialState: GridInitialState = React.useMemo( + () => ({ + ...props.initialState, + pagination: { + paginationModel: { + pageSize: 5, + }, + rowCount: 0, + }, + }), + [props.initialState], + ); + + return ( +
+ +
+ +
+
+ ); +} diff --git a/docs/data/data-grid/server-side-data/ServerSideTreeDataCustomCache.tsx.preview b/docs/data/data-grid/server-side-data/ServerSideTreeDataCustomCache.tsx.preview new file mode 100644 index 000000000000..1c8c80d0e4b6 --- /dev/null +++ b/docs/data/data-grid/server-side-data/ServerSideTreeDataCustomCache.tsx.preview @@ -0,0 +1,15 @@ + +
+ +
\ No newline at end of file diff --git a/docs/data/data-grid/server-side-data/ServerSideTreeDataErrorHandling.js b/docs/data/data-grid/server-side-data/ServerSideTreeDataErrorHandling.js new file mode 100644 index 000000000000..44da5bf0cab3 --- /dev/null +++ b/docs/data/data-grid/server-side-data/ServerSideTreeDataErrorHandling.js @@ -0,0 +1,148 @@ +import * as React from 'react'; +import { DataGridPro, useGridApiRef } from '@mui/x-data-grid-pro'; +import Snackbar from '@mui/material/Snackbar'; +import Button from '@mui/material/Button'; +import Checkbox from '@mui/material/Checkbox'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import { alpha, styled, darken, lighten } from '@mui/material/styles'; +import { useMockServer } from '@mui/x-data-grid-generator'; + +const pageSizeOptions = [5, 10, 50]; +const serverOptions = { useCursorPagination: false }; +const dataSetOptions = { + dataSet: 'Employee', + rowLength: 1000, + treeData: { maxDepth: 3, groupingField: 'name', averageChildren: 5 }, +}; + +export default function ServerSideTreeDataErrorHandling() { + const apiRef = useGridApiRef(); + const [rootError, setRootError] = React.useState(); + const [childrenError, setChildrenError] = React.useState(); + const [shouldRequestsFail, setShouldRequestsFail] = React.useState(false); + + const { fetchRows, ...props } = useMockServer( + dataSetOptions, + serverOptions, + shouldRequestsFail, + ); + + const dataSource = React.useMemo( + () => ({ + getRows: async (params) => { + const urlParams = new URLSearchParams({ + paginationModel: encodeURIComponent( + JSON.stringify(params.paginationModel), + ), + filterModel: encodeURIComponent(JSON.stringify(params.filterModel)), + sortModel: encodeURIComponent(JSON.stringify(params.sortModel)), + groupKeys: encodeURIComponent(JSON.stringify(params.groupKeys)), + }); + 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], + ); + + const initialState = React.useMemo( + () => ({ + ...props.initialState, + pagination: { + paginationModel: { + pageSize: 5, + }, + rowCount: 0, + }, + }), + [props.initialState], + ); + + return ( +
+
+ + setShouldRequestsFail(e.target.checked)} + /> + } + label="Make the requests fail" + /> +
+
+ { + if (!params.groupKeys || params.groupKeys.length === 0) { + setRootError(e.message); + } else { + setChildrenError( + `${e.message} (Requested level: ${params.groupKeys.join(' > ')})`, + ); + } + }} + unstable_dataSourceCache={null} + apiRef={apiRef} + pagination + pageSizeOptions={pageSizeOptions} + initialState={initialState} + /> + {rootError && } + setChildrenError('')} + message={childrenError} + /> +
+
+ ); +} + +function getBorderColor(theme) { + if (theme.palette.mode === 'light') { + return lighten(alpha(theme.palette.divider, 1), 0.88); + } + return darken(alpha(theme.palette.divider, 1), 0.68); +} + +const StyledDiv = styled('div')(({ theme: t }) => ({ + position: 'absolute', + zIndex: 10, + fontSize: '0.875em', + top: 0, + height: '100%', + width: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: '4px', + border: `1px solid ${getBorderColor(t)}`, + backgroundColor: t.palette.background.default, +})); + +function ErrorOverlay({ error }) { + if (!error) { + return null; + } + return {error}; +} diff --git a/docs/data/data-grid/server-side-data/ServerSideTreeDataErrorHandling.tsx b/docs/data/data-grid/server-side-data/ServerSideTreeDataErrorHandling.tsx new file mode 100644 index 000000000000..fdd4deeb1771 --- /dev/null +++ b/docs/data/data-grid/server-side-data/ServerSideTreeDataErrorHandling.tsx @@ -0,0 +1,153 @@ +import * as React from 'react'; +import { + DataGridPro, + useGridApiRef, + GridInitialState, + GridDataSource, +} from '@mui/x-data-grid-pro'; +import Snackbar from '@mui/material/Snackbar'; +import Button from '@mui/material/Button'; +import Checkbox from '@mui/material/Checkbox'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import { alpha, styled, darken, lighten, Theme } from '@mui/material/styles'; +import { useMockServer } from '@mui/x-data-grid-generator'; + +const pageSizeOptions = [5, 10, 50]; +const serverOptions = { useCursorPagination: false }; +const dataSetOptions = { + dataSet: 'Employee' as 'Employee', + rowLength: 1000, + treeData: { maxDepth: 3, groupingField: 'name', averageChildren: 5 }, +}; + +export default function ServerSideTreeDataErrorHandling() { + const apiRef = useGridApiRef(); + const [rootError, setRootError] = React.useState(); + const [childrenError, setChildrenError] = React.useState(); + const [shouldRequestsFail, setShouldRequestsFail] = React.useState(false); + + const { fetchRows, ...props } = useMockServer( + dataSetOptions, + serverOptions, + shouldRequestsFail, + ); + + const dataSource: GridDataSource = React.useMemo( + () => ({ + getRows: async (params) => { + const urlParams = new URLSearchParams({ + paginationModel: encodeURIComponent( + JSON.stringify(params.paginationModel), + ), + filterModel: encodeURIComponent(JSON.stringify(params.filterModel)), + sortModel: encodeURIComponent(JSON.stringify(params.sortModel)), + groupKeys: encodeURIComponent(JSON.stringify(params.groupKeys)), + }); + 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], + ); + + const initialState: GridInitialState = React.useMemo( + () => ({ + ...props.initialState, + pagination: { + paginationModel: { + pageSize: 5, + }, + rowCount: 0, + }, + }), + [props.initialState], + ); + + return ( +
+
+ + setShouldRequestsFail(e.target.checked)} + /> + } + label="Make the requests fail" + /> +
+
+ { + if (!params.groupKeys || params.groupKeys.length === 0) { + setRootError(e.message); + } else { + setChildrenError( + `${e.message} (Requested level: ${params.groupKeys.join(' > ')})`, + ); + } + }} + unstable_dataSourceCache={null} + apiRef={apiRef} + pagination + pageSizeOptions={pageSizeOptions} + initialState={initialState} + /> + {rootError && } + setChildrenError('')} + message={childrenError} + /> +
+
+ ); +} + +function getBorderColor(theme: Theme) { + if (theme.palette.mode === 'light') { + return lighten(alpha(theme.palette.divider, 1), 0.88); + } + return darken(alpha(theme.palette.divider, 1), 0.68); +} + +const StyledDiv = styled('div')(({ theme: t }) => ({ + position: 'absolute', + zIndex: 10, + fontSize: '0.875em', + top: 0, + height: '100%', + width: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: '4px', + border: `1px solid ${getBorderColor(t)}`, + backgroundColor: t.palette.background.default, +})); + +function ErrorOverlay({ error }: { error: string }) { + if (!error) { + return null; + } + return {error}; +} diff --git a/docs/data/data-grid/server-side-data/ServerSideTreeDataGroupExpansion.js b/docs/data/data-grid/server-side-data/ServerSideTreeDataGroupExpansion.js new file mode 100644 index 000000000000..9b61ceacdcf8 --- /dev/null +++ b/docs/data/data-grid/server-side-data/ServerSideTreeDataGroupExpansion.js @@ -0,0 +1,77 @@ +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'; + +const pageSizeOptions = [5, 10, 50]; +const dataSetOptions = { + dataSet: 'Employee', + rowLength: 1000, + treeData: { maxDepth: 3, groupingField: 'name', averageChildren: 5 }, +}; + +export default function ServerSideTreeDataGroupExpansion() { + const apiRef = useGridApiRef(); + + const { fetchRows, columns, initialState } = useMockServer(dataSetOptions); + + const dataSource = React.useMemo( + () => ({ + getRows: async (params) => { + const urlParams = new URLSearchParams({ + paginationModel: encodeURIComponent( + JSON.stringify(params.paginationModel), + ), + filterModel: encodeURIComponent(JSON.stringify(params.filterModel)), + sortModel: encodeURIComponent(JSON.stringify(params.sortModel)), + groupKeys: encodeURIComponent(JSON.stringify(params.groupKeys)), + }); + 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], + ); + + const initialStateWithPagination = React.useMemo( + () => ({ + ...initialState, + pagination: { + paginationModel: { + pageSize: 5, + }, + rowCount: 0, + }, + }), + [initialState], + ); + + return ( +
+ +
+ +
+
+ ); +} diff --git a/docs/data/data-grid/server-side-data/ServerSideTreeDataGroupExpansion.tsx b/docs/data/data-grid/server-side-data/ServerSideTreeDataGroupExpansion.tsx new file mode 100644 index 000000000000..9eb7fdd75ba4 --- /dev/null +++ b/docs/data/data-grid/server-side-data/ServerSideTreeDataGroupExpansion.tsx @@ -0,0 +1,85 @@ +import * as React from 'react'; +import { + DataGridPro, + useGridApiRef, + GridInitialState, + GridToolbar, + GridDataSource, +} from '@mui/x-data-grid-pro'; +import Button from '@mui/material/Button'; +import { useMockServer } from '@mui/x-data-grid-generator'; + +type DataSet = 'Commodity' | 'Employee'; + +const pageSizeOptions = [5, 10, 50]; +const dataSetOptions = { + dataSet: 'Employee' as DataSet, + rowLength: 1000, + treeData: { maxDepth: 3, groupingField: 'name', averageChildren: 5 }, +}; + +export default function ServerSideTreeDataGroupExpansion() { + const apiRef = useGridApiRef(); + + const { fetchRows, columns, initialState } = useMockServer(dataSetOptions); + + const dataSource: GridDataSource = React.useMemo( + () => ({ + getRows: async (params) => { + const urlParams = new URLSearchParams({ + paginationModel: encodeURIComponent( + JSON.stringify(params.paginationModel), + ), + filterModel: encodeURIComponent(JSON.stringify(params.filterModel)), + sortModel: encodeURIComponent(JSON.stringify(params.sortModel)), + groupKeys: encodeURIComponent(JSON.stringify(params.groupKeys)), + }); + 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], + ); + + const initialStateWithPagination: GridInitialState = React.useMemo( + () => ({ + ...initialState, + pagination: { + paginationModel: { + pageSize: 5, + }, + rowCount: 0, + }, + }), + [initialState], + ); + + return ( +
+ +
+ +
+
+ ); +} diff --git a/docs/data/data-grid/server-side-data/index.md b/docs/data/data-grid/server-side-data/index.md index a626f60d7c3c..38a003c98581 100644 --- a/docs/data/data-grid/server-side-data/index.md +++ b/docs/data/data-grid/server-side-data/index.md @@ -2,23 +2,19 @@ title: React Data Grid - Server-side data --- -# Data Grid - Server-side data 🚧 +# Data Grid - Server-side data [](/x/introduction/licensing/#pro-plan 'Pro plan')

The data grid server-side data.

-## Overview +## Introduction -Managing server-side data efficiently in a React application can become complex as the dataset grows. +Server-side data management in React can become complex with growing datasets. +Challenges include manual data fetching, pagination, sorting, filtering, and performance optimization. +A dedicated module can help abstract these complexities, improving user experience. -Without a dedicated module that abstracts its complexities, developers often face challenges related to manual data fetching, pagination, sorting, and filtering, and it often gets trickier to tackle performance issues, which can lead to a poor user experience. - -Have a look at an example: - -### Example scenario - -Imagine having a data grid that displays a list of users. The data grid has pagination enabled and the user can sort the data by clicking on the column headers and also apply filters. - -The data grid is configured to fetch data from the server whenever the user changes the page or updates filtering or sorting. +Consider a Data Grid displaying a list of users. +It supports pagination, sorting by column headers, and filtering. +The Data Grid fetches data from the server when the user changes the page or updates filtering or sorting. ```tsx const [rows, setRows] = React.useState([]); @@ -72,51 +68,51 @@ Trying to solve these problems one after the other can make the code complex and ## Data source -A very common pattern to solve these problems is to use a centralized data source. A data source is an abstraction layer that sits between the data grid and the server. It provides a simple interface to the data grid to fetch data and update it. It handles a lot of the complexities related to server-side data fetching. Let's delve a bit deeper into how it will look like. +The idea for a centralized data source is to simplify server-side data fetching. +It's an abstraction layer between the Data Grid and the server, providing a simple interface for interacting with the server. +Think of it like a middleman handling the communication between the Data Grid (client) and the actual data source (server). :::warning -This feature is still under development and the information shared on this page is subject to change. Feel free to subscribe or comment on the official GitHub [issue](https://github.com/mui/mui-x/issues/8179). +This feature is under development and is marked as **unstable**. +The information shared on this page could change in future. +Feel free to subscribe or comment on the official GitHub [umbrella issue](https://github.com/mui/mui-x/issues/8179). ::: -### Overview - -The Data Grid already supports manual server-side data fetching for features like sorting, filtering, etc. In order to make it more powerful and simple to use, the grid will support a data source interface that you can implement with your existing data fetching logic. - -The datasource will work with all the major data grid features which require server-side data fetching such as sorting, filtering, pagination, grouping, etc. +It has an initial set of required methods that you need to implement. The data grid will use these methods internally to fetch a sub-set of data when needed. -### Usage - -The data grid server-side data source has an initial set of required methods that you need to implement. The data grid will call these methods internally when the data is required for a specific page. +Let's take a look at the minimal `GridDataSource` interface configuration. ```tsx -interface DataSource { +interface GridDataSource { /** - Fetcher Functions: - - `getRows` is required - - `updateRow` is optional - - `getRows` will be used by the grid to fetch data for the current page or children for the current parent group - It may return a `rowCount` to update the total count of rows in the grid - */ - getRows(params: GetRowsParams): Promise; - updateRow?(updatedRow: GridRowModel): Promise; + * This method will be called when the grid needs to fetch some rows + * @param {GridGetRowsParams} params The parameters required to fetch the rows + * @returns {Promise} A promise that resolves to the data of type [GridGetRowsResponse] + */ + getRows(params: GridGetRowsParams): Promise; } ``` -Here's how the code will look like for the above example when implemented with data source: +:::info + +The above interface is a minimal configuration required for a data source to work. +More specific properties like `getChildrenCount` and `getGroupKey` will be discussed in the corresponding sections. + +::: + +Here's how the above mentioned example would look like when implemented with the data source: ```tsx -const customDataSource: DataSource = { - getRows: async (params: GetRowsParams): GetRowsResponse => { - // fetch data from server +const customDataSource: GridDataSource = { + getRows: async (params: GridGetRowsParams): GetRowsResponse => { const response = await fetch('https://my-api.com/data', { method: 'GET', body: JSON.stringify(params), }); const data = await response.json(); - // return the data and the total number of rows + return { rows: data.rows, rowCount: data.totalCount, @@ -126,161 +122,145 @@ const customDataSource: DataSource = { ``` -Not only the code has been reduced significantly, it has removed the hassle of managing controlled states and data fetching logic too. +The code has been significantly reduced, the need for managing the controlled states is removed, and data fetching logic is centralized. -On top of that, the data source will also handle a lot of other aspects like caching and deduping of requests. +## Server-side filtering, sorting, and pagination -#### Loading data +The data source changes how the existing server-side features like `filtering`, `sorting`, and `pagination` work. -The method `dataSource.getRows` will be called with the `GetRowsParams` object whenever some data from the server is needed. This object contains all the information that you need to fetch the data from the server. +**Without data source** -Since previously, the data grid did not support internal data fetching, the `rows` prop was the way to pass the data to the grid. However, with server-side data, the `rows` prop is no longer needed. Instead, the data grid will call the `getRows` method whenever it needs to fetch data. - -Here's the `GetRowsParams` object for reference: +When there's no data source, the features `filtering`, `sorting`, `pagination` work on `client` by default. +In order for them to work with server-side data, you need to set them to `server` explicitly and provide the [`onFilterModelChange`](https://mui.com/x/react-data-grid/filtering/server-side/), [`onSortModelChange`](https://mui.com/x/react-data-grid/sorting/#server-side-sorting), [`onPaginationModelChange`](https://mui.com/x/react-data-grid/pagination/#server-side-pagination) event handlers to fetch the data from the server based on the updated variables. ```tsx -interface GetRowsParams { - sortModel: GridSortModel; - filterModel: GridFilterModel; - /** - * Alternate to `start` and `end`, maps to `GridPaginationModel` interface - */ - paginationModel: GridPaginationModel; - /** - * First row index to fetch (number) or cursor information (number | string) - */ - start: number | string; // first row index to fetch or cursor information - /** - * Last row index to fetch - */ - end: number; // last row index to fetch - /** - * Array of keys returned by `getGroupKey` of all the parent rows until the row for which the data is requested - * `getGroupKey` prop must be implemented to use this - * Useful for `treeData` and `rowGrouping` only - */ - groupKeys: string[]; - /** - * List of grouped columns (only applicable with `rowGrouping`) - */ - groupFields: GridColDef['field'][]; // list of grouped columns (`rowGrouping`) -} + { + // fetch data from server + }} + onSortModelChange={(newSortModel) => { + // fetch data from server + }} + onFilterModelChange={(newFilterModel) => { + // fetch data from server + }} +/> ``` -And here's the `GetRowsResponse` object for reference: +**With data source** + +With the data source, the features `filtering`, `sorting`, `pagination` are automatically set to `server`. + +When the corresponding models update, the data grid calls the `getRows` method with the updated values of type `GridGetRowsParams` to get updated data. ```tsx -interface GetRowsResponse { - /** - * Subset of the rows as per the passed `GetRowsParams` - */ - rows: GridRowModel[]; - /** - * To reflect updates in total `rowCount` (optional) - * Useful when the `rowCount` is inaccurate (for example when filtering) or not available upfront - */ - rowCount?: number; - /** - * Additional `pageInfo` to help the grid determine if there are more rows to fetch (corner-cases) - * `hasNextPage`: When row count is unknown/inaccurate, if `truncated` is set or rowCount is not known, data will keep loading until `hasNextPage` is `false` - * `truncated`: To reflect `rowCount` is inaccurate (will trigger `x-y of many` in pagination after the count of rows fetched is greater than provided `rowCount`) - * It could be useful with: - * 1. Cursor based pagination: - * When rowCount is not known, grid will check for `hasNextPage` to determine - * if there are more rows to fetch. - * 2. Inaccurate `rowCount`: - * `truncated: true` will let the grid know that `rowCount` is estimated/truncated. - * Thus `hasNextPage` will come into play to check more rows are available to fetch after the number becomes >= provided `rowCount` - */ - pageInfo?: { - hasNextPage?: boolean; - truncated?: number; - }; -} + ``` -#### Updating data +The following demo showcases this behavior. -If provided, the method `dataSource.updateRow` will be called with the `GridRowModel` object whenever the user edits a row. This method is optional and you can skip it if you don't need to update the data on the server. It will work in a similar way as the `processRowUpdate` prop. +{{"demo": "ServerSideDataGrid.js", "bg": "inline"}} -#### Data Grid props +:::info +The data source demos use a utility function `useMockServer` to simulate the server-side data fetching. +In a real-world scenario, you should replace this with your own server-side data fetching logic. -These data grid props will work with the server-side data source: +Open info section of the browser console to see the requests being made and the data being fetched in response. +::: -- `dataSource: DataSource`: the data source object that you need to implement -- `rows`: will be ignored, could be skipped when `dataSource` is provided -- `rowCount`: will be used to identify the total number of rows in the grid, if not provided, the grid will check for the _GetRowsResponse.rowCount_ value, unless the feature being used is infinite loading where no `rowCount` is available at all. +## Data caching -Props related to grouped data (`treeData` and `rowGrouping`): +The data source caches fetched data by default. +This means that if the user navigates to a page or expands a node that has already been fetched, the grid will not call the `getRows` function again to avoid unnecessary calls to the server. -- `getGroupKey(row: GridRowModel): string` +The `GridDataSourceCacheDefault` is used by default which is a simple in-memory cache that stores the data in a plain object. It could be seen in action in the demo below. - will be used by the grid to group rows by their parent group - This effectively replaces `getTreeDataPath`. - Consider this structure: +{{"demo": "ServerSideDataGrid.js", "bg": "inline"}} - ```js - - (1) Sarah // groupKey 'Sarah' - - (2) Thomas // groupKey 'Thomas' - ``` +### Customize the cache lifetime - When (2) is expanded, the `getRows` function will be called with group keys `['Sarah', 'Thomas']`. +The `GridDataSourceCacheDefault` has a default Time To Live (`ttl`) of 5 minutes. To customize it, pass the `ttl` option in milliseconds to the `GridDataSourceCacheDefault` constructor, and then pass it as the `unstable_dataSourceCache` prop. -- `hasChildren?(row: GridRowModel): boolean` +```tsx +import { GridDataSourceCacheDefault } from '@mui/x-data-grid-pro'; - Will be used by the grid to determine if a row has children on server +const lowTTLCache = new GridDataSourceCacheDefault({ ttl: 1000 * 10 }); // 10 seconds -- `getChildrenCount?: (row: GridRowModel) => number` +; +``` + +{{"demo": "ServerSideDataGridTTL.js", "bg": "inline"}} + +### Custom cache - Will be used by the grid to determine the number of children of a row on server +To provide a custom cache, use `unstable_dataSourceCache` prop, which could be either written from scratch or based out of another cache library. +This prop accepts a generic interface of type `GridDataSourceCache`. -#### Existing server-side features +```tsx +export interface GridDataSourceCache { + set: (key: GridGetRowsParams, value: GridGetRowsResponse) => void; + get: (key: GridGetRowsParams) => GridGetRowsResponse | undefined; + clear: () => void; +} +``` -The server-side data source will change a bit the way existing server-side features like `filtering`, `sorting`, and `pagination` work. +### Disable cache -**Without data source**: -When there's no data source, the features `filtering`, `sorting`, `pagination` will work on `client` by default. In order for them to work with server-side data, you need to set them to `server` explicitly and listen to the [`onFilterModelChange`](https://mui.com/x/react-data-grid/filtering/server-side/), [`onSortModelChange`](https://mui.com/x/react-data-grid/sorting/#server-side-sorting), [`onPaginationModelChange`](https://mui.com/x/react-data-grid/pagination/#server-side-pagination) events to fetch the data from the server based on the updated variables. +To disable the data source cache, pass `null` to the `unstable_dataSourceCache` prop. ```tsx - { - // fetch data from server - }} - onSortModelChange={(newSortModel) => { - // fetch data from server - }} - onFilterModelChange={(newFilterModel) => { - // fetch data from server - }} + unstable_dataSource={customDataSource} + unstable_dataSourceCache={null} /> ``` -**With data source**: -However, with a valid data source passed the features `filtering`, `sorting`, `pagination` will automatically be set to `server`. +{{"demo": "ServerSideDataGridNoCache.js", "bg": "inline"}} -You just need to implement the `getRows` method and the data grid will call the `getRows` method with the proper params whenever it needs data. +## Error handling + +You could handle the errors with the data source by providing an error handler function using the `unstable_onDataSourceError`. +It will be called whenever there's an error in fetching the data. + +The first argument of this function is the error object, and the second argument is the fetch parameters of type `GridGetRowsParams`. ```tsx { + console.error(error); + }} /> ``` -#### Caching +{{"demo": "ServerSideErrorHandling.js", "bg": "inline"}} + +## Updating data 🚧 + +This feature is yet to be implemented, when completed, the method `unstable_dataSource.updateRow` will be called with the `GridRowModel` whenever the user edits a row. +It will work in a similar way as the `processRowUpdate` prop. -The data grid will cache the data it receives from the server. This means that if the user navigates to a page that has already been fetched, the grid will not call the `getRows` function again. This is to avoid unnecessary calls to the server. +Feel free to upvote the related GitHub [issue](https://github.com/mui/mui-x/issues/13261) to see this feature land faster. ## API 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 ba9d93e36719..8e77fe0542bf 100644 --- a/docs/data/data-grid/server-side-data/tree-data.md +++ b/docs/data/data-grid/server-side-data/tree-data.md @@ -2,14 +2,72 @@ title: React Server-side tree data --- -# Data Grid - Server-side tree data [](/x/introduction/licensing/#pro-plan 'Pro plan')🚧 +# Data Grid - Server-side tree data [](/x/introduction/licensing/#pro-plan 'Pro plan')

Tree data lazy-loading with server-side data source.

-:::warning -This feature isn't implemented yet. It's coming. +To dynamically load tree data from the server, including lazy-loading of children, you must create a data source and pass the `unstable_dataSource` prop to the Data Grid, as detailed in the [overview section](/x/react-data-grid/server-side-data/). -👍 Upvote [issue #3377](https://github.com/mui/mui-x/issues/3377) if you want to see it land faster. +The data source also requires some additional props to handle tree data, namely `getGroupKey` and `getChildrenCount`. +If the children count is not available for some reason, but there are some children, `getChildrenCount` should return `-1`. -Don't hesitate to leave a comment on the same issue to influence what gets built. Especially if you already have a use case for this component, or if you are facing a pain point with the [currently proposed workaround](https://mui.com/x/react-data-grid/tree-data/#children-lazy-loading). +```tsx +const customDataSource: GridDataSource = { + getRows: async (params) => { + // Fetch the data from the server + }, + getGroupKey: (row) => { + // Return the group key for the row, e.g. `name` + return row.name; + }, + getChildrenCount: (row) => { + // Return the number of children for the row + return row.childrenCount; + }, +}; +``` + +The following tree data example supports filtering, sorting, and pagination on the server. +It also caches the data by default. + +{{"demo": "ServerSideTreeData.js", "bg": "inline"}} + +:::info +The data source demos use a utility function `useMockServer` to simulate the server-side data fetching. +In a real-world scenario, you would replace this with your own server-side data fetching logic. + +Open the info section of the browser console to see the requests being made and the data being fetched in response. ::: + +## Error handling + +For each row group expansion, the data source is called to fetch the children. If an error occurs during the fetch, the grid will display an error message in the row group cell. `unstable_onDataSourceError` is also triggered with the error and the fetch params. + +The demo below shows a toast apart from the default error message in the grouping cell. Cache has been disabled in this demo for simplicity. + +{{"demo": "ServerSideTreeDataErrorHandling.js", "bg": "inline"}} + +## Group expansion + +The idea behind the group expansion is the same as explained in the [Row grouping](/x/react-data-grid/row-grouping/#group-expansion) section. +The difference is that the data is not initially available and is fetched automatically after the Data Grid is mounted based on the props `defaultGroupingExpansionDepth` and `isGroupExpandedByDefault` in a waterfall manner. + +The following demo uses `defaultGroupingExpansionDepth='-1'` to expand all levels of the tree by default. + +{{"demo": "ServerSideTreeDataGroupExpansion.js", "bg": "inline"}} + +## Custom cache + +The data source uses a cache by default to store the fetched data. +Use the `unstable_dataSourceCache` prop to provide a custom cache if necessary. +See [Data caching](/x/react-data-grid/server-side-data/#data-caching) for more info. + +The following demo uses `QueryClient` from `@tanstack/react-core` as a data source cache. + +{{"demo": "ServerSideTreeDataCustomCache.js", "bg": "inline"}} + +## API + +- [DataGrid](/x/api/data-grid/data-grid/) +- [DataGridPro](/x/api/data-grid/data-grid-pro/) +- [DataGridPremium](/x/api/data-grid/data-grid-premium/) diff --git a/docs/data/data-grid/tree-data/TreeDataLazyLoading.js b/docs/data/data-grid/tree-data/TreeDataLazyLoading.js deleted file mode 100644 index ea3f9c8fd2a4..000000000000 --- a/docs/data/data-grid/tree-data/TreeDataLazyLoading.js +++ /dev/null @@ -1,297 +0,0 @@ -import * as React from 'react'; -import { - DataGridPro, - getDataGridUtilityClass, - useGridApiContext, - useGridApiRef, - useGridRootProps, -} from '@mui/x-data-grid-pro'; -import { unstable_composeClasses as composeClasses, styled } from '@mui/material'; -import Box from '@mui/material/Box'; -import CircularProgress from '@mui/material/CircularProgress'; -import IconButton from '@mui/material/IconButton'; - -export const isNavigationKey = (key) => - key === 'Home' || - key === 'End' || - key.indexOf('Arrow') === 0 || - key.indexOf('Page') === 0 || - key === ' '; - -const ALL_ROWS = [ - { - hierarchy: ['Sarah'], - jobTitle: 'Head of Human Resources', - recruitmentDate: new Date(2020, 8, 12), - id: 0, - }, - { - hierarchy: ['Thomas'], - jobTitle: 'Head of Sales', - recruitmentDate: new Date(2017, 3, 4), - id: 1, - }, - { - hierarchy: ['Thomas', 'Robert'], - jobTitle: 'Sales Person', - recruitmentDate: new Date(2020, 11, 20), - id: 2, - }, - { - hierarchy: ['Thomas', 'Karen'], - jobTitle: 'Sales Person', - recruitmentDate: new Date(2020, 10, 14), - id: 3, - }, - { - hierarchy: ['Thomas', 'Nancy'], - jobTitle: 'Sales Person', - recruitmentDate: new Date(2017, 10, 29), - id: 4, - }, - { - hierarchy: ['Thomas', 'Daniel'], - jobTitle: 'Sales Person', - recruitmentDate: new Date(2020, 7, 21), - id: 5, - }, - { - hierarchy: ['Thomas', 'Christopher'], - jobTitle: 'Sales Person', - recruitmentDate: new Date(2020, 7, 20), - id: 6, - }, - { - hierarchy: ['Thomas', 'Donald'], - jobTitle: 'Sales Person', - recruitmentDate: new Date(2019, 6, 28), - id: 7, - }, - { - hierarchy: ['Mary'], - jobTitle: 'Head of Engineering', - recruitmentDate: new Date(2016, 3, 14), - id: 8, - }, - { - hierarchy: ['Mary', 'Jennifer'], - jobTitle: 'Tech lead front', - recruitmentDate: new Date(2016, 5, 17), - id: 9, - }, - { - hierarchy: ['Mary', 'Jennifer', 'Anna'], - jobTitle: 'Front-end developer', - recruitmentDate: new Date(2019, 11, 7), - id: 10, - }, - { - hierarchy: ['Mary', 'Michael'], - jobTitle: 'Tech lead devops', - recruitmentDate: new Date(2021, 7, 1), - id: 11, - }, - { - hierarchy: ['Mary', 'Linda'], - jobTitle: 'Tech lead back', - recruitmentDate: new Date(2017, 0, 12), - id: 12, - }, - { - hierarchy: ['Mary', 'Linda', 'Elizabeth'], - jobTitle: 'Back-end developer', - recruitmentDate: new Date(2019, 2, 22), - id: 13, - }, - { - hierarchy: ['Mary', 'Linda', 'William'], - jobTitle: 'Back-end developer', - recruitmentDate: new Date(2018, 4, 19), - id: 14, - }, -]; - -const columns = [ - { field: 'jobTitle', headerName: 'Job Title', width: 200 }, - { - field: 'recruitmentDate', - headerName: 'Recruitment Date', - type: 'date', - width: 150, - }, -]; - -const getChildren = (parentPath) => { - const parentPathStr = parentPath.join('-'); - return ALL_ROWS.filter( - (row) => row.hierarchy.slice(0, -1).join('-') === parentPathStr, - ); -}; - -/** - * This is a naive implementation with terrible performances on a real dataset. - * This fake server is only here for demonstration purposes. - */ -const fakeDataFetcher = (parentPath = []) => - new Promise((resolve) => { - setTimeout( - () => { - const rows = getChildren(parentPath).map((row) => ({ - ...row, - descendantCount: getChildren(row.hierarchy).length, - })); - resolve(rows); - }, - 500 + Math.random() * 300, - ); - }); - -const LoadingContainer = styled('div')({ - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - height: '100%', -}); - -const getTreeDataPath = (row) => row.hierarchy; - -const useUtilityClasses = (ownerState) => { - const { classes } = ownerState; - - const slots = { - root: ['treeDataGroupingCell'], - toggle: ['treeDataGroupingCellToggle'], - }; - - return composeClasses(slots, getDataGridUtilityClass, classes); -}; - -/** - * Reproduce the behavior of the `GridTreeDataGroupingCell` component in `@mui/x-data-grid-pro` - * But base the amount of children on a `row.descendantCount` property rather than on the internal lookups. - */ -function GroupingCellWithLazyLoading(props) { - const { id, rowNode, row, hideDescendantCount, formattedValue } = props; - - const rootProps = useGridRootProps(); - const apiRef = useGridApiContext(); - const classes = useUtilityClasses({ classes: rootProps.classes }); - - const isLoading = rowNode.childrenExpanded ? !row.childrenFetched : false; - - const Icon = rowNode.childrenExpanded - ? rootProps.slots.treeDataCollapseIcon - : rootProps.slots.treeDataExpandIcon; - - const handleClick = () => { - apiRef.current.setRowChildrenExpansion(id, !rowNode.childrenExpanded); - }; - - return ( - -
- {row.descendantCount > 0 && - (isLoading ? ( - - - - ) : ( - - - - ))} -
- - {formattedValue === undefined ? rowNode.groupingKey : formattedValue} - {!hideDescendantCount && row.descendantCount > 0 - ? ` (${row.descendantCount})` - : ''} - -
- ); -} - -const CUSTOM_GROUPING_COL_DEF = { - renderCell: (params) => , -}; - -// Optional -const getRowId = (row) => { - if (typeof row?.id === 'string' && row?.id.startsWith('placeholder-children-')) { - return row.id; - } - return row.id; -}; - -function updateRows(apiRef, rows) { - if (!apiRef.current) { - return; - } - const rowsToAdd = [...rows]; - rows.forEach((row) => { - if (row.descendantCount && row.descendantCount > 0) { - // Add a placeholder row to make the row expandable - rowsToAdd.push({ - id: `placeholder-children-${getRowId(row)}`, - hierarchy: [...row.hierarchy, ''], - }); - } - }); - apiRef.current.updateRows(rowsToAdd); -} - -const initialRows = []; - -export default function TreeDataLazyLoading() { - const apiRef = useGridApiRef(); - - React.useEffect(() => { - fakeDataFetcher().then((rowsData) => { - updateRows(apiRef, rowsData); - }); - - const handleRowExpansionChange = async (node) => { - const row = apiRef.current.getRow(node.id); - - if (!node.childrenExpanded || !row || row.childrenFetched) { - return; - } - - const childrenRows = await fakeDataFetcher(row.hierarchy); - updateRows(apiRef, [ - ...childrenRows, - { ...row, childrenFetched: true }, - { id: `placeholder-children-${node.id}`, _action: 'delete' }, - ]); - }; - - return apiRef.current.subscribeEvent( - 'rowExpansionChange', - handleRowExpansionChange, - ); - }, [apiRef]); - - return ( -
- -
- ); -} diff --git a/docs/data/data-grid/tree-data/TreeDataLazyLoading.tsx b/docs/data/data-grid/tree-data/TreeDataLazyLoading.tsx deleted file mode 100644 index 4eb26369ed09..000000000000 --- a/docs/data/data-grid/tree-data/TreeDataLazyLoading.tsx +++ /dev/null @@ -1,329 +0,0 @@ -import * as React from 'react'; -import { - DataGridPro, - GridApi, - getDataGridUtilityClass, - GridColDef, - DataGridProProps, - GridEventListener, - GridGroupingColDefOverride, - GridRenderCellParams, - GridRowModel, - GridRowsProp, - GridGroupNode, - useGridApiContext, - useGridApiRef, - useGridRootProps, - GridRowModelUpdate, - GridRowIdGetter, -} from '@mui/x-data-grid-pro'; -import { unstable_composeClasses as composeClasses, styled } from '@mui/material'; -import Box from '@mui/material/Box'; -import CircularProgress from '@mui/material/CircularProgress'; -import IconButton, { IconButtonProps } from '@mui/material/IconButton'; - -export const isNavigationKey = (key: string) => - key === 'Home' || - key === 'End' || - key.indexOf('Arrow') === 0 || - key.indexOf('Page') === 0 || - key === ' '; - -interface Row { - hierarchy: string[]; - jobTitle: string; - recruitmentDate: Date; - id: number; - descendantCount?: number; - childrenFetched?: boolean; -} - -const ALL_ROWS: GridRowModel[] = [ - { - hierarchy: ['Sarah'], - jobTitle: 'Head of Human Resources', - recruitmentDate: new Date(2020, 8, 12), - id: 0, - }, - { - hierarchy: ['Thomas'], - jobTitle: 'Head of Sales', - recruitmentDate: new Date(2017, 3, 4), - id: 1, - }, - { - hierarchy: ['Thomas', 'Robert'], - jobTitle: 'Sales Person', - recruitmentDate: new Date(2020, 11, 20), - id: 2, - }, - { - hierarchy: ['Thomas', 'Karen'], - jobTitle: 'Sales Person', - recruitmentDate: new Date(2020, 10, 14), - id: 3, - }, - { - hierarchy: ['Thomas', 'Nancy'], - jobTitle: 'Sales Person', - recruitmentDate: new Date(2017, 10, 29), - id: 4, - }, - { - hierarchy: ['Thomas', 'Daniel'], - jobTitle: 'Sales Person', - recruitmentDate: new Date(2020, 7, 21), - id: 5, - }, - { - hierarchy: ['Thomas', 'Christopher'], - jobTitle: 'Sales Person', - recruitmentDate: new Date(2020, 7, 20), - id: 6, - }, - { - hierarchy: ['Thomas', 'Donald'], - jobTitle: 'Sales Person', - recruitmentDate: new Date(2019, 6, 28), - id: 7, - }, - { - hierarchy: ['Mary'], - jobTitle: 'Head of Engineering', - recruitmentDate: new Date(2016, 3, 14), - id: 8, - }, - { - hierarchy: ['Mary', 'Jennifer'], - jobTitle: 'Tech lead front', - recruitmentDate: new Date(2016, 5, 17), - id: 9, - }, - { - hierarchy: ['Mary', 'Jennifer', 'Anna'], - jobTitle: 'Front-end developer', - recruitmentDate: new Date(2019, 11, 7), - id: 10, - }, - { - hierarchy: ['Mary', 'Michael'], - jobTitle: 'Tech lead devops', - recruitmentDate: new Date(2021, 7, 1), - id: 11, - }, - { - hierarchy: ['Mary', 'Linda'], - jobTitle: 'Tech lead back', - recruitmentDate: new Date(2017, 0, 12), - id: 12, - }, - { - hierarchy: ['Mary', 'Linda', 'Elizabeth'], - jobTitle: 'Back-end developer', - recruitmentDate: new Date(2019, 2, 22), - id: 13, - }, - { - hierarchy: ['Mary', 'Linda', 'William'], - jobTitle: 'Back-end developer', - recruitmentDate: new Date(2018, 4, 19), - id: 14, - }, -]; - -const columns: GridColDef[] = [ - { field: 'jobTitle', headerName: 'Job Title', width: 200 }, - { - field: 'recruitmentDate', - headerName: 'Recruitment Date', - type: 'date', - width: 150, - }, -]; - -const getChildren = (parentPath: string[]) => { - const parentPathStr = parentPath.join('-'); - return ALL_ROWS.filter( - (row) => row.hierarchy.slice(0, -1).join('-') === parentPathStr, - ); -}; - -/** - * This is a naive implementation with terrible performances on a real dataset. - * This fake server is only here for demonstration purposes. - */ -const fakeDataFetcher = (parentPath: string[] = []) => - new Promise[]>((resolve) => { - setTimeout( - () => { - const rows = getChildren(parentPath).map((row) => ({ - ...row, - descendantCount: getChildren(row.hierarchy).length, - })); - resolve(rows); - }, - 500 + Math.random() * 300, - ); - }); - -const LoadingContainer = styled('div')({ - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - height: '100%', -}); - -const getTreeDataPath: DataGridProProps['getTreeDataPath'] = (row) => row.hierarchy; - -const useUtilityClasses = (ownerState: { classes: DataGridProProps['classes'] }) => { - const { classes } = ownerState; - - const slots = { - root: ['treeDataGroupingCell'], - toggle: ['treeDataGroupingCellToggle'], - }; - - return composeClasses(slots, getDataGridUtilityClass, classes); -}; - -interface GroupingCellWithLazyLoadingProps - extends GridRenderCellParams { - hideDescendantCount?: boolean; -} - -/** - * Reproduce the behavior of the `GridTreeDataGroupingCell` component in `@mui/x-data-grid-pro` - * But base the amount of children on a `row.descendantCount` property rather than on the internal lookups. - */ -function GroupingCellWithLazyLoading(props: GroupingCellWithLazyLoadingProps) { - const { id, rowNode, row, hideDescendantCount, formattedValue } = props; - - const rootProps = useGridRootProps(); - const apiRef = useGridApiContext(); - const classes = useUtilityClasses({ classes: rootProps.classes }); - - const isLoading = rowNode.childrenExpanded ? !row.childrenFetched : false; - - const Icon = rowNode.childrenExpanded - ? rootProps.slots.treeDataCollapseIcon - : rootProps.slots.treeDataExpandIcon; - - const handleClick: IconButtonProps['onClick'] = () => { - apiRef.current.setRowChildrenExpansion(id, !rowNode.childrenExpanded); - }; - - return ( - -
- {row.descendantCount > 0 && - (isLoading ? ( - - - - ) : ( - - - - ))} -
- - {formattedValue === undefined ? rowNode.groupingKey : formattedValue} - {!hideDescendantCount && row.descendantCount > 0 - ? ` (${row.descendantCount})` - : ''} - -
- ); -} - -const CUSTOM_GROUPING_COL_DEF: GridGroupingColDefOverride = { - renderCell: (params) => ( - - ), -}; - -// Optional -const getRowId: GridRowIdGetter = (row) => { - if (typeof row?.id === 'string' && row?.id.startsWith('placeholder-children-')) { - return row.id; - } - return row.id; -}; - -function updateRows( - apiRef: React.MutableRefObject, - rows: GridRowModelUpdate[], -) { - if (!apiRef.current) { - return; - } - const rowsToAdd = [...rows]; - rows.forEach((row) => { - if (row.descendantCount && row.descendantCount > 0) { - // Add a placeholder row to make the row expandable - rowsToAdd.push({ - id: `placeholder-children-${getRowId(row)}`, - hierarchy: [...row.hierarchy, ''], - }); - } - }); - apiRef.current.updateRows(rowsToAdd); -} - -const initialRows: GridRowsProp = []; - -export default function TreeDataLazyLoading() { - const apiRef = useGridApiRef(); - - React.useEffect(() => { - fakeDataFetcher().then((rowsData) => { - updateRows(apiRef, rowsData); - }); - - const handleRowExpansionChange: GridEventListener<'rowExpansionChange'> = async ( - node, - ) => { - const row = apiRef.current.getRow(node.id) as Row | null; - - if (!node.childrenExpanded || !row || row.childrenFetched) { - return; - } - - const childrenRows = await fakeDataFetcher(row.hierarchy); - updateRows(apiRef, [ - ...childrenRows, - { ...row, childrenFetched: true }, - { id: `placeholder-children-${node.id}`, _action: 'delete' }, - ]); - }; - - return apiRef.current.subscribeEvent( - 'rowExpansionChange', - handleRowExpansionChange, - ); - }, [apiRef]); - - return ( -
- -
- ); -} diff --git a/docs/data/data-grid/tree-data/TreeDataLazyLoading.tsx.preview b/docs/data/data-grid/tree-data/TreeDataLazyLoading.tsx.preview deleted file mode 100644 index 527bdff70663..000000000000 --- a/docs/data/data-grid/tree-data/TreeDataLazyLoading.tsx.preview +++ /dev/null @@ -1,10 +0,0 @@ - \ No newline at end of file diff --git a/docs/data/data-grid/tree-data/tree-data.md b/docs/data/data-grid/tree-data/tree-data.md index fe6fa401e005..3af238f688a3 100644 --- a/docs/data/data-grid/tree-data/tree-data.md +++ b/docs/data/data-grid/tree-data/tree-data.md @@ -126,23 +126,9 @@ const invalidRows = [{ path: ['A'] }, { path: ['B'] }, { path: ['A', 'A'] }]; ::: -## Children lazy-loading 🚧 +## Children lazy-loading -:::warning -This feature isn't implemented yet. It's coming. - -👍 Upvote [issue #3377](https://github.com/mui/mui-x/issues/3377) if you want to see it land faster. - -Don't hesitate to leave a comment on the same issue to influence what gets built. Especially if you already have a use case for this component, or if you are facing a pain point with your current solution. -::: - -Alternatively, you can achieve a similar behavior by implementing this feature outside the component as shown below. -This implementation does not support every feature of the data grid but can be a good starting point for large datasets. - -The idea is to add a property `descendantCount` on the row and to use it instead of the internal grid state. -To do so, you need to override both the `renderCell` of the grouping column and to manually open the rows by listening to `rowExpansionChange` event. - -{{"demo": "TreeDataLazyLoading.js", "bg": "inline", "defaultCodeOpen": false}} +Check the [Server-side tree data](/x/react-data-grid/server-side-data/tree-data/) section for more information about lazy-loading tree data children. ## Full example diff --git a/docs/data/pages.ts b/docs/data/pages.ts index 003eae58bddd..ea9eb355a871 100644 --- a/docs/data/pages.ts +++ b/docs/data/pages.ts @@ -116,9 +116,10 @@ const pages: MuiPage[] = [ { pathname: '/x/react-data-grid/server-side-data-group', title: 'Server-side data', - planned: true, + plan: 'pro', children: [ - { pathname: '/x/react-data-grid/server-side-data', title: 'Overview', planned: true }, + { pathname: '/x/react-data-grid/server-side-data', title: 'Overview' }, + { pathname: '/x/react-data-grid/server-side-data/tree-data', plan: 'pro' }, { pathname: '/x/react-data-grid/server-side-data/lazy-loading', plan: 'pro', @@ -129,7 +130,6 @@ const pages: MuiPage[] = [ plan: 'pro', planned: true, }, - { pathname: '/x/react-data-grid/server-side-data/tree-data', plan: 'pro', planned: true }, { pathname: '/x/react-data-grid/server-side-data/row-grouping', plan: 'pro', diff --git a/docs/package.json b/docs/package.json index d5f9c2627309..8fd77423b9ad 100644 --- a/docs/package.json +++ b/docs/package.json @@ -45,6 +45,7 @@ "@mui/x-date-pickers-pro": "workspace:*", "@mui/x-tree-view": "workspace:*", "@react-spring/web": "^9.7.3", + "@tanstack/query-core": "^5.24.8", "ast-types": "^0.14.2", "autoprefixer": "^10.4.19", "babel-plugin-module-resolver": "^5.0.2", diff --git a/docs/pages/x/api/data-grid/grid-api.json b/docs/pages/x/api/data-grid/grid-api.json index 88b2080043cd..f37bb1bcf4ef 100644 --- a/docs/pages/x/api/data-grid/grid-api.json +++ b/docs/pages/x/api/data-grid/grid-api.json @@ -350,6 +350,7 @@ }, "required": true }, + "setLoading": { "type": { "description": "(loading: boolean) => void" }, "required": true }, "setPage": { "type": { "description": "(page: number) => void" }, "required": true }, "setPageSize": { "type": { "description": "(pageSize: number) => void" }, "required": true }, "setPaginationMeta": { @@ -466,6 +467,11 @@ "required": true, "isProPlan": true }, + "unstable_dataSource": { + "type": { "description": "GridDataSourceApiBase" }, + "required": true, + "isProPlan": true + }, "unstable_replaceRows": { "type": { "description": "(firstRowToReplace: number, newRows: GridRowModel[]) => void" }, "required": true diff --git a/docs/pages/x/api/data-grid/grid-pagination-model-api.json b/docs/pages/x/api/data-grid/grid-pagination-model-api.json new file mode 100644 index 000000000000..cb60edc245db --- /dev/null +++ b/docs/pages/x/api/data-grid/grid-pagination-model-api.json @@ -0,0 +1,21 @@ +{ + "name": "GridPaginationModelApi", + "description": "The pagination model API interface that is available in the grid `apiRef`.", + "properties": [ + { + "name": "setPage", + "description": "Sets the displayed page to the value given by page.", + "type": "(page: number) => void" + }, + { + "name": "setPageSize", + "description": "Sets the number of displayed rows to the value given by pageSize.", + "type": "(pageSize: number) => void" + }, + { + "name": "setPaginationModel", + "description": "Sets the paginationModel to a new value.", + "type": "(model: GridPaginationModel) => void" + } + ] +} diff --git a/docs/translations/api-docs/data-grid/grid-api.json b/docs/translations/api-docs/data-grid/grid-api.json index cec2f0cfce03..fd478d19f954 100644 --- a/docs/translations/api-docs/data-grid/grid-api.json +++ b/docs/translations/api-docs/data-grid/grid-api.json @@ -181,6 +181,7 @@ "setFilterModel": { "description": "Sets the filter model to the one given by model." }, + "setLoading": { "description": "Sets the internal loading state." }, "setPage": { "description": "Sets the displayed page to the value given by page." }, @@ -243,6 +244,7 @@ }, "toggleDetailPanel": { "description": "Expands or collapses the detail panel of a row." }, "unpinColumn": { "description": "Unpins a column." }, + "unstable_dataSource": { "description": "The data source API." }, "unstable_replaceRows": { "description": "Replace a set of rows with new rows." }, "unstable_setColumnVirtualization": { "description": "Enable/disable column virtualization." }, "unstable_setPinnedRows": { "description": "Changes the pinned rows." }, diff --git a/packages/x-data-grid-generator/src/hooks/index.ts b/packages/x-data-grid-generator/src/hooks/index.ts index 06e612fa89c8..84dd7368aea4 100644 --- a/packages/x-data-grid-generator/src/hooks/index.ts +++ b/packages/x-data-grid-generator/src/hooks/index.ts @@ -2,3 +2,6 @@ export * from './useDemoData'; export * from './useBasicDemoData'; export * from './useMovieData'; export * from './useQuery'; +export * from './useMockServer'; +export { loadServerRows } from './serverUtils'; +export type { QueryOptions } from './serverUtils'; diff --git a/packages/x-data-grid-generator/src/hooks/serverUtils.ts b/packages/x-data-grid-generator/src/hooks/serverUtils.ts new file mode 100644 index 000000000000..095893126643 --- /dev/null +++ b/packages/x-data-grid-generator/src/hooks/serverUtils.ts @@ -0,0 +1,492 @@ +import { + GridRowModel, + GridFilterModel, + GridSortModel, + GridLogicOperator, + GridFilterOperator, + GridColDef, + GridRowId, + GridPaginationModel, + GridValidRowModel, +} from '@mui/x-data-grid-pro'; +import { GridStateColDef } from '@mui/x-data-grid-pro/internals'; +import { UseDemoDataOptions } from './useDemoData'; +import { randomInt } from '../services/random-generator'; + +export interface FakeServerResponse { + returnedRows: GridRowModel[]; + nextCursor?: string; + hasNextPage?: boolean; + totalRowCount: number; +} + +export interface PageInfo { + totalRowCount?: number; + nextCursor?: string; + hasNextPage?: boolean; + pageSize?: number; +} + +export interface DefaultServerOptions { + minDelay: number; + maxDelay: number; + useCursorPagination?: boolean; +} + +export type ServerOptions = Partial; + +export interface QueryOptions { + cursor?: GridRowId; + page?: number; + pageSize?: number; + filterModel?: GridFilterModel; + sortModel?: GridSortModel; + firstRowToRender?: number; + lastRowToRender?: number; +} + +export interface ServerSideQueryOptions { + cursor?: GridRowId; + paginationModel?: GridPaginationModel; + groupKeys?: string[]; + filterModel?: GridFilterModel; + sortModel?: GridSortModel; + firstRowToRender?: number; + lastRowToRender?: number; +} + +export const DEFAULT_DATASET_OPTIONS: UseDemoDataOptions = { + dataSet: 'Commodity', + rowLength: 100, + maxColumns: 6, +}; + +declare const DISABLE_CHANCE_RANDOM: any; +export const disableDelay = typeof DISABLE_CHANCE_RANDOM !== 'undefined' && DISABLE_CHANCE_RANDOM; + +export const DEFAULT_SERVER_OPTIONS: DefaultServerOptions = { + minDelay: disableDelay ? 0 : 100, + maxDelay: disableDelay ? 0 : 300, + useCursorPagination: true, +}; + +const apiRef = {} as any; + +const simplifiedValueGetter = (field: string, colDef: GridColDef) => (row: GridRowModel) => { + return colDef.valueGetter?.(row[row.id] as never, row, colDef, apiRef) || row[field]; +}; + +const getRowComparator = ( + sortModel: GridSortModel | undefined, + columnsWithDefaultColDef: GridColDef[], +) => { + if (!sortModel) { + const comparator = () => 0; + return comparator; + } + const sortOperators = sortModel.map((sortItem) => { + const columnField = sortItem.field; + const colDef = columnsWithDefaultColDef.find(({ field }) => field === columnField) as any; + return { + ...sortItem, + valueGetter: simplifiedValueGetter(columnField, colDef), + sortComparator: colDef.sortComparator, + }; + }); + + const comparator = (row1: GridRowModel, row2: GridRowModel) => + sortOperators.reduce((acc, { valueGetter, sort, sortComparator }) => { + if (acc !== 0) { + return acc; + } + const v1 = valueGetter(row1); + const v2 = valueGetter(row2); + return sort === 'desc' ? -1 * sortComparator(v1, v2) : sortComparator(v1, v2); + }, 0); + + return comparator; +}; + +const buildQuickFilterApplier = (filterModel: GridFilterModel, columns: GridColDef[]) => { + const quickFilterValues = filterModel.quickFilterValues?.filter(Boolean) ?? []; + if (quickFilterValues.length === 0) { + return null; + } + + const appliersPerField = [] as { + column: GridColDef; + appliers: { + fn: null | ((...args: any[]) => boolean); + }[]; + }[]; + + const stubApiRef = { + current: { + getRowFormattedValue: (row: GridValidRowModel, c: GridColDef) => { + const field = c.field; + return row[field]; + }, + }, + }; + + columns.forEach((column) => { + const getApplyQuickFilterFn = column?.getApplyQuickFilterFn; + + if (getApplyQuickFilterFn) { + appliersPerField.push({ + column, + appliers: quickFilterValues.map((quickFilterValue) => { + return { + fn: getApplyQuickFilterFn( + quickFilterValue, + column as GridStateColDef, + stubApiRef as any, + ), + }; + }), + }); + } + }); + + return function isRowMatchingQuickFilter( + row: GridValidRowModel, + shouldApplyFilter?: (field: string) => boolean, + ) { + const result = {} as Record; + + /* eslint-disable no-labels */ + outer: for (let v = 0; v < quickFilterValues.length; v += 1) { + const filterValue = quickFilterValues[v]; + + for (let i = 0; i < appliersPerField.length; i += 1) { + const { column, appliers } = appliersPerField[i]; + const { field } = column; + + if (shouldApplyFilter && !shouldApplyFilter(field)) { + continue; + } + + const applier = appliers[v]; + const value = row[field]; + + if (applier.fn === null) { + continue; + } + const isMatching = applier.fn(value, row, column, stubApiRef); + + if (isMatching) { + result[filterValue] = true; + continue outer; + } + } + + result[filterValue] = false; + } + /* eslint-enable no-labels */ + + return result; + }; +}; + +const getQuicklyFilteredRows = ( + rows: GridRowModel[], + filterModel: GridFilterModel | undefined, + columnsWithDefaultColDef: GridColDef[], +) => { + if (filterModel === undefined || filterModel.quickFilterValues?.length === 0) { + return rows; + } + + const isRowMatchingQuickFilter = buildQuickFilterApplier(filterModel, columnsWithDefaultColDef); + + if (isRowMatchingQuickFilter) { + return rows.filter((row) => { + const result = isRowMatchingQuickFilter(row); + return filterModel.quickFilterLogicOperator === GridLogicOperator.And + ? Object.values(result).every(Boolean) + : Object.values(result).some(Boolean); + }); + } + return rows; +}; + +const getFilteredRows = ( + rows: GridRowModel[], + filterModel: GridFilterModel | undefined, + columnsWithDefaultColDef: GridColDef[], +) => { + if (filterModel === undefined || filterModel.items.length === 0) { + return rows; + } + + const valueGetters = filterModel.items.map(({ field }) => + simplifiedValueGetter( + field, + columnsWithDefaultColDef.find((column) => column.field === field) as any, + ), + ); + + const filterFunctions = filterModel.items.map((filterItem) => { + const { field, operator } = filterItem; + const colDef: GridColDef = columnsWithDefaultColDef.find( + (column) => column.field === field, + ) as any; + + if (!colDef.filterOperators) { + throw new Error(`MUI: No filter operator found for column '${field}'.`); + } + const filterOperator: any = colDef.filterOperators.find( + ({ value }: GridFilterOperator) => operator === value, + ); + + let parsedValue = filterItem.value; + + if (colDef.valueParser) { + const parser = colDef.valueParser; + parsedValue = Array.isArray(filterItem.value) + ? filterItem.value?.map((x) => parser(x, {}, colDef, apiRef)) + : parser(filterItem.value, {}, colDef, apiRef); + } + + return filterOperator.getApplyFilterFn({ filterItem, value: parsedValue }, colDef); + }); + + if (filterModel.logicOperator === GridLogicOperator.Or) { + return rows.filter((row: GridRowModel) => + filterModel.items.some((_, index) => { + const value = valueGetters[index](row); + return filterFunctions[index] === null ? true : filterFunctions[index](value); + }), + ); + } + return rows.filter((row: GridRowModel) => + filterModel.items.every((_, index) => { + const value = valueGetters[index](row); + return filterFunctions[index] === null ? true : filterFunctions[index](value); + }), + ); +}; + +/** + * Simulates server data loading + */ +export const loadServerRows = ( + rows: GridRowModel[], + queryOptions: QueryOptions, + serverOptions: ServerOptions, + columnsWithDefaultColDef: GridColDef[], +): Promise => { + const { minDelay = 100, maxDelay = 300, useCursorPagination } = serverOptions; + + if (maxDelay < minDelay) { + throw new Error('serverOptions.minDelay is larger than serverOptions.maxDelay '); + } + const delay = randomInt(minDelay, maxDelay); + + const { cursor, page = 0, pageSize } = queryOptions; + + let nextCursor; + let firstRowIndex; + let lastRowIndex; + + let filteredRows = getFilteredRows(rows, queryOptions.filterModel, columnsWithDefaultColDef); + + const rowComparator = getRowComparator(queryOptions.sortModel, columnsWithDefaultColDef); + filteredRows = [...filteredRows].sort(rowComparator); + + const totalRowCount = filteredRows.length; + if (!pageSize) { + firstRowIndex = 0; + lastRowIndex = filteredRows.length; + } else if (useCursorPagination) { + firstRowIndex = cursor ? filteredRows.findIndex(({ id }) => id === cursor) : 0; + firstRowIndex = Math.max(firstRowIndex, 0); // if cursor not found return 0 + lastRowIndex = firstRowIndex + pageSize; + + nextCursor = lastRowIndex >= filteredRows.length ? undefined : filteredRows[lastRowIndex].id; + } else { + firstRowIndex = page * pageSize; + lastRowIndex = (page + 1) * pageSize; + } + const hasNextPage = lastRowIndex < filteredRows.length - 1; + const response: FakeServerResponse = { + returnedRows: filteredRows.slice(firstRowIndex, lastRowIndex), + hasNextPage, + nextCursor, + totalRowCount, + }; + + return new Promise((resolve) => { + setTimeout(() => { + resolve(response); + }, delay); // simulate network latency + }); +}; + +interface ProcessTreeDataRowsResponse { + rows: GridRowModel[]; + rootRowCount: number; +} + +const findTreeDataRowChildren = ( + allRows: GridRowModel[], + parentPath: string[], + pathKey: string = 'path', + depth: number = 1, // the depth of the children to find relative to parentDepth, `-1` to find all +) => { + const parentDepth = parentPath.length; + const children = []; + for (let i = 0; i < allRows.length; i += 1) { + const row = allRows[i]; + const rowPath = row[pathKey]; + if (!rowPath) { + continue; + } + if ( + ((depth < 0 && rowPath.length > parentDepth) || rowPath.length === parentDepth + depth) && + parentPath.every((value, index) => value === rowPath[index]) + ) { + children.push(row); + } + } + return children; +}; + +type GetTreeDataFilteredRows = ( + rows: GridValidRowModel[], + filterModel: GridFilterModel | undefined, + columnsWithDefaultColDef: GridColDef[], +) => GridValidRowModel; + +const getTreeDataFilteredRows: GetTreeDataFilteredRows = ( + rows, + filterModel, + columnsWithDefaultColDef, +): GridValidRowModel[] => { + let filteredRows = [...rows]; + if (filterModel && filterModel.quickFilterValues?.length! > 0) { + filteredRows = getQuicklyFilteredRows(rows, filterModel, columnsWithDefaultColDef); + } + if ((filterModel?.items.length ?? 0) > 0) { + filteredRows = getFilteredRows(filteredRows, filterModel, columnsWithDefaultColDef); + } + + if (filteredRows.length === rows.length || filteredRows.length === 0) { + return filteredRows; + } + + const pathsToIndexesMap = new Map(); + rows.forEach((row: GridValidRowModel, index: number) => { + pathsToIndexesMap.set(row.path.join(','), index); + }); + + const includedPaths = new Set(); + filteredRows.forEach((row) => { + includedPaths.add(row.path.join(',')); + }); + + const missingChildren: GridValidRowModel[] = []; + + // include missing children of filtered rows + filteredRows.forEach((row) => { + const path = row.path; + if (path) { + const children = findTreeDataRowChildren(rows, path, 'path', -1); + children.forEach((child) => { + const subPath = child.path.join(','); + if (!includedPaths.has(subPath)) { + missingChildren.push(child); + } + }); + } + }); + + filteredRows = missingChildren.concat(filteredRows); + + const missingParents: GridValidRowModel[] = []; + + // include missing parents of filtered rows + filteredRows.forEach((row) => { + const path = row.path; + if (path) { + includedPaths.add(path.join(',')); + for (let i = 0; i < path.length - 1; i += 1) { + const subPath = path.slice(0, i + 1).join(','); + if (!includedPaths.has(subPath)) { + const index = pathsToIndexesMap.get(subPath); + if (index !== undefined) { + missingParents.push(rows[index]); + includedPaths.add(subPath); + } + } + } + } + }); + + return missingParents.concat(filteredRows); +}; + +/** + * Simulates server data loading + */ +export const processTreeDataRows = ( + rows: GridRowModel[], + queryOptions: ServerSideQueryOptions, + serverOptions: ServerOptions, + columnsWithDefaultColDef: GridColDef[], +): Promise => { + const { minDelay = 100, maxDelay = 300 } = serverOptions; + const pathKey = 'path'; + // TODO: Support filtering and cursor based pagination + if (maxDelay < minDelay) { + throw new Error('serverOptions.minDelay is larger than serverOptions.maxDelay '); + } + + if (queryOptions.groupKeys == null) { + throw new Error('serverOptions.groupKeys must be defined to compute tree data '); + } + + const delay = randomInt(minDelay, maxDelay); + + // apply plain filtering + const filteredRows = getTreeDataFilteredRows( + rows, + queryOptions.filterModel, + columnsWithDefaultColDef, + ) as GridValidRowModel[]; + + // get root row count + const rootRowCount = findTreeDataRowChildren(filteredRows, []).length; + + // find direct children referring to the `parentPath` + const childRows = findTreeDataRowChildren(filteredRows, queryOptions.groupKeys); + + let childRowsWithDescendantCounts = childRows.map((row) => { + const descendants = findTreeDataRowChildren(filteredRows, row[pathKey], pathKey, -1); + const descendantCount = descendants.length; + return { ...row, descendantCount } as GridRowModel; + }); + + if (queryOptions.sortModel) { + // apply sorting + const rowComparator = getRowComparator(queryOptions.sortModel, columnsWithDefaultColDef); + childRowsWithDescendantCounts = [...childRowsWithDescendantCounts].sort(rowComparator); + } + + if (queryOptions.paginationModel && queryOptions.groupKeys.length === 0) { + // Only paginate root rows, grid should refetch root rows when `paginationModel` updates + const { pageSize, page } = queryOptions.paginationModel; + if (pageSize < childRowsWithDescendantCounts.length) { + childRowsWithDescendantCounts = childRowsWithDescendantCounts.slice( + page * pageSize, + (page + 1) * pageSize, + ); + } + } + + return new Promise((resolve) => { + setTimeout(() => { + resolve({ rows: childRowsWithDescendantCounts, rootRowCount }); + }, delay); // simulate network latency + }); +}; diff --git a/packages/x-data-grid-generator/src/hooks/useDemoData.ts b/packages/x-data-grid-generator/src/hooks/useDemoData.ts index 28ad937181b9..2687290dc027 100644 --- a/packages/x-data-grid-generator/src/hooks/useDemoData.ts +++ b/packages/x-data-grid-generator/src/hooks/useDemoData.ts @@ -37,7 +37,10 @@ export interface UseDemoDataOptions { // Generate fake data from a seed. // It's about x20 faster than getRealData. -async function extrapolateSeed(rowLength: number, data: GridDemoData): Promise { +export async function extrapolateSeed( + rowLength: number, + data: GridDemoData, +): Promise { return new Promise((resolve) => { const seed = data.rows; const rows = data.rows.slice(); @@ -70,7 +73,7 @@ async function extrapolateSeed(rowLength: number, data: GridDemoData): Promise(object: T): T => { +export const deepFreeze = (object: T): T => { // Retrieve the property names defined on object const propNames = Object.getOwnPropertyNames(object); diff --git a/packages/x-data-grid-generator/src/hooks/useMockServer.ts b/packages/x-data-grid-generator/src/hooks/useMockServer.ts new file mode 100644 index 000000000000..36a4abde2402 --- /dev/null +++ b/packages/x-data-grid-generator/src/hooks/useMockServer.ts @@ -0,0 +1,278 @@ +import * as React from 'react'; +import LRUCache from 'lru-cache'; +import { + getGridDefaultColumnTypes, + GridRowModel, + GridGetRowsParams, + GridGetRowsResponse, + GridColDef, + GridInitialState, + GridColumnVisibilityModel, +} from '@mui/x-data-grid-pro'; +import { + UseDemoDataOptions, + getColumnsFromOptions, + extrapolateSeed, + deepFreeze, +} from './useDemoData'; +import { GridColDefGenerator } from '../services/gridColDefGenerator'; +import { getRealGridData, GridDemoData } from '../services/real-data-service'; +import { addTreeDataOptionsToDemoData } from '../services/tree-data-generator'; +import { + loadServerRows, + processTreeDataRows, + DEFAULT_DATASET_OPTIONS, + DEFAULT_SERVER_OPTIONS, +} from './serverUtils'; +import type { ServerOptions } from './serverUtils'; +import { randomInt } from '../services'; + +const dataCache = new LRUCache({ + max: 10, + ttl: 60 * 5 * 1e3, // 5 minutes +}); + +export const BASE_URL = 'https://mui.com/x/api/data-grid'; + +type UseMockServerResponse = { + columns: GridColDef[]; + initialState: GridInitialState; + getGroupKey?: (row: GridRowModel) => string; + getChildrenCount?: (row: GridRowModel) => number; + fetchRows: (url: string) => Promise; + loadNewData: () => void; +}; + +function decodeParams(url: string): GridGetRowsParams { + const params = new URL(url).searchParams; + const decodedParams = {} as any; + const array = Array.from(params.entries()); + + for (const [key, value] of array) { + try { + decodedParams[key] = JSON.parse(decodeURIComponent(value)); + } catch (e) { + decodedParams[key] = value; + } + } + + return decodedParams as GridGetRowsParams; +} + +const getInitialState = (columns: GridColDefGenerator[], groupingField?: string) => { + const columnVisibilityModel: GridColumnVisibilityModel = {}; + columns.forEach((col) => { + if (col.hide) { + columnVisibilityModel[col.field] = false; + } + }); + + if (groupingField) { + columnVisibilityModel![groupingField] = false; + } + + return { columns: { columnVisibilityModel } }; +}; + +const defaultColDef = getGridDefaultColumnTypes(); + +export const useMockServer = ( + dataSetOptions?: Partial, + serverOptions?: ServerOptions & { verbose?: boolean }, + shouldRequestsFail?: boolean, +): UseMockServerResponse => { + const [data, setData] = React.useState(); + const [index, setIndex] = React.useState(0); + const shouldRequestsFailRef = React.useRef(shouldRequestsFail ?? false); + + React.useEffect(() => { + if (shouldRequestsFail !== undefined) { + shouldRequestsFailRef.current = shouldRequestsFail; + } + }, [shouldRequestsFail]); + + const options = { ...DEFAULT_DATASET_OPTIONS, ...dataSetOptions }; + + const columns = React.useMemo(() => { + return getColumnsFromOptions({ + dataSet: options.dataSet, + editable: options.editable, + maxColumns: options.maxColumns, + visibleFields: options.visibleFields, + }); + }, [options.dataSet, options.editable, options.maxColumns, options.visibleFields]); + + const initialState = React.useMemo( + () => getInitialState(columns, options.treeData?.groupingField), + [columns, options.treeData?.groupingField], + ); + + const columnsWithDefaultColDef: GridColDef[] = React.useMemo( + () => + columns.map((column) => ({ + ...defaultColDef[column.type || 'string'], + ...column, + })), + [columns], + ); + + const isTreeData = options.treeData?.groupingField != null; + + const getGroupKey = React.useMemo(() => { + if (isTreeData) { + return (row: GridRowModel): string => row[options.treeData!.groupingField!]; + } + return undefined; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [options.treeData?.groupingField, isTreeData]); + + const getChildrenCount = React.useMemo(() => { + if (isTreeData) { + return (row: GridRowModel): number => row.descendantCount; + } + return undefined; + }, [isTreeData]); + + React.useEffect(() => { + const cacheKey = `${options.dataSet}-${options.rowLength}-${index}-${options.maxColumns}`; + + // Cache to allow fast switch between the JavaScript and TypeScript version + // of the demos. + if (dataCache.has(cacheKey)) { + const newData = dataCache.get(cacheKey)!; + setData(newData); + return undefined; + } + + let active = true; + + (async () => { + let rowData; + const rowLength = options.rowLength; + if (rowLength > 1000) { + rowData = await getRealGridData(1000, columns); + rowData = await extrapolateSeed(rowLength, rowData); + } else { + rowData = await getRealGridData(rowLength, columns); + } + + if (!active) { + return; + } + + if (isTreeData) { + rowData = addTreeDataOptionsToDemoData(rowData, { + maxDepth: options.treeData?.maxDepth, + groupingField: options.treeData?.groupingField, + averageChildren: options.treeData?.averageChildren, + }); + } + + if (process.env.NODE_ENV !== 'production') { + deepFreeze(rowData); + } + + dataCache.set(cacheKey, rowData); + setData(rowData); + })(); + + return () => { + active = false; + }; + }, [ + columns, + isTreeData, + options.rowLength, + options.treeData?.maxDepth, + options.treeData?.groupingField, + options.treeData?.averageChildren, + options.dataSet, + options.maxColumns, + index, + ]); + + const fetchRows = React.useCallback( + async (requestUrl: string): Promise => { + if (!data || !requestUrl) { + return new Promise((resolve) => { + resolve({ rows: [], rowCount: 0 }); + }); + } + const params = decodeParams(requestUrl); + const verbose = serverOptions?.verbose ?? true; + // eslint-disable-next-line no-console + const print = console.info; + if (verbose) { + print('MUI X: DATASOURCE REQUEST', params); + } + let getRowsResponse: GridGetRowsResponse; + const serverOptionsWithDefault = { + minDelay: serverOptions?.minDelay ?? DEFAULT_SERVER_OPTIONS.minDelay, + maxDelay: serverOptions?.maxDelay ?? DEFAULT_SERVER_OPTIONS.maxDelay, + useCursorPagination: + serverOptions?.useCursorPagination ?? DEFAULT_SERVER_OPTIONS.useCursorPagination, + }; + + if (shouldRequestsFailRef.current) { + const { minDelay, maxDelay } = serverOptionsWithDefault; + const delay = randomInt(minDelay, maxDelay); + return new Promise((_, reject) => { + if (verbose) { + print('MUI X: DATASOURCE REQUEST FAILURE', params); + } + setTimeout(() => reject(new Error('Could not fetch the data')), delay); + }); + } + + if (isTreeData /* || TODO: `isRowGrouping` */) { + const { rows, rootRowCount } = await processTreeDataRows( + data.rows, + params, + serverOptionsWithDefault, + columnsWithDefaultColDef, + ); + + getRowsResponse = { + rows: rows.slice().map((row) => ({ ...row, path: undefined })), + rowCount: rootRowCount, + }; + } else { + // plain data + const { returnedRows, nextCursor, totalRowCount } = await loadServerRows( + data.rows, + { ...params, ...params.paginationModel }, + serverOptionsWithDefault, + columnsWithDefaultColDef, + ); + getRowsResponse = { rows: returnedRows, rowCount: totalRowCount, pageInfo: { nextCursor } }; + } + + return new Promise((resolve) => { + if (verbose) { + print('MUI X: DATASOURCE RESPONSE', params, getRowsResponse); + } + resolve(getRowsResponse); + }); + }, + [ + data, + serverOptions?.verbose, + serverOptions?.minDelay, + serverOptions?.maxDelay, + serverOptions?.useCursorPagination, + isTreeData, + columnsWithDefaultColDef, + ], + ); + + return { + columns: columnsWithDefaultColDef, + initialState, + getGroupKey, + getChildrenCount, + fetchRows, + loadNewData: () => { + setIndex((oldIndex) => oldIndex + 1); + }, + }; +}; diff --git a/packages/x-data-grid-generator/src/hooks/useQuery.ts b/packages/x-data-grid-generator/src/hooks/useQuery.ts index 55adae6cb6a7..62ae140bcdcd 100644 --- a/packages/x-data-grid-generator/src/hooks/useQuery.ts +++ b/packages/x-data-grid-generator/src/hooks/useQuery.ts @@ -1,14 +1,5 @@ import * as React from 'react'; -import { - getGridDefaultColumnTypes, - GridRowModel, - GridFilterModel, - GridSortModel, - GridRowId, - GridLogicOperator, - GridFilterOperator, - GridColDef, -} from '@mui/x-data-grid-pro'; +import { getGridDefaultColumnTypes, GridRowModel } from '@mui/x-data-grid-pro'; import { isDeepEqual } from '@mui/x-data-grid/internals'; import { useDemoData, @@ -16,198 +7,8 @@ import { getColumnsFromOptions, getInitialState, } from './useDemoData'; -import { randomInt } from '../services/random-generator'; - -const apiRef = {} as any; - -const simplifiedValueGetter = (field: string, colDef: GridColDef) => (row: GridRowModel) => { - return colDef.valueGetter?.(row[row.id] as never, row, colDef, apiRef) || row[field]; -}; - -const getRowComparator = ( - sortModel: GridSortModel | undefined, - columnsWithDefaultColDef: GridColDef[], -) => { - if (!sortModel) { - const comparator = () => 0; - return comparator; - } - const sortOperators = sortModel.map((sortItem) => { - const columnField = sortItem.field; - const colDef = columnsWithDefaultColDef.find(({ field }) => field === columnField) as any; - return { - ...sortItem, - valueGetter: simplifiedValueGetter(columnField, colDef), - sortComparator: colDef.sortComparator, - }; - }); - - const comparator = (row1: GridRowModel, row2: GridRowModel) => - sortOperators.reduce((acc, { valueGetter, sort, sortComparator }) => { - if (acc !== 0) { - return acc; - } - const v1 = valueGetter(row1); - const v2 = valueGetter(row2); - return sort === 'desc' ? -1 * sortComparator(v1, v2) : sortComparator(v1, v2); - }, 0); - - return comparator; -}; - -const getFilteredRows = ( - rows: GridRowModel[], - filterModel: GridFilterModel | undefined, - columnsWithDefaultColDef: GridColDef[], -) => { - if (filterModel === undefined || filterModel.items.length === 0) { - return rows; - } - - const valueGetters = filterModel.items.map(({ field }) => - simplifiedValueGetter( - field, - columnsWithDefaultColDef.find((column) => column.field === field) as any, - ), - ); - const filterFunctions = filterModel.items.map((filterItem) => { - const { field, operator } = filterItem; - const colDef = columnsWithDefaultColDef.find((column) => column.field === field) as any; - - const filterOperator: any = colDef.filterOperators.find( - ({ value }: GridFilterOperator) => operator === value, - ); - - let parsedValue = filterItem.value; - if (colDef.valueParser) { - const parser = colDef.valueParser; - parsedValue = Array.isArray(filterItem.value) - ? filterItem.value?.map((x) => parser(x)) - : parser(filterItem.value); - } - - return filterOperator?.getApplyFilterFn({ filterItem, value: parsedValue }, colDef); - }); - - if (filterModel.logicOperator === GridLogicOperator.Or) { - return rows.filter((row: GridRowModel) => - filterModel.items.some((_, index) => { - const value = valueGetters[index](row); - return filterFunctions[index] === null ? true : filterFunctions[index]({ value }); - }), - ); - } - return rows.filter((row: GridRowModel) => - filterModel.items.every((_, index) => { - const value = valueGetters[index](row); - return filterFunctions[index] === null ? true : filterFunctions[index](value); - }), - ); -}; - -/** - * Simulates server data loading - */ -export const loadServerRows = ( - rows: GridRowModel[], - queryOptions: QueryOptions, - serverOptions: ServerOptions, - columnsWithDefaultColDef: GridColDef[], -): Promise => { - const { minDelay = 100, maxDelay = 300, useCursorPagination } = serverOptions; - - if (maxDelay < minDelay) { - throw new Error('serverOptions.minDelay is larger than serverOptions.maxDelay '); - } - const delay = randomInt(minDelay, maxDelay); - - const { cursor, page = 0, pageSize } = queryOptions; - - let nextCursor; - let firstRowIndex; - let lastRowIndex; - - let filteredRows = getFilteredRows(rows, queryOptions.filterModel, columnsWithDefaultColDef); - - const rowComparator = getRowComparator(queryOptions.sortModel, columnsWithDefaultColDef); - filteredRows = [...filteredRows].sort(rowComparator); - - const totalRowCount = filteredRows.length; - if (!pageSize) { - firstRowIndex = 0; - lastRowIndex = filteredRows.length; - } else if (useCursorPagination) { - firstRowIndex = cursor ? filteredRows.findIndex(({ id }) => id === cursor) : 0; - firstRowIndex = Math.max(firstRowIndex, 0); // if cursor not found return 0 - lastRowIndex = firstRowIndex + pageSize; - - nextCursor = lastRowIndex >= filteredRows.length ? undefined : filteredRows[lastRowIndex].id; - } else { - firstRowIndex = page * pageSize; - lastRowIndex = (page + 1) * pageSize; - } - const hasNextPage = lastRowIndex < filteredRows.length - 1; - const response: FakeServerResponse = { - returnedRows: filteredRows.slice(firstRowIndex, lastRowIndex), - nextCursor, - hasNextPage, - totalRowCount, - }; - - return new Promise((resolve) => { - setTimeout(() => { - resolve(response); - }, delay); // simulate network latency - }); -}; - -interface FakeServerResponse { - returnedRows: GridRowModel[]; - nextCursor?: string; - hasNextPage: boolean; - totalRowCount: number; -} - -interface PageInfo { - totalRowCount?: number; - nextCursor?: string; - hasNextPage?: boolean; - pageSize?: number; -} - -interface DefaultServerOptions { - minDelay: number; - maxDelay: number; - useCursorPagination?: boolean; -} - -type ServerOptions = Partial; - -export interface QueryOptions { - cursor?: GridRowId; - page?: number; - pageSize?: number; - // TODO: implement the behavior liked to following models - filterModel?: GridFilterModel; - sortModel?: GridSortModel; - firstRowToRender?: number; - lastRowToRender?: number; -} - -const DEFAULT_DATASET_OPTIONS: UseDemoDataOptions = { - dataSet: 'Commodity', - rowLength: 100, - maxColumns: 6, -}; - -declare const DISABLE_CHANCE_RANDOM: any; -const disableDelay = typeof DISABLE_CHANCE_RANDOM !== 'undefined' && DISABLE_CHANCE_RANDOM; - -const DEFAULT_SERVER_OPTIONS: DefaultServerOptions = { - minDelay: disableDelay ? 0 : 100, - maxDelay: disableDelay ? 0 : 300, - useCursorPagination: true, -}; +import { DEFAULT_DATASET_OPTIONS, DEFAULT_SERVER_OPTIONS, loadServerRows } from './serverUtils'; +import type { ServerOptions, QueryOptions, PageInfo } from './serverUtils'; export const createFakeServer = ( dataSetOptions?: Partial, diff --git a/packages/x-data-grid-premium/src/DataGridPremium/DataGridPremium.tsx b/packages/x-data-grid-premium/src/DataGridPremium/DataGridPremium.tsx index c00103025fe4..2cba34da09e7 100644 --- a/packages/x-data-grid-premium/src/DataGridPremium/DataGridPremium.tsx +++ b/packages/x-data-grid-premium/src/DataGridPremium/DataGridPremium.tsx @@ -64,21 +64,6 @@ const DataGridPremiumRaw = React.forwardRef(function DataGridPremium ); }); -interface DataGridPremiumComponent { - ( - props: DataGridPremiumProps & React.RefAttributes, - ): React.JSX.Element; - propTypes?: any; -} - -/** - * Demos: - * - [DataGridPremium](https://mui.com/x/react-data-grid/demo/) - * - * API: - * - [DataGridPremium API](https://mui.com/x/api/data-grid/data-grid-premium/) - */ -export const DataGridPremium = React.memo(DataGridPremiumRaw) as DataGridPremiumComponent; DataGridPremiumRaw.propTypes = { // ----------------------------- Warning -------------------------------- @@ -1059,4 +1044,32 @@ DataGridPremiumRaw.propTypes = { * @default false */ treeData: PropTypes.bool, + unstable_dataSource: PropTypes.shape({ + getChildrenCount: PropTypes.func, + getGroupKey: PropTypes.func, + getRows: PropTypes.func.isRequired, + updateRow: PropTypes.func, + }), + unstable_dataSourceCache: PropTypes.shape({ + clear: PropTypes.func.isRequired, + get: PropTypes.func.isRequired, + set: PropTypes.func.isRequired, + }), + unstable_onDataSourceError: PropTypes.func, } as any; + +interface DataGridPremiumComponent { + ( + props: DataGridPremiumProps & React.RefAttributes, + ): React.JSX.Element; + propTypes?: any; +} + +/** + * Demos: + * - [DataGridPremium](https://mui.com/x/react-data-grid/demo/) + * + * API: + * - [DataGridPremium API](https://mui.com/x/api/data-grid/data-grid-premium/) + */ +export const DataGridPremium = React.memo(DataGridPremiumRaw) as DataGridPremiumComponent; diff --git a/packages/x-data-grid-premium/src/DataGridPremium/useDataGridPremiumComponent.tsx b/packages/x-data-grid-premium/src/DataGridPremium/useDataGridPremiumComponent.tsx index c1d5bf49973a..b61e63a1f927 100644 --- a/packages/x-data-grid-premium/src/DataGridPremium/useDataGridPremiumComponent.tsx +++ b/packages/x-data-grid-premium/src/DataGridPremium/useDataGridPremiumComponent.tsx @@ -65,6 +65,9 @@ import { useGridHeaderFiltering, virtualizationStateInitializer, useGridVirtualization, + useGridDataSourceTreeDataPreProcessors, + useGridDataSource, + dataSourceStateInitializer, } from '@mui/x-data-grid-pro/internals'; import { GridApiPremium, GridPrivateApiPremium } from '../models/gridApiPremium'; import { DataGridPremiumProcessedProps } from '../models/dataGridPremiumProps'; @@ -99,6 +102,7 @@ export const useDataGridPremiumComponent = ( useGridRowReorderPreProcessors(apiRef, props); useGridRowGroupingPreProcessors(apiRef, props); useGridTreeDataPreProcessors(apiRef, props); + useGridDataSourceTreeDataPreProcessors(apiRef, props); useGridLazyLoaderPreProcessors(apiRef, props); useGridRowPinningPreProcessors(apiRef); useGridAggregationPreProcessors(apiRef, props); @@ -135,10 +139,11 @@ export const useDataGridPremiumComponent = ( useGridInitializeState(columnMenuStateInitializer, apiRef, props); useGridInitializeState(columnGroupsStateInitializer, apiRef, props); useGridInitializeState(virtualizationStateInitializer, apiRef, props); + useGridInitializeState(dataSourceStateInitializer, apiRef, props); useGridRowGrouping(apiRef, props); useGridHeaderFiltering(apiRef, props); - useGridTreeData(apiRef); + useGridTreeData(apiRef, props); useGridAggregation(apiRef, props); useGridKeyboardNavigation(apiRef, props); useGridRowSelection(apiRef, props); @@ -174,6 +179,7 @@ export const useDataGridPremiumComponent = ( useGridDimensions(apiRef, props); useGridEvents(apiRef, props); useGridStatePersistence(apiRef); + useGridDataSource(apiRef, props); useGridVirtualization(apiRef, props); return apiRef; diff --git a/packages/x-data-grid-premium/src/DataGridPremium/useDataGridPremiumProps.ts b/packages/x-data-grid-premium/src/DataGridPremium/useDataGridPremiumProps.ts index c77bc51f8c99..0d50f1b0a819 100644 --- a/packages/x-data-grid-premium/src/DataGridPremium/useDataGridPremiumProps.ts +++ b/packages/x-data-grid-premium/src/DataGridPremium/useDataGridPremiumProps.ts @@ -1,6 +1,10 @@ import * as React from 'react'; import { useThemeProps } from '@mui/material/styles'; -import { DATA_GRID_PRO_PROPS_DEFAULT_VALUES, GRID_DEFAULT_LOCALE_TEXT } from '@mui/x-data-grid-pro'; +import { + DATA_GRID_PRO_PROPS_DEFAULT_VALUES, + GRID_DEFAULT_LOCALE_TEXT, + DataGridProProps, +} from '@mui/x-data-grid-pro'; import { computeSlots, useProps } from '@mui/x-data-grid-pro/internals'; import { DataGridPremiumProps, @@ -11,6 +15,26 @@ import { GridPremiumSlotsComponent } from '../models'; import { GRID_AGGREGATION_FUNCTIONS } from '../hooks/features/aggregation'; import { DATA_GRID_PREMIUM_DEFAULT_SLOTS_COMPONENTS } from '../constants/dataGridPremiumDefaultSlotsComponents'; +interface GetDataGridPremiumPropsDefaultValues extends DataGridPremiumProps {} + +type DataGridProForcedProps = { + [key in keyof DataGridProProps]?: DataGridPremiumProcessedProps[key]; +}; +type GetDataGridProForcedProps = ( + themedProps: GetDataGridPremiumPropsDefaultValues, +) => DataGridProForcedProps; + +const getDataGridPremiumForcedProps: GetDataGridProForcedProps = (themedProps) => ({ + signature: 'DataGridPremium', + ...(themedProps.unstable_dataSource + ? { + filterMode: 'server', + sortingMode: 'server', + paginationMode: 'server', + } + : {}), +}); + /** * The default values of `DataGridPremiumPropsWithDefaultValue` to inject in the props of DataGridPremium. */ @@ -63,7 +87,7 @@ export const useDataGridPremiumProps = (inProps: DataGridPremiumProps) => { ...themedProps, localeText, slots, - signature: 'DataGridPremium', + ...getDataGridPremiumForcedProps(themedProps), }), [themedProps, localeText, slots], ); diff --git a/packages/x-data-grid-premium/src/models/gridApiPremium.ts b/packages/x-data-grid-premium/src/models/gridApiPremium.ts index c9a1c7f911a9..999a16685997 100644 --- a/packages/x-data-grid-premium/src/models/gridApiPremium.ts +++ b/packages/x-data-grid-premium/src/models/gridApiPremium.ts @@ -8,6 +8,8 @@ import { GridRowMultiSelectionApi, GridColumnReorderApi, GridRowProApi, + GridDataSourceApi, + GridDataSourcePrivateApi, } from '@mui/x-data-grid-pro'; import { GridInitialStatePremium, GridStatePremium } from './gridStatePremium'; import type { GridRowGroupingApi, GridExcelExportApi, GridAggregationApi } from '../hooks'; @@ -27,6 +29,7 @@ export interface GridApiPremium GridExcelExportApi, GridAggregationApi, GridRowPinningApi, + GridDataSourceApi, GridCellSelectionApi, // APIs that are private in Community plan, but public in Pro and Premium plans GridRowMultiSelectionApi, @@ -35,4 +38,5 @@ export interface GridApiPremium export interface GridPrivateApiPremium extends GridApiPremium, GridPrivateOnlyApiCommon, + GridDataSourcePrivateApi, GridDetailPanelPrivateApi {} diff --git a/packages/x-data-grid-premium/src/tests/rowGrouping.DataGridPremium.test.tsx b/packages/x-data-grid-premium/src/tests/rowGrouping.DataGridPremium.test.tsx index 69f2268255ea..824d610884a2 100644 --- a/packages/x-data-grid-premium/src/tests/rowGrouping.DataGridPremium.test.tsx +++ b/packages/x-data-grid-premium/src/tests/rowGrouping.DataGridPremium.test.tsx @@ -34,6 +34,10 @@ import { spy } from 'sinon'; const isJSDOM = /jsdom/.test(window.navigator.userAgent); +interface BaselineProps extends DataGridPremiumProps { + rows: GridRowsProp; +} + const rows: GridRowsProp = [ { id: 0, category1: 'Cat A', category2: 'Cat 1' }, { id: 1, category1: 'Cat A', category2: 'Cat 2' }, @@ -51,7 +55,7 @@ const unbalancedRows: GridRowsProp = [ { id: 5, category1: null }, ]; -const baselineProps: DataGridPremiumProps = { +const baselineProps: BaselineProps = { autoHeight: isJSDOM, disableVirtualization: true, rows, diff --git a/packages/x-data-grid-pro/src/DataGridPro/DataGridPro.tsx b/packages/x-data-grid-pro/src/DataGridPro/DataGridPro.tsx index 4a68d6580cf5..7f7c45fa1d5e 100644 --- a/packages/x-data-grid-pro/src/DataGridPro/DataGridPro.tsx +++ b/packages/x-data-grid-pro/src/DataGridPro/DataGridPro.tsx @@ -943,4 +943,16 @@ DataGridProRaw.propTypes = { * @default false */ treeData: PropTypes.bool, + unstable_dataSource: PropTypes.shape({ + getChildrenCount: PropTypes.func, + getGroupKey: PropTypes.func, + getRows: PropTypes.func.isRequired, + updateRow: PropTypes.func, + }), + unstable_dataSourceCache: PropTypes.shape({ + clear: PropTypes.func.isRequired, + get: PropTypes.func.isRequired, + set: PropTypes.func.isRequired, + }), + unstable_onDataSourceError: PropTypes.func, } as any; diff --git a/packages/x-data-grid-pro/src/DataGridPro/useDataGridProComponent.tsx b/packages/x-data-grid-pro/src/DataGridPro/useDataGridProComponent.tsx index 3357ebbaf7eb..d902aa413bb6 100644 --- a/packages/x-data-grid-pro/src/DataGridPro/useDataGridProComponent.tsx +++ b/packages/x-data-grid-pro/src/DataGridPro/useDataGridProComponent.tsx @@ -58,6 +58,7 @@ import { } from '../hooks/features/columnReorder/useGridColumnReorder'; import { useGridTreeData } from '../hooks/features/treeData/useGridTreeData'; import { useGridTreeDataPreProcessors } from '../hooks/features/treeData/useGridTreeDataPreProcessors'; +import { useGridDataSourceTreeDataPreProcessors } from '../hooks/features/serverSideTreeData/useGridDataSourceTreeDataPreProcessors'; import { useGridColumnPinning, columnPinningStateInitializer, @@ -77,6 +78,10 @@ import { rowPinningStateInitializer, } from '../hooks/features/rowPinning/useGridRowPinning'; import { useGridRowPinningPreProcessors } from '../hooks/features/rowPinning/useGridRowPinningPreProcessors'; +import { + useGridDataSource, + dataSourceStateInitializer, +} from '../hooks/features/dataSource/useGridDataSource'; export const useDataGridProComponent = ( inputApiRef: React.MutableRefObject | undefined, @@ -90,6 +95,7 @@ export const useDataGridProComponent = ( useGridRowSelectionPreProcessors(apiRef, props); useGridRowReorderPreProcessors(apiRef, props); useGridTreeDataPreProcessors(apiRef, props); + useGridDataSourceTreeDataPreProcessors(apiRef, props); useGridLazyLoaderPreProcessors(apiRef, props); useGridRowPinningPreProcessors(apiRef); useGridDetailPanelPreProcessors(apiRef, props); @@ -122,9 +128,10 @@ export const useDataGridProComponent = ( useGridInitializeState(columnMenuStateInitializer, apiRef, props); useGridInitializeState(columnGroupsStateInitializer, apiRef, props); useGridInitializeState(virtualizationStateInitializer, apiRef, props); + useGridInitializeState(dataSourceStateInitializer, apiRef, props); useGridHeaderFiltering(apiRef, props); - useGridTreeData(apiRef); + useGridTreeData(apiRef, props); useGridKeyboardNavigation(apiRef, props); useGridRowSelection(apiRef, props); useGridColumnPinning(apiRef, props); @@ -157,6 +164,7 @@ export const useDataGridProComponent = ( useGridEvents(apiRef, props); useGridStatePersistence(apiRef); useGridVirtualization(apiRef, props); + useGridDataSource(apiRef, props); return apiRef; }; diff --git a/packages/x-data-grid-pro/src/DataGridPro/useDataGridProProps.ts b/packages/x-data-grid-pro/src/DataGridPro/useDataGridProProps.ts index 33791fca9b48..b970dfdd4645 100644 --- a/packages/x-data-grid-pro/src/DataGridPro/useDataGridProProps.ts +++ b/packages/x-data-grid-pro/src/DataGridPro/useDataGridProProps.ts @@ -14,6 +14,24 @@ import { import { GridProSlotsComponent } from '../models'; import { DATA_GRID_PRO_DEFAULT_SLOTS_COMPONENTS } from '../constants/dataGridProDefaultSlotsComponents'; +interface GetDataGridProPropsDefaultValues extends DataGridProProps {} + +type DataGridProForcedProps = { [key in keyof DataGridProProps]?: DataGridProProcessedProps[key] }; +type GetDataGridProForcedProps = ( + themedProps: GetDataGridProPropsDefaultValues, +) => DataGridProForcedProps; + +const getDataGridProForcedProps: GetDataGridProForcedProps = (themedProps) => ({ + signature: 'DataGridPro', + ...(themedProps.unstable_dataSource + ? { + filterMode: 'server', + sortingMode: 'server', + paginationMode: 'server', + } + : {}), +}); + /** * The default values of `DataGridProPropsWithDefaultValue` to inject in the props of DataGridPro. */ @@ -65,7 +83,7 @@ export const useDataGridProProps = (inProps: DataGr ...themedProps, localeText, slots, - signature: 'DataGridPro', + ...getDataGridProForcedProps(themedProps), }), [themedProps, localeText, slots], ); diff --git a/packages/x-data-grid-pro/src/components/GridDataSourceTreeDataGroupingCell.tsx b/packages/x-data-grid-pro/src/components/GridDataSourceTreeDataGroupingCell.tsx new file mode 100644 index 000000000000..d03c28e675c4 --- /dev/null +++ b/packages/x-data-grid-pro/src/components/GridDataSourceTreeDataGroupingCell.tsx @@ -0,0 +1,132 @@ +import * as React from 'react'; +import { unstable_composeClasses as composeClasses } from '@mui/utils'; +import Box from '@mui/material/Box'; +import Badge from '@mui/material/Badge'; +import { + getDataGridUtilityClass, + GridRenderCellParams, + GridDataSourceGroupNode, + useGridSelector, +} from '@mui/x-data-grid'; +import CircularProgress from '@mui/material/CircularProgress'; +import { useGridRootProps } from '../hooks/utils/useGridRootProps'; +import { useGridPrivateApiContext } from '../hooks/utils/useGridPrivateApiContext'; +import { DataGridProProcessedProps } from '../models/dataGridProProps'; +import { GridPrivateApiPro } from '../models/gridApiPro'; +import { GridStatePro } from '../models/gridStatePro'; + +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) { + const apiRef = useGridPrivateApiContext() as React.MutableRefObject; + const rootProps = useGridRootProps(); + const classes = useUtilityClasses(rootProps); + const { rowNode, id, field, descendantCount } = props; + + const loadingSelector = (state: GridStatePro) => state.dataSource.loading[id] ?? false; + const errorSelector = (state: GridStatePro) => state.dataSource.errors[id]; + const isDataLoading = useGridSelector(apiRef, loadingSelector); + const error = useGridSelector(apiRef, errorSelector); + + const handleClick = (event: React.MouseEvent) => { + if (!rowNode.childrenExpanded) { + // always fetch/get from cache the children when the node is expanded + apiRef.current.unstable_dataSource.fetchRows(id); + } else { + apiRef.current.setRowChildrenExpansion(id, !rowNode.childrenExpanded); + } + apiRef.current.setCellFocus(id, field); + event.stopPropagation(); // TODO remove event.stopPropagation + }; + + const Icon = rowNode.childrenExpanded + ? rootProps.slots.treeDataCollapseIcon + : rootProps.slots.treeDataExpandIcon; + + if (isDataLoading) { + return ( +
+ +
+ ); + } + return descendantCount > 0 ? ( + + + + + + + + ) : null; +} + +export function GridDataSourceTreeDataGroupingCell(props: GridTreeDataGroupingCellProps) { + const { id, field, formattedValue, rowNode, hideDescendantCount, offsetMultiplier = 2 } = 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); + } + + return ( + +
+ +
+ + {formattedValue === undefined ? rowNode.groupingKey : formattedValue} + {!hideDescendantCount && descendantCount > 0 ? ` (${descendantCount})` : ''} + +
+ ); +} diff --git a/packages/x-data-grid-pro/src/components/GridTreeDataGroupingCell.tsx b/packages/x-data-grid-pro/src/components/GridTreeDataGroupingCell.tsx index 8e1e85688d89..673b637ee038 100644 --- a/packages/x-data-grid-pro/src/components/GridTreeDataGroupingCell.tsx +++ b/packages/x-data-grid-pro/src/components/GridTreeDataGroupingCell.tsx @@ -13,7 +13,7 @@ import { useGridRootProps } from '../hooks/utils/useGridRootProps'; import { useGridApiContext } from '../hooks/utils/useGridApiContext'; import { DataGridProProcessedProps } from '../models/dataGridProProps'; -type OwnerState = { classes: DataGridProProcessedProps['classes'] }; +type OwnerState = DataGridProProcessedProps; const useUtilityClasses = (ownerState: OwnerState) => { const { classes } = ownerState; @@ -40,8 +40,7 @@ function GridTreeDataGroupingCell(props: GridTreeDataGroupingCellProps) { const rootProps = useGridRootProps(); const apiRef = useGridApiContext(); - const ownerState: OwnerState = { classes: rootProps.classes }; - const classes = useUtilityClasses(ownerState); + const classes = useUtilityClasses(rootProps); const filteredDescendantCountLookup = useGridSelector( apiRef, gridFilteredDescendantCountLookupSelector, diff --git a/packages/x-data-grid-pro/src/hooks/features/dataSource/cache.ts b/packages/x-data-grid-pro/src/hooks/features/dataSource/cache.ts new file mode 100644 index 000000000000..dde8cad3d39f --- /dev/null +++ b/packages/x-data-grid-pro/src/hooks/features/dataSource/cache.ts @@ -0,0 +1,53 @@ +import { GridGetRowsParams, GridGetRowsResponse } from '../../../models'; + +type GridDataSourceCacheDefaultConfig = { + /** + * Time To Live for each cache entry in milliseconds. + * After this time the cache entry will become stale and the next query will result in cache miss. + * @default 300000 (5 minutes) + */ + ttl?: number; +}; + +function getKey(params: GridGetRowsParams) { + return JSON.stringify([ + params.paginationModel, + params.filterModel, + params.sortModel, + params.groupKeys, + ]); +} + +export class GridDataSourceCacheDefault { + private cache: Record; + + private ttl: number; + + constructor({ ttl = 300000 }: GridDataSourceCacheDefaultConfig) { + this.cache = {}; + this.ttl = ttl; + } + + set(key: GridGetRowsParams, value: GridGetRowsResponse) { + const keyString = getKey(key); + const expiry = Date.now() + this.ttl; + this.cache[keyString] = { value, expiry }; + } + + get(key: GridGetRowsParams): GridGetRowsResponse | undefined { + const keyString = getKey(key); + const entry = this.cache[keyString]; + if (!entry) { + return undefined; + } + if (Date.now() > entry.expiry) { + delete this.cache[keyString]; + return undefined; + } + return entry.value; + } + + clear() { + this.cache = {}; + } +} diff --git a/packages/x-data-grid-pro/src/hooks/features/dataSource/gridDataSourceSelector.ts b/packages/x-data-grid-pro/src/hooks/features/dataSource/gridDataSourceSelector.ts new file mode 100644 index 000000000000..a7bee9eec1d9 --- /dev/null +++ b/packages/x-data-grid-pro/src/hooks/features/dataSource/gridDataSourceSelector.ts @@ -0,0 +1,43 @@ +import { + GridPaginationModel, + gridFilterModelSelector, + gridSortModelSelector, + gridPaginationModelSelector, +} from '@mui/x-data-grid'; +import { createSelector } from '@mui/x-data-grid/internals'; +import { GridStatePro } from '../../../models/gridStatePro'; + +const computeStartEnd = (paginationModel: GridPaginationModel) => { + const start = paginationModel.page * paginationModel.pageSize; + const end = start + paginationModel.pageSize - 1; + return { start, end }; +}; + +export const gridGetRowsParamsSelector = createSelector( + gridFilterModelSelector, + gridSortModelSelector, + gridPaginationModelSelector, + (filterModel, sortModel, paginationModel) => { + return { + groupKeys: [], + // TODO: Implement with `rowGrouping` + groupFields: [], + paginationModel, + sortModel, + filterModel, + ...computeStartEnd(paginationModel), + }; + }, +); + +export const gridDataSourceStateSelector = (state: GridStatePro) => state.dataSource; + +export const gridDataSourceLoadingSelector = createSelector( + gridDataSourceStateSelector, + (dataSource) => dataSource.loading, +); + +export const gridDataSourceErrorsSelector = createSelector( + gridDataSourceStateSelector, + (dataSource) => dataSource.errors, +); diff --git a/packages/x-data-grid-pro/src/hooks/features/dataSource/interfaces.ts b/packages/x-data-grid-pro/src/hooks/features/dataSource/interfaces.ts new file mode 100644 index 000000000000..90bfc4ed39de --- /dev/null +++ b/packages/x-data-grid-pro/src/hooks/features/dataSource/interfaces.ts @@ -0,0 +1,53 @@ +import { GridRowId } from '@mui/x-data-grid'; +import { GridDataSourceCache } from '../../../models'; + +export interface GridDataSourceState { + loading: Record; + errors: Record; +} + +/** + * The base data source API interface that is available in the grid [[apiRef]]. + */ +export interface GridDataSourceApiBase { + /** + * Set the loading state of a parent row. + * @param {string} parentId The id of the parent node. + * @param {boolean} loading The loading state to set. + */ + setChildrenLoading: (parentId: GridRowId, loading: boolean) => void; + /** + * Set error occured while fetching the children of a row. + * @param {string} parentId The id of the parent node. + * @param {Error} error The error of type `Error` or `null`. + */ + setChildrenFetchError: (parentId: GridRowId, error: Error | null) => void; + /** + * Fetches the rows from the server for a given `parentId`. + * If no `parentId` is provided, it fetches the root rows. + * @param {string} parentId The id of the group to be fetched. + */ + fetchRows: (parentId?: GridRowId) => void; + /** + * The data source cache object. + */ + cache: GridDataSourceCache; +} + +export interface GridDataSourceApi { + /** + * The data source API. + */ + unstable_dataSource: GridDataSourceApiBase; +} +export interface GridDataSourcePrivateApi { + /** + * Initiates the fetch of the children of a row. + * @param {string} id The id of the group to be fetched. + */ + fetchRowChildren: (id: GridRowId) => void; + /** + * Resets the data source state. + */ + resetDataSourceState: () => void; +} diff --git a/packages/x-data-grid-pro/src/hooks/features/dataSource/useGridDataSource.ts b/packages/x-data-grid-pro/src/hooks/features/dataSource/useGridDataSource.ts new file mode 100644 index 000000000000..b948c0a48745 --- /dev/null +++ b/packages/x-data-grid-pro/src/hooks/features/dataSource/useGridDataSource.ts @@ -0,0 +1,299 @@ +import * as React from 'react'; +import useLazyRef from '@mui/utils/useLazyRef'; +import { + useGridApiEventHandler, + gridRowsLoadingSelector, + useGridApiMethod, + GridDataSourceGroupNode, + useGridSelector, + GridRowId, +} from '@mui/x-data-grid'; +import { gridRowGroupsToFetchSelector, GridStateInitializer } from '@mui/x-data-grid/internals'; +import { GridPrivateApiPro } from '../../../models/gridApiPro'; +import { DataGridProProcessedProps } from '../../../models/dataGridProProps'; +import { gridGetRowsParamsSelector, gridDataSourceErrorsSelector } from './gridDataSourceSelector'; +import { GridDataSourceApi, GridDataSourceApiBase, GridDataSourcePrivateApi } from './interfaces'; +import { runIfServerMode, NestedDataManager, RequestStatus } from './utils'; +import { GridDataSourceCache } from '../../../models'; +import { GridDataSourceCacheDefault } from './cache'; + +const INITIAL_STATE = { + loading: {}, + errors: {}, +}; + +const noopCache: GridDataSourceCache = { + clear: () => {}, + get: () => undefined, + set: () => {}, +}; + +function getCache(cacheProp?: GridDataSourceCache | null) { + if (cacheProp === null) { + return noopCache; + } + return cacheProp ?? new GridDataSourceCacheDefault({}); +} + +export const dataSourceStateInitializer: GridStateInitializer = (state) => { + return { + ...state, + dataSource: INITIAL_STATE, + }; +}; + +export const useGridDataSource = ( + apiRef: React.MutableRefObject, + props: Pick< + DataGridProProcessedProps, + | 'unstable_dataSource' + | 'unstable_dataSourceCache' + | 'unstable_onDataSourceError' + | 'sortingMode' + | 'filterMode' + | 'paginationMode' + | 'treeData' + >, +) => { + const nestedDataManager = useLazyRef( + () => new NestedDataManager(apiRef), + ).current; + const groupsToAutoFetch = useGridSelector(apiRef, gridRowGroupsToFetchSelector); + const scheduledGroups = React.useRef(0); + const onError = props.unstable_onDataSourceError; + + const [cache, setCache] = React.useState(() => + getCache(props.unstable_dataSourceCache), + ); + + const fetchRows = React.useCallback( + async (parentId?: GridRowId) => { + const getRows = props.unstable_dataSource?.getRows; + if (!getRows) { + return; + } + + if (parentId) { + nestedDataManager.queue([parentId]); + return; + } + + nestedDataManager.clear(); + scheduledGroups.current = 0; + const dataSourceState = apiRef.current.state.dataSource; + if (dataSourceState !== INITIAL_STATE) { + apiRef.current.resetDataSourceState(); + } + + const fetchParams = gridGetRowsParamsSelector(apiRef); + + const cachedData = apiRef.current.unstable_dataSource.cache.get(fetchParams); + + if (cachedData !== undefined) { + const rows = cachedData.rows; + apiRef.current.setRows(rows); + if (cachedData.rowCount) { + apiRef.current.setRowCount(cachedData.rowCount); + } + return; + } + + const isLoading = gridRowsLoadingSelector(apiRef); + if (!isLoading) { + apiRef.current.setLoading(true); + } + + try { + const getRowsResponse = await getRows(fetchParams); + apiRef.current.unstable_dataSource.cache.set(fetchParams, getRowsResponse); + if (getRowsResponse.rowCount) { + apiRef.current.setRowCount(getRowsResponse.rowCount); + } + apiRef.current.setRows(getRowsResponse.rows); + apiRef.current.setLoading(false); + } catch (error) { + apiRef.current.setRows([]); + apiRef.current.setLoading(false); + onError?.(error as Error, fetchParams); + } + }, + [nestedDataManager, apiRef, props.unstable_dataSource?.getRows, onError], + ); + + const fetchRowChildren = React.useCallback( + async (id) => { + if (!props.treeData) { + nestedDataManager.clearPendingRequest(id); + return; + } + const getRows = props.unstable_dataSource?.getRows; + if (!getRows) { + nestedDataManager.clearPendingRequest(id); + return; + } + + const rowNode = apiRef.current.getRowNode(id); + if (!rowNode) { + nestedDataManager.clearPendingRequest(id); + return; + } + + const fetchParams = { ...gridGetRowsParamsSelector(apiRef), groupKeys: rowNode.path }; + + const cachedData = apiRef.current.unstable_dataSource.cache.get(fetchParams); + + if (cachedData !== undefined) { + const rows = cachedData.rows; + nestedDataManager.setRequestSettled(id); + apiRef.current.updateServerRows(rows, rowNode.path); + if (cachedData.rowCount) { + apiRef.current.setRowCount(cachedData.rowCount); + } + apiRef.current.setRowChildrenExpansion(id, true); + apiRef.current.unstable_dataSource.setChildrenLoading(id, false); + return; + } + + const existingError = gridDataSourceErrorsSelector(apiRef)[id] ?? null; + if (existingError) { + apiRef.current.unstable_dataSource.setChildrenFetchError(id, null); + } + + try { + const getRowsResponse = await getRows(fetchParams); + if (!apiRef.current.getRowNode(id)) { + // The row has been removed from the grid + nestedDataManager.clearPendingRequest(id); + return; + } + if (nestedDataManager.getRequestStatus(id) === RequestStatus.UNKNOWN) { + apiRef.current.unstable_dataSource.setChildrenLoading(id, false); + return; + } + nestedDataManager.setRequestSettled(id); + apiRef.current.unstable_dataSource.cache.set(fetchParams, getRowsResponse); + if (getRowsResponse.rowCount) { + apiRef.current.setRowCount(getRowsResponse.rowCount); + } + apiRef.current.updateServerRows(getRowsResponse.rows, rowNode.path); + apiRef.current.setRowChildrenExpansion(id, true); + } catch (error) { + const e = error as Error; + apiRef.current.unstable_dataSource.setChildrenFetchError(id, e); + onError?.(e, fetchParams); + } finally { + apiRef.current.unstable_dataSource.setChildrenLoading(id, false); + nestedDataManager.setRequestSettled(id); + } + }, + [nestedDataManager, onError, apiRef, props.treeData, props.unstable_dataSource?.getRows], + ); + + const setChildrenLoading = React.useCallback( + (parentId, isLoading) => { + apiRef.current.setState((state) => { + if (!state.dataSource.loading[parentId] && isLoading === false) { + return state; + } + const newLoadingState = { ...state.dataSource.loading }; + if (isLoading === false) { + delete newLoadingState[parentId]; + } else { + newLoadingState[parentId] = isLoading; + } + return { + ...state, + dataSource: { + ...state.dataSource, + loading: newLoadingState, + }, + }; + }); + }, + [apiRef], + ); + + const setChildrenFetchError = React.useCallback( + (parentId, error) => { + apiRef.current.setState((state) => { + const newErrorsState = { ...state.dataSource.errors }; + if (error === null && newErrorsState[parentId] !== undefined) { + delete newErrorsState[parentId]; + } else { + newErrorsState[parentId] = error; + } + return { + ...state, + dataSource: { + ...state.dataSource, + errors: newErrorsState, + }, + }; + }); + }, + [apiRef], + ); + + const resetDataSourceState = React.useCallback(() => { + apiRef.current.setState((state) => { + return { + ...state, + dataSource: INITIAL_STATE, + }; + }); + }, [apiRef]); + + const dataSourceApi: GridDataSourceApi = { + unstable_dataSource: { + setChildrenLoading, + setChildrenFetchError, + fetchRows, + cache, + }, + }; + + const dataSourcePrivateApi: GridDataSourcePrivateApi = { + fetchRowChildren, + resetDataSourceState, + }; + + useGridApiMethod(apiRef, dataSourceApi, 'public'); + useGridApiMethod(apiRef, dataSourcePrivateApi, 'private'); + + useGridApiEventHandler(apiRef, 'sortModelChange', runIfServerMode(props.sortingMode, fetchRows)); + useGridApiEventHandler(apiRef, 'filterModelChange', runIfServerMode(props.filterMode, fetchRows)); + useGridApiEventHandler( + apiRef, + 'paginationModelChange', + runIfServerMode(props.paginationMode, fetchRows), + ); + + const isFirstRender = React.useRef(true); + React.useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + const newCache = getCache(props.unstable_dataSourceCache); + setCache((prevCache) => (prevCache !== newCache ? newCache : prevCache)); + }, [props.unstable_dataSourceCache]); + + React.useEffect(() => { + if (props.unstable_dataSource) { + apiRef.current.unstable_dataSource.cache.clear(); + apiRef.current.unstable_dataSource.fetchRows(); + } + }, [apiRef, props.unstable_dataSource]); + + React.useEffect(() => { + if ( + groupsToAutoFetch && + groupsToAutoFetch.length && + scheduledGroups.current < groupsToAutoFetch.length + ) { + const groupsToSchedule = groupsToAutoFetch.slice(scheduledGroups.current); + nestedDataManager.queue(groupsToSchedule); + scheduledGroups.current = groupsToAutoFetch.length; + } + }, [apiRef, nestedDataManager, groupsToAutoFetch]); +}; diff --git a/packages/x-data-grid-pro/src/hooks/features/dataSource/utils.ts b/packages/x-data-grid-pro/src/hooks/features/dataSource/utils.ts new file mode 100644 index 000000000000..dafc6d9783f2 --- /dev/null +++ b/packages/x-data-grid-pro/src/hooks/features/dataSource/utils.ts @@ -0,0 +1,114 @@ +import { GridRowId } from '@mui/x-data-grid'; +import { GridPrivateApiPro } from '../../../models/gridApiPro'; + +const MAX_CONCURRENT_REQUESTS = Infinity; + +export const runIfServerMode = (modeProp: 'server' | 'client', fn: Function) => () => { + if (modeProp === 'server') { + fn(); + } +}; + +export enum RequestStatus { + QUEUED, + PENDING, + SETTLED, + UNKNOWN, +} + +/** + * Fetches row children from the server with option to limit the number of concurrent requests + * Determines the status of a request based on the enum `RequestStatus` + * Uses `GridRowId` to uniquely identify a request + */ +export class NestedDataManager { + private pendingRequests: Set = new Set(); + + private queuedRequests: Set = new Set(); + + private settledRequests: Set = new Set(); + + private api: GridPrivateApiPro; + + private maxConcurrentRequests: number; + + constructor( + privateApiRef: React.MutableRefObject, + maxConcurrentRequests = MAX_CONCURRENT_REQUESTS, + ) { + this.api = privateApiRef.current; + this.maxConcurrentRequests = maxConcurrentRequests; + } + + private processQueue = async () => { + if (this.queuedRequests.size === 0 || this.pendingRequests.size >= this.maxConcurrentRequests) { + return; + } + const loopLength = Math.min( + this.maxConcurrentRequests - this.pendingRequests.size, + this.queuedRequests.size, + ); + if (loopLength === 0) { + return; + } + const fetchQueue = Array.from(this.queuedRequests); + + for (let i = 0; i < loopLength; i += 1) { + const id = fetchQueue[i]; + this.queuedRequests.delete(id); + this.pendingRequests.add(id); + this.api.fetchRowChildren(id); + } + }; + + public queue = async (ids: GridRowId[]) => { + const loadingIds: Record = {}; + ids.forEach((id) => { + this.queuedRequests.add(id); + loadingIds[id] = true; + }); + this.api.setState((state) => ({ + ...state, + dataSource: { + ...state.dataSource, + loading: { + ...state.dataSource.loading, + ...loadingIds, + }, + }, + })); + this.processQueue(); + }; + + public setRequestSettled = (id: GridRowId) => { + this.pendingRequests.delete(id); + this.settledRequests.add(id); + this.processQueue(); + }; + + public clear = () => { + this.queuedRequests.clear(); + Array.from(this.pendingRequests).forEach((id) => this.clearPendingRequest(id)); + }; + + public clearPendingRequest = (id: GridRowId) => { + this.api.unstable_dataSource.setChildrenLoading(id, false); + this.pendingRequests.delete(id); + this.processQueue(); + }; + + public getRequestStatus = (id: GridRowId) => { + if (this.pendingRequests.has(id)) { + return RequestStatus.PENDING; + } + if (this.queuedRequests.has(id)) { + return RequestStatus.QUEUED; + } + if (this.settledRequests.has(id)) { + return RequestStatus.SETTLED; + } + return RequestStatus.UNKNOWN; + }; + + public getActiveRequestsCount = () => this.pendingRequests.size + this.queuedRequests.size; +} diff --git a/packages/x-data-grid-pro/src/hooks/features/index.ts b/packages/x-data-grid-pro/src/hooks/features/index.ts index ea390a65dc61..dd9209be6a53 100644 --- a/packages/x-data-grid-pro/src/hooks/features/index.ts +++ b/packages/x-data-grid-pro/src/hooks/features/index.ts @@ -5,3 +5,5 @@ export * from './rowReorder'; export * from './treeData'; export * from './detailPanel'; export * from './rowPinning'; +export * from './dataSource/interfaces'; +export * from './dataSource/cache'; diff --git a/packages/x-data-grid-pro/src/hooks/features/serverSideTreeData/useGridDataSourceTreeDataPreProcessors.tsx b/packages/x-data-grid-pro/src/hooks/features/serverSideTreeData/useGridDataSourceTreeDataPreProcessors.tsx new file mode 100644 index 000000000000..6c182e6c04d1 --- /dev/null +++ b/packages/x-data-grid-pro/src/hooks/features/serverSideTreeData/useGridDataSourceTreeDataPreProcessors.tsx @@ -0,0 +1,256 @@ +import * as React from 'react'; +import { + gridRowTreeSelector, + useFirstRender, + GridColDef, + GridRenderCellParams, + GridDataSourceGroupNode, + GridRowId, + GRID_CHECKBOX_SELECTION_FIELD, +} from '@mui/x-data-grid'; +import { + GridPipeProcessor, + GridRowsPartialUpdates, + GridStrategyProcessor, + useGridRegisterPipeProcessor, + useGridRegisterStrategyProcessor, +} from '@mui/x-data-grid/internals'; +import { + GRID_TREE_DATA_GROUPING_COL_DEF, + GRID_TREE_DATA_GROUPING_COL_DEF_FORCED_PROPERTIES, +} from '../treeData/gridTreeDataGroupColDef'; +import { DataGridProProcessedProps } from '../../../models/dataGridProProps'; +import { skipFiltering, skipSorting } from './utils'; +import { GridPrivateApiPro } from '../../../models/gridApiPro'; +import { + GridGroupingColDefOverride, + GridGroupingColDefOverrideParams, +} from '../../../models/gridGroupingColDefOverride'; +import { GridDataSourceTreeDataGroupingCell } from '../../../components/GridDataSourceTreeDataGroupingCell'; +import { createRowTree } from '../../../utils/tree/createRowTree'; +import { + GridTreePathDuplicateHandler, + RowTreeBuilderGroupingCriterion, +} from '../../../utils/tree/models'; +import { updateRowTree } from '../../../utils/tree/updateRowTree'; +import { getVisibleRowsLookup } from '../../../utils/tree/utils'; + +const DATA_SOURCE_TREE_DATA_STRATEGY = 'dataSourceTreeData'; + +export const useGridDataSourceTreeDataPreProcessors = ( + privateApiRef: React.MutableRefObject, + props: Pick< + DataGridProProcessedProps, + | 'treeData' + | 'groupingColDef' + | 'disableChildrenSorting' + | 'disableChildrenFiltering' + | 'defaultGroupingExpansionDepth' + | 'isGroupExpandedByDefault' + | 'unstable_dataSource' + >, +) => { + const setStrategyAvailability = React.useCallback(() => { + privateApiRef.current.setStrategyAvailability( + 'rowTree', + DATA_SOURCE_TREE_DATA_STRATEGY, + props.treeData && props.unstable_dataSource ? () => true : () => false, + ); + }, [privateApiRef, props.treeData, props.unstable_dataSource]); + + const getGroupingColDef = React.useCallback(() => { + const groupingColDefProp = props.groupingColDef; + + let colDefOverride: GridGroupingColDefOverride | null | undefined; + if (typeof groupingColDefProp === 'function') { + const params: GridGroupingColDefOverrideParams = { + groupingName: DATA_SOURCE_TREE_DATA_STRATEGY, + fields: [], + }; + + colDefOverride = groupingColDefProp(params); + } else { + colDefOverride = groupingColDefProp; + } + + const { hideDescendantCount, ...colDefOverrideProperties } = colDefOverride ?? {}; + + const commonProperties: Omit = { + ...GRID_TREE_DATA_GROUPING_COL_DEF, + renderCell: (params) => ( + )} + hideDescendantCount={hideDescendantCount} + /> + ), + headerName: privateApiRef.current.getLocaleText('treeDataGroupingHeaderName'), + }; + + return { + ...commonProperties, + ...colDefOverrideProperties, + ...GRID_TREE_DATA_GROUPING_COL_DEF_FORCED_PROPERTIES, + }; + }, [privateApiRef, props.groupingColDef]); + + const updateGroupingColumn = React.useCallback>( + (columnsState) => { + if (!props.unstable_dataSource) { + return columnsState; + } + const groupingColDefField = GRID_TREE_DATA_GROUPING_COL_DEF_FORCED_PROPERTIES.field; + + const shouldHaveGroupingColumn = props.treeData; + const prevGroupingColumn = columnsState.lookup[groupingColDefField]; + + if (shouldHaveGroupingColumn) { + const newGroupingColumn = getGroupingColDef(); + if (prevGroupingColumn) { + newGroupingColumn.width = prevGroupingColumn.width; + newGroupingColumn.flex = prevGroupingColumn.flex; + } + columnsState.lookup[groupingColDefField] = newGroupingColumn; + if (prevGroupingColumn == null) { + const index = columnsState.orderedFields[0] === GRID_CHECKBOX_SELECTION_FIELD ? 1 : 0; + columnsState.orderedFields = [ + ...columnsState.orderedFields.slice(0, index), + groupingColDefField, + ...columnsState.orderedFields.slice(index), + ]; + } + } else if (!shouldHaveGroupingColumn && prevGroupingColumn) { + delete columnsState.lookup[groupingColDefField]; + columnsState.orderedFields = columnsState.orderedFields.filter( + (field) => field !== groupingColDefField, + ); + } + + return columnsState; + }, + [props.treeData, props.unstable_dataSource, getGroupingColDef], + ); + + const createRowTreeForTreeData = React.useCallback>( + (params) => { + const getGroupKey = props.unstable_dataSource?.getGroupKey; + if (!getGroupKey) { + throw new Error('MUI X: No `getGroupKey` method provided with the dataSource.'); + } + + const getChildrenCount = props.unstable_dataSource?.getChildrenCount; + if (!getChildrenCount) { + throw new Error('MUI X: No `getChildrenCount` method provided with the dataSource.'); + } + + const parentPath = (params.updates as GridRowsPartialUpdates).groupKeys ?? []; + + const getRowTreeBuilderNode = (rowId: GridRowId) => { + const count = getChildrenCount(params.dataRowIdToModelLookup[rowId]); + return { + id: rowId, + path: [...parentPath, getGroupKey(params.dataRowIdToModelLookup[rowId])].map( + (key): RowTreeBuilderGroupingCriterion => ({ key, field: null }), + ), + hasServerChildren: !!count && count !== 0, + }; + }; + + const onDuplicatePath: GridTreePathDuplicateHandler = (firstId, secondId, path) => { + throw new Error( + [ + 'MUI X: The values returned by `getGroupKey` for all the sibling rows should be unique.', + `The rows with id #${firstId} and #${secondId} have the same.`, + `Path: ${JSON.stringify(path.map((step) => step.key))}.`, + ].join('\n'), + ); + }; + + if (params.updates.type === 'full') { + return createRowTree({ + previousTree: params.previousTree, + nodes: params.updates.rows.map(getRowTreeBuilderNode), + defaultGroupingExpansionDepth: props.defaultGroupingExpansionDepth, + isGroupExpandedByDefault: props.isGroupExpandedByDefault, + groupingName: DATA_SOURCE_TREE_DATA_STRATEGY, + onDuplicatePath, + }); + } + + return updateRowTree({ + nodes: { + inserted: params.updates.actions.insert.map(getRowTreeBuilderNode), + modified: params.updates.actions.modify.map(getRowTreeBuilderNode), + removed: params.updates.actions.remove, + }, + previousTree: params.previousTree!, + previousGroupsToFetch: params.previousGroupsToFetch, + previousTreeDepth: params.previousTreeDepths!, + defaultGroupingExpansionDepth: props.defaultGroupingExpansionDepth, + isGroupExpandedByDefault: props.isGroupExpandedByDefault, + groupingName: DATA_SOURCE_TREE_DATA_STRATEGY, + }); + }, + [ + props.unstable_dataSource, + props.defaultGroupingExpansionDepth, + props.isGroupExpandedByDefault, + ], + ); + + const filterRows = React.useCallback>(() => { + const rowTree = gridRowTreeSelector(privateApiRef); + + return skipFiltering(rowTree); + }, [privateApiRef]); + + const sortRows = React.useCallback>(() => { + const rowTree = gridRowTreeSelector(privateApiRef); + + return skipSorting(rowTree); + }, [privateApiRef]); + + useGridRegisterPipeProcessor(privateApiRef, 'hydrateColumns', updateGroupingColumn); + useGridRegisterStrategyProcessor( + privateApiRef, + DATA_SOURCE_TREE_DATA_STRATEGY, + 'rowTreeCreation', + createRowTreeForTreeData, + ); + useGridRegisterStrategyProcessor( + privateApiRef, + DATA_SOURCE_TREE_DATA_STRATEGY, + 'filtering', + filterRows, + ); + useGridRegisterStrategyProcessor( + privateApiRef, + DATA_SOURCE_TREE_DATA_STRATEGY, + 'sorting', + sortRows, + ); + useGridRegisterStrategyProcessor( + privateApiRef, + DATA_SOURCE_TREE_DATA_STRATEGY, + 'visibleRowsLookupCreation', + getVisibleRowsLookup, + ); + + /** + * 1ST RENDER + */ + useFirstRender(() => { + setStrategyAvailability(); + }); + + /** + * EFFECTS + */ + const isFirstRender = React.useRef(true); + React.useEffect(() => { + if (!isFirstRender.current) { + setStrategyAvailability(); + } else { + isFirstRender.current = false; + } + }, [setStrategyAvailability]); +}; diff --git a/packages/x-data-grid-pro/src/hooks/features/serverSideTreeData/utils.ts b/packages/x-data-grid-pro/src/hooks/features/serverSideTreeData/utils.ts new file mode 100644 index 000000000000..e1314f6aef1d --- /dev/null +++ b/packages/x-data-grid-pro/src/hooks/features/serverSideTreeData/utils.ts @@ -0,0 +1,22 @@ +import { GridRowId, GridRowTreeConfig, GRID_ROOT_GROUP_ID } from '@mui/x-data-grid'; +import { getTreeNodeDescendants } from '@mui/x-data-grid/internals'; + +export function skipFiltering(rowTree: GridRowTreeConfig) { + const filteredRowsLookup: Record = {}; + const filteredDescendantCountLookup: Record = {}; + + const nodes = Object.values(rowTree); + for (let i = 0; i < nodes.length; i += 1) { + const node: any = nodes[i]; + filteredRowsLookup[node.id] = true; + } + + return { + filteredRowsLookup, + filteredDescendantCountLookup, + }; +} + +export function skipSorting(rowTree: GridRowTreeConfig) { + return getTreeNodeDescendants(rowTree, GRID_ROOT_GROUP_ID, false); +} diff --git a/packages/x-data-grid-pro/src/hooks/features/treeData/useGridTreeData.tsx b/packages/x-data-grid-pro/src/hooks/features/treeData/useGridTreeData.tsx index f2410669db0f..8f09f695627d 100644 --- a/packages/x-data-grid-pro/src/hooks/features/treeData/useGridTreeData.tsx +++ b/packages/x-data-grid-pro/src/hooks/features/treeData/useGridTreeData.tsx @@ -1,9 +1,14 @@ import * as React from 'react'; import { useGridApiEventHandler, GridEventListener } from '@mui/x-data-grid'; import { GridApiPro } from '../../../models/gridApiPro'; +import { DataGridProProcessedProps } from '../../../models/dataGridProProps'; + import { GRID_TREE_DATA_GROUPING_FIELD } from './gridTreeDataGroupColDef'; -export const useGridTreeData = (apiRef: React.MutableRefObject) => { +export const useGridTreeData = ( + apiRef: React.MutableRefObject, + props: Pick, +) => { /** * EVENTS */ @@ -19,10 +24,15 @@ export const useGridTreeData = (apiRef: React.MutableRefObject) => { return; } + if (props.unstable_dataSource && !params.rowNode.childrenExpanded) { + apiRef.current.unstable_dataSource.fetchRows(params.id); + return; + } + apiRef.current.setRowChildrenExpansion(params.id, !params.rowNode.childrenExpanded); } }, - [apiRef], + [apiRef, props.unstable_dataSource], ); useGridApiEventHandler(apiRef, 'cellKeyDown', handleCellKeyDown); diff --git a/packages/x-data-grid-pro/src/hooks/features/treeData/useGridTreeDataPreProcessors.tsx b/packages/x-data-grid-pro/src/hooks/features/treeData/useGridTreeDataPreProcessors.tsx index be47c0bd6bb1..74c0fcfcda38 100644 --- a/packages/x-data-grid-pro/src/hooks/features/treeData/useGridTreeDataPreProcessors.tsx +++ b/packages/x-data-grid-pro/src/hooks/features/treeData/useGridTreeDataPreProcessors.tsx @@ -46,15 +46,16 @@ export const useGridTreeDataPreProcessors = ( | 'disableChildrenFiltering' | 'defaultGroupingExpansionDepth' | 'isGroupExpandedByDefault' + | 'unstable_dataSource' >, ) => { const setStrategyAvailability = React.useCallback(() => { privateApiRef.current.setStrategyAvailability( 'rowTree', TREE_DATA_STRATEGY, - props.treeData ? () => true : () => false, + props.treeData && !props.unstable_dataSource ? () => true : () => false, ); - }, [privateApiRef, props.treeData]); + }, [privateApiRef, props.treeData, props.unstable_dataSource]); const getGroupingColDef = React.useCallback(() => { const groupingColDefProp = props.groupingColDef; @@ -93,6 +94,9 @@ export const useGridTreeDataPreProcessors = ( const updateGroupingColumn = React.useCallback>( (columnsState) => { + if (props.unstable_dataSource) { + return columnsState; + } const groupingColDefField = GRID_TREE_DATA_GROUPING_COL_DEF_FORCED_PROPERTIES.field; const shouldHaveGroupingColumn = props.treeData; @@ -122,7 +126,7 @@ export const useGridTreeDataPreProcessors = ( return columnsState; }, - [props.treeData, getGroupingColDef], + [props.treeData, props.unstable_dataSource, getGroupingColDef], ); const createRowTreeForTreeData = React.useCallback>( diff --git a/packages/x-data-grid-pro/src/internals/index.ts b/packages/x-data-grid-pro/src/internals/index.ts index 83f507d992b1..28b184e2bd42 100644 --- a/packages/x-data-grid-pro/src/internals/index.ts +++ b/packages/x-data-grid-pro/src/internals/index.ts @@ -15,6 +15,7 @@ export { useGridColumnReorder, columnReorderStateInitializer, } from '../hooks/features/columnReorder/useGridColumnReorder'; +export { useGridDataSourceTreeDataPreProcessors } from '../hooks/features/serverSideTreeData/useGridDataSourceTreeDataPreProcessors'; export { useGridDetailPanel, detailPanelStateInitializer, @@ -36,6 +37,10 @@ export { } from '../hooks/features/rowPinning/useGridRowPinningPreProcessors'; export { useGridLazyLoader } from '../hooks/features/lazyLoader/useGridLazyLoader'; export { useGridLazyLoaderPreProcessors } from '../hooks/features/lazyLoader/useGridLazyLoaderPreProcessors'; +export { + useGridDataSource, + dataSourceStateInitializer, +} from '../hooks/features/dataSource/useGridDataSource'; export type { GridExperimentalProFeatures, diff --git a/packages/x-data-grid-pro/src/internals/propValidation.ts b/packages/x-data-grid-pro/src/internals/propValidation.ts index b93320993aab..13f138529d47 100644 --- a/packages/x-data-grid-pro/src/internals/propValidation.ts +++ b/packages/x-data-grid-pro/src/internals/propValidation.ts @@ -16,6 +16,7 @@ export const propValidatorsDataGridPro: PropValidator (props) => (props.treeData && props.filterMode === 'server' && + !props.unstable_dataSource && 'MUI X: The `filterMode="server"` prop is not available when the `treeData` is enabled.') || undefined, (props) => diff --git a/packages/x-data-grid-pro/src/models/dataGridProProps.ts b/packages/x-data-grid-pro/src/models/dataGridProProps.ts index 9ffc76113476..4d611b57ea06 100644 --- a/packages/x-data-grid-pro/src/models/dataGridProProps.ts +++ b/packages/x-data-grid-pro/src/models/dataGridProProps.ts @@ -8,7 +8,7 @@ import { GridGroupNode, GridFeatureMode, } from '@mui/x-data-grid'; -import { +import type { GridExperimentalFeatures, DataGridPropsWithoutDefaultValue, DataGridPropsWithDefaultValues, @@ -17,6 +17,8 @@ import { GridPinnedColumnFields, DataGridProSharedPropsWithDefaultValue, DataGridProSharedPropsWithoutDefaultValue, + GridDataSourceCache, + GridGetRowsParams, } from '@mui/x-data-grid/internals'; import type { GridPinnedRowsProp } from '../hooks/features/rowPinning'; import { GridApiPro } from './gridApiPro'; @@ -137,11 +139,30 @@ export interface DataGridProPropsWithDefaultValue void; +} + +interface DataGridProRegularProps { + /** + * Determines the path of a row in the tree data. + * For instance, a row with the path ["A", "B"] is the child of the row with the path ["A"]. + * Note that all paths must contain at least one element. + * @template R + * @param {R} row The row from which we want the path. + * @returns {string[]} The path to the row. + */ + getTreeDataPath?: (row: R) => string[]; +} + export interface DataGridProPropsWithoutDefaultValue extends Omit< DataGridPropsWithoutDefaultValue, 'initialState' | 'componentsProps' | 'slotProps' >, + DataGridProRegularProps, + DataGridProDataSourceProps, DataGridProSharedPropsWithoutDefaultValue { /** * The ref object that allows grid manipulation. Can be instantiated with `useGridApiRef()`. @@ -158,15 +179,6 @@ export interface DataGridProPropsWithoutDefaultValue; - /** - * Determines the path of a row in the tree data. - * For instance, a row with the path ["A", "B"] is the child of the row with the path ["A"]. - * Note that all paths must contain at least one element. - * @template R - * @param {R} row The row from which we want the path. - * @returns {string[]} The path to the row. - */ - getTreeDataPath?: (row: R) => string[]; /** * Callback fired when scrolling to the bottom of the grid viewport. * @param {GridRowScrollEndParams} params With all properties from [[GridRowScrollEndParams]]. diff --git a/packages/x-data-grid-pro/src/models/dataSource.ts b/packages/x-data-grid-pro/src/models/dataSource.ts deleted file mode 100644 index 53bb071e6b4f..000000000000 --- a/packages/x-data-grid-pro/src/models/dataSource.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { - GridSortModel, - GridFilterModel, - GridColDef, - GridRowModel, - GridPaginationModel, -} from '@mui/x-data-grid'; - -interface GetRowsParams { - sortModel: GridSortModel; - filterModel: GridFilterModel; - /** - * Alternate to `start` and `end`, maps to `GridPaginationModel` interface. - */ - paginationModel: GridPaginationModel; - /** - * First row index to fetch (number) or cursor information (number | string). - */ - start: number | string; - /** - * Last row index to fetch. - */ - end: number; // last row index to fetch - /** - * Array of keys returned by `getGroupKey` of all the parent rows until the row for which the data is requested - * `getGroupKey` prop must be implemented to use this. - * Useful for `treeData` and `rowGrouping` only. - */ - groupKeys: string[]; - /** - * List of grouped columns (only applicable with `rowGrouping`). - */ - groupFields: GridColDef['field'][]; -} - -interface GetRowsResponse { - rows: GridRowModel[]; - /** - * To reflect updates in total `rowCount` (optional). - * Useful when the `rowCount` is inaccurate (for example when filtering) or not available upfront. - */ - rowCount?: number; - /** - * Additional `pageInfo` to help the grid determine if there are more rows to fetch (corner-cases). - * `hasNextPage`: When row count is unknown/inaccurate, if `truncated` is set or rowCount is not known, data will keep loading until `hasNextPage` is `false` - * `truncated`: To reflect `rowCount` is inaccurate (will trigger `x-y of many` in pagination after the count of rows fetched is greater than provided `rowCount`) - * It could be useful with: - * 1. Cursor based pagination: - * When rowCount is not known, grid will check for `hasNextPage` to determine - * if there are more rows to fetch. - * 2. Inaccurate `rowCount`: - * `truncated: true` will let the grid know that `rowCount` is estimated/truncated. - * Thus `hasNextPage` will come into play to check more rows are available to fetch after the number becomes >= provided `rowCount` - */ - pageInfo?: { - hasNextPage?: boolean; - truncated?: number; - }; -} - -export interface DataSource { - /** - * Fetcher Functions: - * - `getRows` is required - * - `updateRow` is optional - * - * `getRows` will be used by the grid to fetch data for the current page or children for the current parent group. - * It may return a `rowCount` to update the total count of rows in the grid along with the optional `pageInfo`. - */ - getRows(params: GetRowsParams): Promise; - updateRow?(rows: GridRowModel): Promise; -} diff --git a/packages/x-data-grid-pro/src/models/gridApiPro.ts b/packages/x-data-grid-pro/src/models/gridApiPro.ts index 33e5f884a0d4..0f0f56261ef5 100644 --- a/packages/x-data-grid-pro/src/models/gridApiPro.ts +++ b/packages/x-data-grid-pro/src/models/gridApiPro.ts @@ -11,6 +11,8 @@ import type { GridDetailPanelApi, GridRowPinningApi, GridDetailPanelPrivateApi, + GridDataSourceApi, + GridDataSourcePrivateApi, } from '../hooks'; import type { DataGridProProcessedProps } from './dataGridProProps'; @@ -23,6 +25,7 @@ export interface GridApiPro GridColumnPinningApi, GridDetailPanelApi, GridRowPinningApi, + GridDataSourceApi, // APIs that are private in Community plan, but public in Pro and Premium plans GridRowMultiSelectionApi, GridColumnReorderApi {} @@ -31,4 +34,5 @@ export interface GridPrivateApiPro extends GridApiPro, GridPrivateOnlyApiCommon, GridDetailPanelPrivateApi, - GridInfiniteLoaderPrivateApi {} + GridInfiniteLoaderPrivateApi, + GridDataSourcePrivateApi {} diff --git a/packages/x-data-grid-pro/src/models/gridStatePro.ts b/packages/x-data-grid-pro/src/models/gridStatePro.ts index 662a9bed10b0..e26694abd3e8 100644 --- a/packages/x-data-grid-pro/src/models/gridStatePro.ts +++ b/packages/x-data-grid-pro/src/models/gridStatePro.ts @@ -9,6 +9,7 @@ import type { GridDetailPanelInitialState, GridColumnReorderState, } from '../hooks'; +import type { GridDataSourceState } from '../hooks/features/dataSource/interfaces'; /** * The state of `DataGridPro`. @@ -17,6 +18,7 @@ export interface GridStatePro extends GridStateCommunity { columnReorder: GridColumnReorderState; pinnedColumns: GridColumnPinningState; detailPanel: GridDetailPanelState; + dataSource: GridDataSourceState; } /** diff --git a/packages/x-data-grid-pro/src/models/index.ts b/packages/x-data-grid-pro/src/models/index.ts index 36deff5c944b..8110b6c70a91 100644 --- a/packages/x-data-grid-pro/src/models/index.ts +++ b/packages/x-data-grid-pro/src/models/index.ts @@ -1,3 +1,9 @@ +export type { + GridGetRowsParams, + GridGetRowsResponse, + GridDataSource, + GridDataSourceCache, +} from '@mui/x-data-grid/internals'; export * from './gridApiPro'; export * from './gridGroupingColDefOverride'; export * from './gridRowScrollEndParams'; diff --git a/packages/x-data-grid-pro/src/tests/rows.DataGridPro.test.tsx b/packages/x-data-grid-pro/src/tests/rows.DataGridPro.test.tsx index f0704d4fb4fd..101bc83e0420 100644 --- a/packages/x-data-grid-pro/src/tests/rows.DataGridPro.test.tsx +++ b/packages/x-data-grid-pro/src/tests/rows.DataGridPro.test.tsx @@ -27,8 +27,12 @@ import { useBasicDemoData, getBasicGridData } from '@mui/x-data-grid-generator'; const isJSDOM = /jsdom/.test(window.navigator.userAgent); +interface BaselineProps extends DataGridProProps { + rows: GridValidRowModel[]; +} + describe(' - Rows', () => { - let baselineProps: DataGridProProps & { rows: GridValidRowModel }; + let baselineProps: BaselineProps; const { clock, render } = createRenderer({ clock: 'fake' }); diff --git a/packages/x-data-grid-pro/src/utils/tree/createRowTree.ts b/packages/x-data-grid-pro/src/utils/tree/createRowTree.ts index 09675aa21548..6f79cc1c727c 100644 --- a/packages/x-data-grid-pro/src/utils/tree/createRowTree.ts +++ b/packages/x-data-grid-pro/src/utils/tree/createRowTree.ts @@ -22,6 +22,7 @@ export const createRowTree = (params: CreateRowTreeParams): GridRowTreeCreationV [GRID_ROOT_GROUP_ID]: buildRootGroup(), }; const treeDepths: GridRowTreeCreationValue['treeDepths'] = {}; + const groupsToFetch = new Set(); for (let i = 0; i < params.nodes.length; i += 1) { const node = params.nodes[i]; @@ -32,10 +33,12 @@ export const createRowTree = (params: CreateRowTreeParams): GridRowTreeCreationV previousTree: params.previousTree, id: node.id, path: node.path, + hasServerChildren: node.hasServerChildren, onDuplicatePath: params.onDuplicatePath, treeDepths, isGroupExpandedByDefault: params.isGroupExpandedByDefault, defaultGroupingExpansionDepth: params.defaultGroupingExpansionDepth, + groupsToFetch, }); } @@ -44,5 +47,6 @@ export const createRowTree = (params: CreateRowTreeParams): GridRowTreeCreationV treeDepths, groupingName: params.groupingName, dataRowIds, + groupsToFetch: Array.from(groupsToFetch), }; }; diff --git a/packages/x-data-grid-pro/src/utils/tree/insertDataRowInTree.ts b/packages/x-data-grid-pro/src/utils/tree/insertDataRowInTree.ts index a79fbdcc759e..86cd27e02026 100644 --- a/packages/x-data-grid-pro/src/utils/tree/insertDataRowInTree.ts +++ b/packages/x-data-grid-pro/src/utils/tree/insertDataRowInTree.ts @@ -4,10 +4,12 @@ import { GridLeafNode, GridRowId, GridRowTreeConfig, + GridDataSourceGroupNode, } from '@mui/x-data-grid'; import { GridTreeDepths, GridRowTreeUpdatedGroupsManager } from '@mui/x-data-grid/internals'; import { updateGroupDefaultExpansion, + checkGroupChildrenExpansion, getGroupRowIdFromPath, insertNodeInTree, updateGroupNodeIdAndAutoGenerated, @@ -57,6 +59,8 @@ interface InsertDataRowInTreeParams { onDuplicatePath?: GridTreePathDuplicateHandler; isGroupExpandedByDefault?: DataGridProProps['isGroupExpandedByDefault']; defaultGroupingExpansionDepth: number; + hasServerChildren?: boolean; + groupsToFetch?: Set; } /** @@ -75,6 +79,8 @@ export const insertDataRowInTree = ({ onDuplicatePath, isGroupExpandedByDefault, defaultGroupingExpansionDepth, + hasServerChildren, + groupsToFetch, }: InsertDataRowInTreeParams) => { let parentNodeId = GRID_ROOT_GROUP_ID; @@ -92,17 +98,43 @@ export const insertDataRowInTree = ({ // If no node matches the full path, // We create a leaf node for the data row. if (existingNodeIdWithPartialPath == null) { - const leafNode: GridLeafNode = { - type: 'leaf', - id, - depth, - parent: parentNodeId, - groupingKey: key, - }; + let node: GridLeafNode | GridDataSourceGroupNode; + if (hasServerChildren) { + node = { + type: 'group', + id, + parent: parentNodeId, + path: path.map((step) => step.key as string), + depth, + isAutoGenerated: false, + groupingKey: key, + groupingField: field, + children: [], + childrenFromPath: {}, + childrenExpanded: false, + hasServerChildren: true, + }; + const shouldFetchChildren = checkGroupChildrenExpansion( + node, + defaultGroupingExpansionDepth, + isGroupExpandedByDefault, + ); + if (shouldFetchChildren) { + groupsToFetch?.add(id); + } + } else { + node = { + type: 'leaf', + id, + depth, + parent: parentNodeId, + groupingKey: key, + }; + } updatedGroupsManager?.addAction(parentNodeId, 'insertChildren'); - insertNodeInTree(leafNode, tree, treeDepths, previousTree); + insertNodeInTree(node, tree, treeDepths, previousTree); } else { const existingNodeWithPartialPath = tree[existingNodeIdWithPartialPath]; diff --git a/packages/x-data-grid-pro/src/utils/tree/models.ts b/packages/x-data-grid-pro/src/utils/tree/models.ts index 5eef120e37ff..871d3fc86c97 100644 --- a/packages/x-data-grid-pro/src/utils/tree/models.ts +++ b/packages/x-data-grid-pro/src/utils/tree/models.ts @@ -8,6 +8,7 @@ export interface RowTreeBuilderGroupingCriterion { export interface RowTreeBuilderNode { id: GridRowId; path: RowTreeBuilderGroupingCriterion[]; + hasServerChildren?: boolean; } /** diff --git a/packages/x-data-grid-pro/src/utils/tree/updateRowTree.ts b/packages/x-data-grid-pro/src/utils/tree/updateRowTree.ts index ad87141ef647..0c7ddc514403 100644 --- a/packages/x-data-grid-pro/src/utils/tree/updateRowTree.ts +++ b/packages/x-data-grid-pro/src/utils/tree/updateRowTree.ts @@ -24,15 +24,19 @@ interface UpdateRowTreeParams { isGroupExpandedByDefault?: (node: GridGroupNode) => boolean; groupingName: string; onDuplicatePath?: GridTreePathDuplicateHandler; + previousGroupsToFetch?: GridRowId[]; } export const updateRowTree = (params: UpdateRowTreeParams): GridRowTreeCreationValue => { const tree = { ...params.previousTree }; const treeDepths = { ...params.previousTreeDepth }; const updatedGroupsManager = createUpdatedGroupsManager(); + const groupsToFetch = params.previousGroupsToFetch + ? new Set([...params.previousGroupsToFetch]) + : new Set([]); for (let i = 0; i < params.nodes.inserted.length; i += 1) { - const { id, path } = params.nodes.inserted[i]; + const { id, path, hasServerChildren } = params.nodes.inserted[i]; insertDataRowInTree({ previousTree: params.previousTree, @@ -41,9 +45,11 @@ export const updateRowTree = (params: UpdateRowTreeParams): GridRowTreeCreationV updatedGroupsManager, id, path, + hasServerChildren, onDuplicatePath: params.onDuplicatePath, isGroupExpandedByDefault: params.isGroupExpandedByDefault, defaultGroupingExpansionDepth: params.defaultGroupingExpansionDepth, + groupsToFetch, }); } @@ -59,7 +65,7 @@ export const updateRowTree = (params: UpdateRowTreeParams): GridRowTreeCreationV } for (let i = 0; i < params.nodes.modified.length; i += 1) { - const { id, path } = params.nodes.modified[i]; + const { id, path, hasServerChildren } = params.nodes.modified[i]; const pathInPreviousTree = getNodePathInTree({ tree, id }); const isInSameGroup = isDeepEqual(pathInPreviousTree, path); @@ -78,9 +84,11 @@ export const updateRowTree = (params: UpdateRowTreeParams): GridRowTreeCreationV updatedGroupsManager, id, path, + hasServerChildren, onDuplicatePath: params.onDuplicatePath, isGroupExpandedByDefault: params.isGroupExpandedByDefault, defaultGroupingExpansionDepth: params.defaultGroupingExpansionDepth, + groupsToFetch, }); } else { updatedGroupsManager?.addAction(tree[id].parent!, 'modifyChildren'); @@ -96,5 +104,6 @@ export const updateRowTree = (params: UpdateRowTreeParams): GridRowTreeCreationV groupingName: params.groupingName, dataRowIds, updatedGroupsManager, + groupsToFetch: Array.from(groupsToFetch), }; }; diff --git a/packages/x-data-grid-pro/src/utils/tree/utils.ts b/packages/x-data-grid-pro/src/utils/tree/utils.ts index 5de194769e5c..fba2f4c9bfa2 100644 --- a/packages/x-data-grid-pro/src/utils/tree/utils.ts +++ b/packages/x-data-grid-pro/src/utils/tree/utils.ts @@ -49,7 +49,7 @@ export const getNodePathInTree = ({ return path; }; -export const updateGroupDefaultExpansion = ( +export const checkGroupChildrenExpansion = ( node: GridGroupNode, defaultGroupingExpansionDepth: number, isGroupExpandedByDefault?: DataGridProProps['isGroupExpandedByDefault'], @@ -64,8 +64,20 @@ export const updateGroupDefaultExpansion = ( defaultGroupingExpansionDepth === -1 || defaultGroupingExpansionDepth > node.depth; } - node.childrenExpanded = childrenExpanded; + return childrenExpanded; +}; +export const updateGroupDefaultExpansion = ( + node: GridGroupNode, + defaultGroupingExpansionDepth: number, + isGroupExpandedByDefault?: DataGridProProps['isGroupExpandedByDefault'], +) => { + const childrenExpanded = checkGroupChildrenExpansion( + node, + defaultGroupingExpansionDepth, + isGroupExpandedByDefault, + ); + node.childrenExpanded = childrenExpanded; return node; }; diff --git a/packages/x-data-grid/src/components/containers/GridRootStyles.ts b/packages/x-data-grid/src/components/containers/GridRootStyles.ts index 0d5e88de51f6..e1ea183b8bcb 100644 --- a/packages/x-data-grid/src/components/containers/GridRootStyles.ts +++ b/packages/x-data-grid/src/components/containers/GridRootStyles.ts @@ -115,6 +115,9 @@ export const GridRootStyles = styled('div', { { [`& .${c.withBorderColor}`]: styles.withBorderColor }, { [`& .${c.treeDataGroupingCell}`]: styles.treeDataGroupingCell }, { [`& .${c.treeDataGroupingCellToggle}`]: styles.treeDataGroupingCellToggle }, + { + [`& .${c.treeDataGroupingCellLoadingContainer}`]: styles.treeDataGroupingCellLoadingContainer, + }, { [`& .${c.detailPanelToggleCell}`]: styles.detailPanelToggleCell }, { [`& .${c['detailPanelToggleCell--expanded']}`]: styles['detailPanelToggleCell--expanded'], @@ -620,6 +623,12 @@ export const GridRootStyles = styled('div', { alignSelf: 'stretch', marginRight: t.spacing(2), }, + [`& .${c.treeDataGroupingCellLoadingContainer}`]: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: '100%', + }, [`& .${c.groupingCriteriaCell}`]: { display: 'flex', alignItems: 'center', diff --git a/packages/x-data-grid/src/constants/gridClasses.ts b/packages/x-data-grid/src/constants/gridClasses.ts index c795ccda8336..d908705f9162 100644 --- a/packages/x-data-grid/src/constants/gridClasses.ts +++ b/packages/x-data-grid/src/constants/gridClasses.ts @@ -578,6 +578,11 @@ export interface GridClasses { * Styles applied to the toggle of the grouping cell of the tree data. */ treeDataGroupingCellToggle: string; + /** + * Styles applied to the loading container of the grouping cell of the tree data. + * @ignore - do not document. + */ + treeDataGroupingCellLoadingContainer: string; /** * Styles applied to the root element of the grouping criteria cell */ @@ -754,6 +759,7 @@ export const gridClasses = generateUtilityClasses('MuiDataGrid', [ 'columnHeader--withLeftBorder', 'treeDataGroupingCell', 'treeDataGroupingCellToggle', + 'treeDataGroupingCellLoadingContainer', 'groupingCriteriaCell', 'groupingCriteriaCellToggle', 'pinnedRows', diff --git a/packages/x-data-grid/src/hooks/features/pagination/index.ts b/packages/x-data-grid/src/hooks/features/pagination/index.ts index f47e042c72ee..4437fa668eb6 100644 --- a/packages/x-data-grid/src/hooks/features/pagination/index.ts +++ b/packages/x-data-grid/src/hooks/features/pagination/index.ts @@ -1,5 +1,7 @@ export * from './gridPaginationSelector'; export type { + GridPaginationModelApi, + GridPaginationRowCountApi, GridPaginationApi, GridPaginationState, GridPaginationInitialState, diff --git a/packages/x-data-grid/src/hooks/features/rows/gridRowsInterfaces.ts b/packages/x-data-grid/src/hooks/features/rows/gridRowsInterfaces.ts index 34562ff2231f..fc999812ee92 100644 --- a/packages/x-data-grid/src/hooks/features/rows/gridRowsInterfaces.ts +++ b/packages/x-data-grid/src/hooks/features/rows/gridRowsInterfaces.ts @@ -50,7 +50,7 @@ export interface GridRowsState { treeDepths: GridTreeDepths; dataRowIds: GridRowId[]; /** - * Matches the value of the `loading` prop. + * The loading status of the rows. */ loading?: boolean; /** @@ -70,6 +70,12 @@ export interface GridRowsState { additionalRowGroups?: { pinnedRows?: GridPinnedRowsState; }; + /** + * Contains some values of type `GridRowId` that have been requested to be fetched + * either by `defaultGroupingExpansionDepth` or `isGroupExpandedByDefault` props. + * Applicable with server-side grouped data and `unstable_dataSource` only. + */ + groupsToFetch?: GridRowId[]; } export interface GridRowTreeCreationParams { @@ -78,6 +84,7 @@ export interface GridRowTreeCreationParams { updates: GridRowsPartialUpdates | GridRowsFullUpdate; dataRowIdToIdLookup: GridRowIdToIdLookup; dataRowIdToModelLookup: GridRowIdToModelLookup; + previousGroupsToFetch?: GridRowId[]; } export type GridRowTreeUpdateGroupAction = 'removeChildren' | 'insertChildren' | 'modifyChildren'; @@ -93,7 +100,7 @@ export type GridRowTreeUpdatedGroupsManager = { export type GridRowTreeCreationValue = Pick< GridRowsState, - 'groupingName' | 'tree' | 'treeDepths' | 'dataRowIds' + 'groupingName' | 'tree' | 'treeDepths' | 'dataRowIds' | 'groupsToFetch' > & { updatedGroupsManager?: GridRowTreeUpdatedGroupsManager; }; @@ -128,6 +135,7 @@ export interface GridRowsPartialUpdates { type: 'partial'; actions: { [action in GridRowsPartialUpdateAction]: GridRowId[] }; idToActionLookup: { [id: GridRowId]: GridRowsPartialUpdateAction | undefined }; + groupKeys?: string[]; } export interface GridPinnedRowsState { diff --git a/packages/x-data-grid/src/hooks/features/rows/gridRowsSelector.ts b/packages/x-data-grid/src/hooks/features/rows/gridRowsSelector.ts index 6dd24b5256bc..c0f3614d0ad3 100644 --- a/packages/x-data-grid/src/hooks/features/rows/gridRowsSelector.ts +++ b/packages/x-data-grid/src/hooks/features/rows/gridRowsSelector.ts @@ -31,6 +31,11 @@ export const gridRowsDataRowIdToIdLookupSelector = createSelector( export const gridRowTreeSelector = createSelector(gridRowsStateSelector, (rows) => rows.tree); +export const gridRowGroupsToFetchSelector = createSelector( + gridRowsStateSelector, + (rows) => rows.groupsToFetch, +); + export const gridRowGroupingNameSelector = createSelector( gridRowsStateSelector, (rows) => rows.groupingName, diff --git a/packages/x-data-grid/src/hooks/features/rows/gridRowsUtils.ts b/packages/x-data-grid/src/hooks/features/rows/gridRowsUtils.ts index a2704b611c8e..83b643c84dd4 100644 --- a/packages/x-data-grid/src/hooks/features/rows/gridRowsUtils.ts +++ b/packages/x-data-grid/src/hooks/features/rows/gridRowsUtils.ts @@ -132,7 +132,11 @@ export const getRowsStateFromCache = ({ loadingProp, previousTree, previousTreeDepths, -}: Pick & { + previousGroupsToFetch, +}: Pick< + GridRowTreeCreationParams, + 'previousTree' | 'previousTreeDepths' | 'previousGroupsToFetch' +> & { apiRef: React.MutableRefObject; rowCountProp: number | undefined; loadingProp: boolean | undefined; @@ -145,12 +149,14 @@ export const getRowsStateFromCache = ({ treeDepths: unProcessedTreeDepths, dataRowIds: unProcessedDataRowIds, groupingName, + groupsToFetch = [], } = apiRef.current.applyStrategyProcessor('rowTreeCreation', { previousTree, previousTreeDepths, updates: cache.updates, dataRowIdToIdLookup: cache.dataRowIdToIdLookup, dataRowIdToModelLookup: cache.dataRowIdToModelLookup, + previousGroupsToFetch, }); // 2. Apply the "hydrateRows" pipe-processing. @@ -182,6 +188,7 @@ export const getRowsStateFromCache = ({ }), groupingName, loading: loadingProp, + groupsToFetch, }; }; @@ -230,10 +237,12 @@ export const updateCacheWithNewRows = ({ previousCache, getRowId, updates, + groupKeys, }: { previousCache: GridRowsInternalCache; getRowId: DataGridProcessedProps['getRowId']; updates: GridRowModelUpdate[]; + groupKeys?: string[]; }): GridRowsInternalCache => { if (previousCache.updates.type === 'full') { throw new Error( @@ -267,6 +276,7 @@ export const updateCacheWithNewRows = ({ remove: [...(previousCache.updates.actions.remove ?? [])], }, idToActionLookup: { ...previousCache.updates.idToActionLookup }, + groupKeys, }; const dataRowIdToModelLookup = { ...previousCache.dataRowIdToModelLookup }; const dataRowIdToIdLookup = { ...previousCache.dataRowIdToIdLookup }; @@ -393,3 +403,35 @@ export function getMinimalContentHeight(apiRef: React.MutableRefObject, + updates: GridRowModelUpdate[], + getRowId: DataGridProcessedProps['getRowId'], +) { + const nonPinnedRowsUpdates: GridRowModelUpdate[] = []; + + updates.forEach((update) => { + const id = getRowIdFromRowModel( + update, + getRowId, + 'A row was provided without id when calling updateRows():', + ); + + const rowNode = apiRef.current.getRowNode(id); + if (rowNode?.type === 'pinnedRow') { + // @ts-ignore because otherwise `release:build` doesn't work + const pinnedRowsCache = apiRef.current.caches.pinnedRows; + const prevModel = pinnedRowsCache.idLookup[id]; + if (prevModel) { + pinnedRowsCache.idLookup[id] = { + ...prevModel, + ...update, + }; + } + } else { + nonPinnedRowsUpdates.push(update); + } + }); + return nonPinnedRowsUpdates; +} diff --git a/packages/x-data-grid/src/hooks/features/rows/useGridRows.ts b/packages/x-data-grid/src/hooks/features/rows/useGridRows.ts index aba863dad31c..2737b07ce94c 100644 --- a/packages/x-data-grid/src/hooks/features/rows/useGridRows.ts +++ b/packages/x-data-grid/src/hooks/features/rows/useGridRows.ts @@ -2,13 +2,8 @@ import * as React from 'react'; import { GridEventListener } from '../../../models/events'; import { DataGridProcessedProps } from '../../../models/props/DataGridProps'; import { GridPrivateApiCommunity } from '../../../models/api/gridApiCommunity'; -import { GridRowApi, GridRowProApi } from '../../../models/api/gridRowApi'; -import { - GridRowId, - GridGroupNode, - GridLeafNode, - GridRowModelUpdate, -} from '../../../models/gridRows'; +import { GridRowApi, GridRowProApi, GridRowProPrivateApi } from '../../../models/api/gridRowApi'; +import { GridRowId, GridGroupNode, GridLeafNode } from '../../../models/gridRows'; import { useGridApiMethod } from '../../utils/useGridApiMethod'; import { useGridLogger } from '../../utils/useGridLogger'; import { @@ -20,6 +15,7 @@ import { gridDataRowIdsSelector, gridRowsDataRowIdToIdLookupSelector, gridRowMaximumTreeDepthSelector, + gridRowGroupsToFetchSelector, } from './gridRowsSelector'; import { useTimeout } from '../../utils/useTimeout'; import { GridSignature, useGridApiEventHandler } from '../../utils/useGridApiEventHandler'; @@ -38,14 +34,16 @@ import { updateCacheWithNewRows, getTopLevelRowCount, getRowIdFromRowModel, + computeRowsUpdates, } from './gridRowsUtils'; import { useGridRegisterPipeApplier } from '../../core/pipeProcessing'; export const rowsStateInitializer: GridStateInitializer< - Pick + Pick > = (state, props, apiRef) => { + const isDataSourceAvailable = !!props.unstable_dataSource; apiRef.current.caches.rows = createRowsInternalCache({ - rows: props.rows, + rows: isDataSourceAvailable ? [] : props.rows, getRowId: props.getRowId, loading: props.loading, rowCount: props.rowCount, @@ -56,7 +54,7 @@ export const rowsStateInitializer: GridStateInitializer< rows: getRowsStateFromCache({ apiRef, rowCountProp: props.rowCount, - loadingProp: props.loading, + loadingProp: isDataSourceAvailable ? true : props.loading, previousTree: null, previousTreeDepths: null, }), @@ -146,6 +144,7 @@ export const useGridRows = ( loadingProp: props.loading, previousTree: gridRowTreeSelector(apiRef), previousTreeDepths: gridRowTreeDepthsSelector(apiRef), + previousGroupsToFetch: gridRowGroupsToFetchSelector(apiRef), }), })); apiRef.current.publishEvent('rowsSet'); @@ -203,30 +202,7 @@ export const useGridRows = ( ); } - const nonPinnedRowsUpdates: GridRowModelUpdate[] = []; - - updates.forEach((update) => { - const id = getRowIdFromRowModel( - update, - props.getRowId, - 'A row was provided without id when calling updateRows():', - ); - - const rowNode = apiRef.current.getRowNode(id); - if (rowNode?.type === 'pinnedRow') { - // @ts-ignore because otherwise `release:build` doesn't work - const pinnedRowsCache = apiRef.current.caches.pinnedRows; - const prevModel = pinnedRowsCache.idLookup[id]; - if (prevModel) { - pinnedRowsCache.idLookup[id] = { - ...prevModel, - ...update, - }; - } - } else { - nonPinnedRowsUpdates.push(update); - } - }); + const nonPinnedRowsUpdates = computeRowsUpdates(apiRef, updates, props.getRowId); const cache = updateCacheWithNewRows({ updates: nonPinnedRowsUpdates, @@ -239,6 +215,37 @@ export const useGridRows = ( [props.signature, props.getRowId, throttledRowsChange, apiRef], ); + const updateServerRows = React.useCallback( + (updates, groupKeys) => { + const nonPinnedRowsUpdates = computeRowsUpdates(apiRef, updates, props.getRowId); + + const cache = updateCacheWithNewRows({ + updates: nonPinnedRowsUpdates, + getRowId: props.getRowId, + previousCache: apiRef.current.caches.rows, + groupKeys: groupKeys ?? [], + }); + + throttledRowsChange({ cache, throttle: false }); + }, + [props.getRowId, throttledRowsChange, apiRef], + ); + + const setLoading = React.useCallback( + (loading) => { + if (loading === props.loading) { + return; + } + logger.debug(`Setting loading to ${loading}`); + apiRef.current.setState((state) => ({ + ...state, + rows: { ...state.rows, loading }, + })); + apiRef.current.caches.rows.loadingPropBeforePartialUpdates = loading; + }, + [props.loading, apiRef, logger], + ); + const getRowModels = React.useCallback(() => { const dataRows = gridDataRowIdsSelector(apiRef); const idRowsLookup = gridRowsLookupSelector(apiRef); @@ -468,6 +475,7 @@ export const useGridRows = ( const rowApi: GridRowApi = { getRow, + setLoading, getRowId, getRowModels, getRowsCount, @@ -485,6 +493,10 @@ export const useGridRows = ( getRowGroupChildren, }; + const rowProPrivateApi: GridRowProPrivateApi = { + updateServerRows, + }; + /** * EVENTS */ @@ -585,6 +597,7 @@ export const useGridRows = ( rowProApi, props.signature === GridSignature.DataGrid ? 'private' : 'public', ); + useGridApiMethod(apiRef, rowProPrivateApi, 'private'); // The effect do not track any value defined synchronously during the 1st render by hooks called after `useGridRows` // As a consequence, the state generated by the 1st run of this useEffect will always be equal to the initialization one @@ -637,7 +650,7 @@ export const useGridRows = ( } } - logger.debug(`Updating all rows, new length ${props.rows.length}`); + logger.debug(`Updating all rows, new length ${props.rows?.length}`); throttledRowsChange({ cache: createRowsInternalCache({ rows: props.rows, diff --git a/packages/x-data-grid/src/internals/index.ts b/packages/x-data-grid/src/internals/index.ts index 88b4d71bf80f..1387f50016d3 100644 --- a/packages/x-data-grid/src/internals/index.ts +++ b/packages/x-data-grid/src/internals/index.ts @@ -130,10 +130,11 @@ export { useGridInitializeState } from '../hooks/utils/useGridInitializeState'; export type { GridStateInitializer } from '../hooks/utils/useGridInitializeState'; export type * from '../models/props/DataGridProps'; - +export type * from '../models/gridDataSource'; export { getColumnsToExport, defaultGetRowsToExport } from '../hooks/features/export/utils'; export * from '../utils/createControllablePromise'; export { createSelector, createSelectorMemoized } from '../utils/createSelector'; +export { gridRowGroupsToFetchSelector } from '../hooks/features/rows/gridRowsSelector'; export { findParentElementFromClassName, getActiveElement, diff --git a/packages/x-data-grid/src/internals/utils/propValidation.ts b/packages/x-data-grid/src/internals/utils/propValidation.ts index 6fa67ab6cc15..825db3991f71 100644 --- a/packages/x-data-grid/src/internals/utils/propValidation.ts +++ b/packages/x-data-grid/src/internals/utils/propValidation.ts @@ -35,6 +35,7 @@ export const propValidatorsDataGrid: PropValidator[] = [ (props) => (props.paginationMode === 'server' && props.rowCount == null && + !props.unstable_dataSource && [ "MUI X: The `rowCount` prop must be passed using `paginationMode='server'`", 'For more detail, see http://mui.com/components/data-grid/pagination/#index-based-pagination', diff --git a/packages/x-data-grid/src/models/api/gridApiCommon.ts b/packages/x-data-grid/src/models/api/gridApiCommon.ts index 1e59d123c8a5..f5ba4a40faf7 100644 --- a/packages/x-data-grid/src/models/api/gridApiCommon.ts +++ b/packages/x-data-grid/src/models/api/gridApiCommon.ts @@ -10,7 +10,7 @@ import type { GridLocaleTextApi } from './gridLocaleTextApi'; import type { GridParamsApi } from './gridParamsApi'; import { GridPreferencesPanelApi } from './gridPreferencesPanelApi'; import { GridPrintExportApi } from './gridPrintExportApi'; -import { GridRowApi } from './gridRowApi'; +import { GridRowApi, GridRowProPrivateApi } from './gridRowApi'; import { GridRowsMetaApi, GridRowsMetaPrivateApi } from './gridRowsMetaApi'; import { GridRowSelectionApi } from './gridRowSelectionApi'; import { GridSortApi } from './gridSortApi'; @@ -82,7 +82,8 @@ export interface GridPrivateOnlyApiCommon< GridLoggerApi, GridFocusPrivateApi, GridHeaderFilteringPrivateApi, - GridVirtualizationPrivateApi {} + GridVirtualizationPrivateApi, + GridRowProPrivateApi {} export interface GridPrivateApiCommon extends GridApiCommon, diff --git a/packages/x-data-grid/src/models/api/gridRowApi.ts b/packages/x-data-grid/src/models/api/gridRowApi.ts index 35382e3067dc..b54617e856a4 100644 --- a/packages/x-data-grid/src/models/api/gridRowApi.ts +++ b/packages/x-data-grid/src/models/api/gridRowApi.ts @@ -48,6 +48,11 @@ export interface GridRowApi { * @returns {GridRowId[]} A list of ids. */ getAllRowIds: () => GridRowId[]; + /** + * Sets the internal loading state. + * @param {boolean} loading If `true` the loading indicator will be shown over the Data Grid. + */ + setLoading: (loading: boolean) => void; /** * Sets a new set of rows. * @param {GridRowModel[]} rows The new rows. @@ -112,3 +117,13 @@ export interface GridRowProApi { */ setRowChildrenExpansion: (id: GridRowId, isExpanded: boolean) => void; } + +export interface GridRowProPrivateApi { + /** + * Allows to update, insert and delete rows at a specific nested level. + * @param {GridRowModelUpdate[]} updates An array of rows with an `action` specifying what to do. + * @param {string[]} groupKeys The group keys of the rows to update. + * @param {boolean} throttle Whether to throttle the updates or not. (default: `true`) + */ + updateServerRows: (updates: GridRowModelUpdate[], groupKeys?: string[]) => void; +} diff --git a/packages/x-data-grid/src/models/gridDataSource.ts b/packages/x-data-grid/src/models/gridDataSource.ts new file mode 100644 index 000000000000..58038fe0e290 --- /dev/null +++ b/packages/x-data-grid/src/models/gridDataSource.ts @@ -0,0 +1,97 @@ +import type { + GridSortModel, + GridFilterModel, + GridColDef, + GridRowModel, + GridPaginationModel, +} from '.'; + +export interface GridGetRowsParams { + sortModel: GridSortModel; + filterModel: GridFilterModel; + /** + * Alternate to `start` and `end`, maps to `GridPaginationModel` interface. + */ + paginationModel: GridPaginationModel; + /** + * First row index to fetch (number) or cursor information (number | string). + */ + start: number | string; + /** + * Last row index to fetch. + */ + end: number; // last row index to fetch + /** + * List of grouped columns (only applicable with `rowGrouping`). + */ + groupFields?: GridColDef['field'][]; + /** + * Array of keys returned by `getGroupKey` of all the parent rows until the row for which the data is requested + * `getGroupKey` prop must be implemented to use this. + * Useful for `treeData` and `rowGrouping` only. + */ + groupKeys?: string[]; +} + +export interface GridGetRowsResponse { + rows: GridRowModel[]; + /** + * To reflect updates in total `rowCount` (optional). + * Useful when the `rowCount` is inaccurate (for example when filtering) or not available upfront. + */ + rowCount?: number; + /** + * Additional `pageInfo` for advanced use-cases. + * `hasNextPage`: When row count is unknown/estimated, `hasNextPage` will be used to check if more records are available on server + */ + pageInfo?: { + hasNextPage?: boolean; + nextCursor?: string; + }; +} + +export interface GridDataSource { + /** + * This method will be called when the grid needs to fetch some rows + * @param {GridGetRowsParams} params The parameters required to fetch the rows + * @returns {Promise} A promise that resolves to the data of type [GridGetRowsResponse] + */ + getRows(params: GridGetRowsParams): Promise; + /** + * This method will be called when the user updates a row [Not yet implemented] + * @param {GridRowModel} updatedRow The updated row + * @returns {Promise} If resolved (synced on the backend), the grid will update the row and mutate the cache + */ + updateRow?(updatedRow: GridRowModel): Promise; + /** + * Used to group rows by their parent group. Replaces `getTreeDataPath` used in client side tree-data . + * @param {GridRowModel} row The row to get the group key of + * @returns {string} The group key for the row + */ + getGroupKey?: (row: GridRowModel) => string; + /** + * Used to determine the number of children a row has on server. + * @param {GridRowModel} row The row to check the number of children + * @returns {number} The number of children the row has + */ + getChildrenCount?: (row: GridRowModel) => number; +} + +export interface GridDataSourceCache { + /** + * Set the cache entry for the given key + * @param {GridGetRowsParams} key The key of type `GridGetRowsParams` + * @param {GridGetRowsResponse} value The value to be stored in the cache + */ + set: (key: GridGetRowsParams, value: GridGetRowsResponse) => void; + /** + * Get the cache entry for the given key + * @param {GridGetRowsParams} key The key of type `GridGetRowsParams` + * @returns {GridGetRowsResponse} The value stored in the cache + */ + get: (key: GridGetRowsParams) => GridGetRowsResponse | undefined; + /** + * Clear the cache + */ + clear: () => void; +} diff --git a/packages/x-data-grid/src/models/gridRows.ts b/packages/x-data-grid/src/models/gridRows.ts index 1519fd1bddba..703433dc873c 100644 --- a/packages/x-data-grid/src/models/gridRows.ts +++ b/packages/x-data-grid/src/models/gridRows.ts @@ -114,6 +114,17 @@ export interface GridDataGroupNode extends GridBasicGroupNode { isAutoGenerated: false; } +export interface GridDataSourceGroupNode extends GridDataGroupNode { + /** + * If true, this node has children on server. + */ + hasServerChildren: boolean; + /** + * The cached path to be passed on as `groupKey` to the server. + */ + path: string[]; +} + export type GridGroupNode = GridDataGroupNode | GridAutoGeneratedGroupNode; export type GridChildrenFromPathLookup = { diff --git a/packages/x-data-grid/src/models/props/DataGridProps.ts b/packages/x-data-grid/src/models/props/DataGridProps.ts index 759097fa8d12..19fc5d2cf42a 100644 --- a/packages/x-data-grid/src/models/props/DataGridProps.ts +++ b/packages/x-data-grid/src/models/props/DataGridProps.ts @@ -32,6 +32,7 @@ import { GridCellModesModel, GridRowModesModel } from '../api/gridEditingApi'; import { GridColumnGroupingModel } from '../gridColumnGrouping'; import { GridPaginationMeta, GridPaginationModel } from '../gridPaginationProps'; import type { GridAutosizeOptions } from '../../hooks/features/columnResize'; +import type { GridDataSource } from '../gridDataSource'; export interface GridExperimentalFeatures { /** @@ -813,6 +814,7 @@ export interface DataGridProSharedPropsWithoutDefaultValue { * Override the height of the header filters. */ headerFilterHeight?: number; + unstable_dataSource?: GridDataSource; } export interface DataGridPremiumSharedPropsWithDefaultValue { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8181431c0916..1bfa70101729 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -479,6 +479,9 @@ importers: '@react-spring/web': specifier: ^9.7.3 version: 9.7.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tanstack/query-core': + specifier: ^5.24.8 + version: 5.40.0 ast-types: specifier: ^0.14.2 version: 0.14.2 @@ -3486,6 +3489,9 @@ packages: '@swc/helpers@0.5.5': resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==} + '@tanstack/query-core@5.40.0': + resolution: {integrity: sha512-eD8K8jsOIq0Z5u/QbvOmfvKKE/XC39jA7yv4hgpl/1SRiU+J8QCIwgM/mEHuunQsL87dcvnHqSVLmf9pD4CiaA==} + '@testing-library/dom@10.1.0': resolution: {integrity: sha512-wdsYKy5zupPyLCW2Je5DLHSxSfbIp6h80WoHOQc+RPtmPGA52O9x5MJEkv92Sjonpq+poOAtUKhh1kBGAXBrNA==} engines: {node: '>=18'} @@ -9748,7 +9754,7 @@ snapshots: '@babel/traverse': 7.24.7 '@babel/types': 7.24.7 convert-source-map: 2.0.0 - debug: 4.3.4(supports-color@8.1.1) + debug: 4.3.4 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -12084,6 +12090,8 @@ snapshots: '@swc/counter': 0.1.3 tslib: 2.6.2 + '@tanstack/query-core@5.40.0': {} + '@testing-library/dom@10.1.0': dependencies: '@babel/code-frame': 7.24.7 @@ -13889,6 +13897,10 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.3.4: + dependencies: + ms: 2.1.2 + debug@4.3.4(supports-color@8.1.1): dependencies: ms: 2.1.2 @@ -18702,6 +18714,15 @@ snapshots: terser: 5.27.0 webpack: 5.92.1(webpack-cli@5.1.4) + terser-webpack-plugin@5.3.10(webpack@5.91.0): + dependencies: + '@jridgewell/trace-mapping': 0.3.25 + jest-worker: 27.5.1 + schema-utils: 3.3.0 + serialize-javascript: 6.0.2 + terser: 5.27.0 + webpack: 5.91.0 + terser@5.27.0: dependencies: '@jridgewell/source-map': 0.3.5 diff --git a/scripts/x-data-grid-generator.exports.json b/scripts/x-data-grid-generator.exports.json index e69f7b247f04..06a34db6fa23 100644 --- a/scripts/x-data-grid-generator.exports.json +++ b/scripts/x-data-grid-generator.exports.json @@ -1,9 +1,12 @@ [ + { "name": "BASE_URL", "kind": "Variable" }, { "name": "ColumnsOptions", "kind": "Interface" }, { "name": "createFakeServer", "kind": "Variable" }, { "name": "currencyPairs", "kind": "Variable" }, + { "name": "deepFreeze", "kind": "Variable" }, { "name": "DemoDataReturnType", "kind": "TypeAlias" }, { "name": "DemoLink", "kind": "Variable" }, + { "name": "extrapolateSeed", "kind": "Function" }, { "name": "generateFilledQuantity", "kind": "Variable" }, { "name": "generateIsFilled", "kind": "Variable" }, { "name": "getBasicGridData", "kind": "Variable" }, @@ -77,5 +80,6 @@ { "name": "useBasicDemoData", "kind": "Variable" }, { "name": "useDemoData", "kind": "Variable" }, { "name": "UseDemoDataOptions", "kind": "Interface" }, + { "name": "useMockServer", "kind": "Variable" }, { "name": "useMovieData", "kind": "Variable" } ] diff --git a/scripts/x-data-grid-premium.exports.json b/scripts/x-data-grid-premium.exports.json index 4539e4a0ce6e..367da6389ce3 100644 --- a/scripts/x-data-grid-premium.exports.json +++ b/scripts/x-data-grid-premium.exports.json @@ -245,6 +245,14 @@ { "name": "GridDataGroupNode", "kind": "Interface" }, { "name": "GridDataPinnedRowNode", "kind": "Interface" }, { "name": "gridDataRowIdsSelector", "kind": "Variable" }, + { "name": "GridDataSource", "kind": "Interface" }, + { "name": "GridDataSourceApi", "kind": "Interface" }, + { "name": "GridDataSourceApiBase", "kind": "Interface" }, + { "name": "GridDataSourceCache", "kind": "Interface" }, + { "name": "GridDataSourceCacheDefault", "kind": "Class" }, + { "name": "GridDataSourceGroupNode", "kind": "Interface" }, + { "name": "GridDataSourcePrivateApi", "kind": "Interface" }, + { "name": "GridDataSourceState", "kind": "Interface" }, { "name": "gridDateComparator", "kind": "Variable" }, { "name": "gridDateFormatter", "kind": "Variable" }, { "name": "gridDateTimeFormatter", "kind": "Variable" }, @@ -358,6 +366,8 @@ { "name": "GridFunctionsIcon", "kind": "Variable" }, { "name": "GridGenericColumnMenu", "kind": "Variable" }, { "name": "GridGenericColumnMenuProps", "kind": "Interface" }, + { "name": "GridGetRowsParams", "kind": "Interface" }, + { "name": "GridGetRowsResponse", "kind": "Interface" }, { "name": "GridGetRowsToExportParams", "kind": "Interface" }, { "name": "GridGroupingColDefOverride", "kind": "Interface" }, { "name": "GridGroupingColDefOverrideParams", "kind": "Interface" }, @@ -414,7 +424,9 @@ { "name": "GridPaginationMeta", "kind": "Interface" }, { "name": "gridPaginationMetaSelector", "kind": "Variable" }, { "name": "GridPaginationModel", "kind": "Interface" }, + { "name": "GridPaginationModelApi", "kind": "Interface" }, { "name": "gridPaginationModelSelector", "kind": "Variable" }, + { "name": "GridPaginationRowCountApi", "kind": "Interface" }, { "name": "gridPaginationRowCountSelector", "kind": "Variable" }, { "name": "gridPaginationRowRangeSelector", "kind": "Variable" }, { "name": "gridPaginationSelector", "kind": "Variable" }, @@ -509,6 +521,7 @@ { "name": "GridRowPinningApi", "kind": "Interface" }, { "name": "GridRowPinningInternalCache", "kind": "Interface" }, { "name": "GridRowProApi", "kind": "Interface" }, + { "name": "GridRowProPrivateApi", "kind": "Interface" }, { "name": "GridRowProps", "kind": "Interface" }, { "name": "GridRowScrollEndParams", "kind": "Interface" }, { "name": "gridRowsDataRowIdToIdLookupSelector", "kind": "Variable" }, diff --git a/scripts/x-data-grid-pro.exports.json b/scripts/x-data-grid-pro.exports.json index a0cece9a0a41..3007e027bd29 100644 --- a/scripts/x-data-grid-pro.exports.json +++ b/scripts/x-data-grid-pro.exports.json @@ -219,6 +219,14 @@ { "name": "GridDataGroupNode", "kind": "Interface" }, { "name": "GridDataPinnedRowNode", "kind": "Interface" }, { "name": "gridDataRowIdsSelector", "kind": "Variable" }, + { "name": "GridDataSource", "kind": "Interface" }, + { "name": "GridDataSourceApi", "kind": "Interface" }, + { "name": "GridDataSourceApiBase", "kind": "Interface" }, + { "name": "GridDataSourceCache", "kind": "Interface" }, + { "name": "GridDataSourceCacheDefault", "kind": "Class" }, + { "name": "GridDataSourceGroupNode", "kind": "Interface" }, + { "name": "GridDataSourcePrivateApi", "kind": "Interface" }, + { "name": "GridDataSourceState", "kind": "Interface" }, { "name": "gridDateComparator", "kind": "Variable" }, { "name": "gridDateFormatter", "kind": "Variable" }, { "name": "gridDateTimeFormatter", "kind": "Variable" }, @@ -326,6 +334,8 @@ { "name": "GridFooterPlaceholder", "kind": "Function" }, { "name": "GridGenericColumnMenu", "kind": "Variable" }, { "name": "GridGenericColumnMenuProps", "kind": "Interface" }, + { "name": "GridGetRowsParams", "kind": "Interface" }, + { "name": "GridGetRowsResponse", "kind": "Interface" }, { "name": "GridGetRowsToExportParams", "kind": "Interface" }, { "name": "GridGroupingColDefOverride", "kind": "Interface" }, { "name": "GridGroupingColDefOverrideParams", "kind": "Interface" }, @@ -378,7 +388,9 @@ { "name": "GridPaginationMeta", "kind": "Interface" }, { "name": "gridPaginationMetaSelector", "kind": "Variable" }, { "name": "GridPaginationModel", "kind": "Interface" }, + { "name": "GridPaginationModelApi", "kind": "Interface" }, { "name": "gridPaginationModelSelector", "kind": "Variable" }, + { "name": "GridPaginationRowCountApi", "kind": "Interface" }, { "name": "gridPaginationRowCountSelector", "kind": "Variable" }, { "name": "gridPaginationRowRangeSelector", "kind": "Variable" }, { "name": "gridPaginationSelector", "kind": "Variable" }, @@ -463,6 +475,7 @@ { "name": "GridRowPinningApi", "kind": "Interface" }, { "name": "GridRowPinningInternalCache", "kind": "Interface" }, { "name": "GridRowProApi", "kind": "Interface" }, + { "name": "GridRowProPrivateApi", "kind": "Interface" }, { "name": "GridRowProps", "kind": "Interface" }, { "name": "GridRowScrollEndParams", "kind": "Interface" }, { "name": "gridRowsDataRowIdToIdLookupSelector", "kind": "Variable" }, diff --git a/scripts/x-data-grid.exports.json b/scripts/x-data-grid.exports.json index 853ded01c41e..cf7cbd8a558f 100644 --- a/scripts/x-data-grid.exports.json +++ b/scripts/x-data-grid.exports.json @@ -205,6 +205,7 @@ { "name": "GridDataGroupNode", "kind": "Interface" }, { "name": "GridDataPinnedRowNode", "kind": "Interface" }, { "name": "gridDataRowIdsSelector", "kind": "Variable" }, + { "name": "GridDataSourceGroupNode", "kind": "Interface" }, { "name": "gridDateComparator", "kind": "Variable" }, { "name": "gridDateFormatter", "kind": "Variable" }, { "name": "gridDateTimeFormatter", "kind": "Variable" }, @@ -349,7 +350,9 @@ { "name": "GridPaginationMeta", "kind": "Interface" }, { "name": "gridPaginationMetaSelector", "kind": "Variable" }, { "name": "GridPaginationModel", "kind": "Interface" }, + { "name": "GridPaginationModelApi", "kind": "Interface" }, { "name": "gridPaginationModelSelector", "kind": "Variable" }, + { "name": "GridPaginationRowCountApi", "kind": "Interface" }, { "name": "gridPaginationRowCountSelector", "kind": "Variable" }, { "name": "gridPaginationRowRangeSelector", "kind": "Variable" }, { "name": "gridPaginationSelector", "kind": "Variable" }, @@ -424,6 +427,7 @@ { "name": "GridRowMultiSelectionApi", "kind": "Interface" }, { "name": "GridRowParams", "kind": "Interface" }, { "name": "GridRowProApi", "kind": "Interface" }, + { "name": "GridRowProPrivateApi", "kind": "Interface" }, { "name": "GridRowProps", "kind": "Interface" }, { "name": "gridRowsDataRowIdToIdLookupSelector", "kind": "Variable" }, { "name": "GridRowSelectionApi", "kind": "Interface" },