diff --git a/docs/data/data-grid/aggregation/AggregationColDefAggregable.js b/docs/data/data-grid/aggregation/AggregationColDefAggregable.js index 5dd5a2410ae7..2191bfe10065 100644 --- a/docs/data/data-grid/aggregation/AggregationColDefAggregable.js +++ b/docs/data/data-grid/aggregation/AggregationColDefAggregable.js @@ -42,22 +42,22 @@ export default function AggregationColDefAggregable() { const data = useMovieData(); return ( - + + }} + experimentalFeatures={{ + private_aggregation: true, + }} + /> + ); } diff --git a/docs/data/data-grid/aggregation/AggregationColDefAggregable.tsx b/docs/data/data-grid/aggregation/AggregationColDefAggregable.tsx index a04de6abc7c8..7ffe446d2fc7 100644 --- a/docs/data/data-grid/aggregation/AggregationColDefAggregable.tsx +++ b/docs/data/data-grid/aggregation/AggregationColDefAggregable.tsx @@ -42,22 +42,22 @@ export default function AggregationColDefAggregable() { const data = useMovieData(); return ( - + + }} + experimentalFeatures={{ + private_aggregation: true, + }} + /> + ); } diff --git a/docs/data/data-grid/aggregation/AggregationColDefAggregable.tsx.preview b/docs/data/data-grid/aggregation/AggregationColDefAggregable.tsx.preview new file mode 100644 index 000000000000..7201e959aad9 --- /dev/null +++ b/docs/data/data-grid/aggregation/AggregationColDefAggregable.tsx.preview @@ -0,0 +1,15 @@ + \ No newline at end of file diff --git a/docs/data/data-grid/aggregation/AggregationControlled.js b/docs/data/data-grid/aggregation/AggregationControlled.js index 4cfd5127eec9..6b1c3f74fc0d 100644 --- a/docs/data/data-grid/aggregation/AggregationControlled.js +++ b/docs/data/data-grid/aggregation/AggregationControlled.js @@ -34,16 +34,18 @@ export default function AggregationControlled() { }); return ( - setAggregationModel(newModel)} - experimentalFeatures={{ - private_aggregation: true, - }} - /> +
+ + setAggregationModel(newModel) + } + experimentalFeatures={{ + private_aggregation: true, + }} + /> +
); } diff --git a/docs/data/data-grid/aggregation/AggregationControlled.tsx b/docs/data/data-grid/aggregation/AggregationControlled.tsx index 92f8b3f712f9..f9aa95ddb94f 100644 --- a/docs/data/data-grid/aggregation/AggregationControlled.tsx +++ b/docs/data/data-grid/aggregation/AggregationControlled.tsx @@ -39,16 +39,18 @@ export default function AggregationControlled() { }); return ( - setAggregationModel(newModel)} - experimentalFeatures={{ - private_aggregation: true, - }} - /> +
+ + setAggregationModel(newModel) + } + experimentalFeatures={{ + private_aggregation: true, + }} + /> +
); } diff --git a/docs/data/data-grid/aggregation/AggregationControlled.tsx.preview b/docs/data/data-grid/aggregation/AggregationControlled.tsx.preview index 0808305bcce7..c44eb3b67395 100644 --- a/docs/data/data-grid/aggregation/AggregationControlled.tsx.preview +++ b/docs/data/data-grid/aggregation/AggregationControlled.tsx.preview @@ -1,10 +1,10 @@ setAggregationModel(newModel)} + private_onAggregationModelChange={(newModel) => + setAggregationModel(newModel) + } experimentalFeatures={{ private_aggregation: true, }} diff --git a/docs/data/data-grid/aggregation/AggregationCustomFunction.js b/docs/data/data-grid/aggregation/AggregationCustomFunction.js index 178d17637fdd..1223eb1c1260 100644 --- a/docs/data/data-grid/aggregation/AggregationCustomFunction.js +++ b/docs/data/data-grid/aggregation/AggregationCustomFunction.js @@ -72,26 +72,26 @@ export default function AggregationCustomFunction() { const data = useMovieData(); return ( - + + }} + experimentalFeatures={{ + private_aggregation: true, + }} + /> + ); } diff --git a/docs/data/data-grid/aggregation/AggregationCustomFunction.tsx b/docs/data/data-grid/aggregation/AggregationCustomFunction.tsx index fa4b05ede701..d77d6a5b3c63 100644 --- a/docs/data/data-grid/aggregation/AggregationCustomFunction.tsx +++ b/docs/data/data-grid/aggregation/AggregationCustomFunction.tsx @@ -75,26 +75,26 @@ export default function AggregationCustomFunction() { const data = useMovieData(); return ( - + + }} + experimentalFeatures={{ + private_aggregation: true, + }} + /> + ); } diff --git a/docs/data/data-grid/aggregation/AggregationFiltering.js b/docs/data/data-grid/aggregation/AggregationFiltering.js index a07d2b5fdb3a..9b7285f6ef5d 100644 --- a/docs/data/data-grid/aggregation/AggregationFiltering.js +++ b/docs/data/data-grid/aggregation/AggregationFiltering.js @@ -30,29 +30,29 @@ export default function AggregationFiltering() { const data = useMovieData(); return ( - + + }} + private_aggregationRowsScope="all" + experimentalFeatures={{ + private_aggregation: true, + }} + /> + ); } diff --git a/docs/data/data-grid/aggregation/AggregationFiltering.tsx b/docs/data/data-grid/aggregation/AggregationFiltering.tsx index e2e4e1d8ead6..c76396bf21e6 100644 --- a/docs/data/data-grid/aggregation/AggregationFiltering.tsx +++ b/docs/data/data-grid/aggregation/AggregationFiltering.tsx @@ -30,29 +30,29 @@ export default function AggregationFiltering() { const data = useMovieData(); return ( - + + }} + private_aggregationRowsScope="all" + experimentalFeatures={{ + private_aggregation: true, + }} + /> + ); } diff --git a/docs/data/data-grid/aggregation/AggregationInitialState.js b/docs/data/data-grid/aggregation/AggregationInitialState.js index d949103bcec7..36570719e305 100644 --- a/docs/data/data-grid/aggregation/AggregationInitialState.js +++ b/docs/data/data-grid/aggregation/AggregationInitialState.js @@ -30,21 +30,21 @@ export default function AggregationInitialState() { const data = useMovieData(); return ( - + + }} + experimentalFeatures={{ + private_aggregation: true, + }} + /> + ); } diff --git a/docs/data/data-grid/aggregation/AggregationInitialState.tsx b/docs/data/data-grid/aggregation/AggregationInitialState.tsx index 723f24885852..2e416402a354 100644 --- a/docs/data/data-grid/aggregation/AggregationInitialState.tsx +++ b/docs/data/data-grid/aggregation/AggregationInitialState.tsx @@ -30,21 +30,21 @@ export default function AggregationInitialState() { const data = useMovieData(); return ( - + + }} + experimentalFeatures={{ + private_aggregation: true, + }} + /> + ); } diff --git a/docs/data/data-grid/aggregation/AggregationInitialState.tsx.preview b/docs/data/data-grid/aggregation/AggregationInitialState.tsx.preview index c544a4019d9d..a8479ccf49d5 100644 --- a/docs/data/data-grid/aggregation/AggregationInitialState.tsx.preview +++ b/docs/data/data-grid/aggregation/AggregationInitialState.tsx.preview @@ -1,7 +1,5 @@ name !== 'sum', - ), - )} - initialState={{ - private_aggregation: { - model: { - gross: 'max', +
+ name !== 'sum', + ), + )} + initialState={{ + private_aggregation: { + model: { + gross: 'max', + }, }, - }, - }} - experimentalFeatures={{ - private_aggregation: true, - }} - /> + }} + experimentalFeatures={{ + private_aggregation: true, + }} + /> +
); } diff --git a/docs/data/data-grid/aggregation/AggregationRemoveFunctionAllColumns.tsx b/docs/data/data-grid/aggregation/AggregationRemoveFunctionAllColumns.tsx index 70a43b3234f4..239f04f14d4b 100644 --- a/docs/data/data-grid/aggregation/AggregationRemoveFunctionAllColumns.tsx +++ b/docs/data/data-grid/aggregation/AggregationRemoveFunctionAllColumns.tsx @@ -34,26 +34,26 @@ export default function AggregationRemoveFunctionAllColumns() { const data = useMovieData(); return ( - name !== 'sum', - ), - )} - initialState={{ - private_aggregation: { - model: { - gross: 'max', +
+ name !== 'sum', + ), + )} + initialState={{ + private_aggregation: { + model: { + gross: 'max', + }, }, - }, - }} - experimentalFeatures={{ - private_aggregation: true, - }} - /> + }} + experimentalFeatures={{ + private_aggregation: true, + }} + /> +
); } diff --git a/docs/data/data-grid/aggregation/AggregationRemoveFunctionOneColumn.js b/docs/data/data-grid/aggregation/AggregationRemoveFunctionOneColumn.js index ecd5a3c8e0bc..03c9b95e11a3 100644 --- a/docs/data/data-grid/aggregation/AggregationRemoveFunctionOneColumn.js +++ b/docs/data/data-grid/aggregation/AggregationRemoveFunctionOneColumn.js @@ -36,22 +36,22 @@ export default function AggregationRemoveFunctionOneColumn() { const data = useMovieData(); return ( - + + }} + experimentalFeatures={{ + private_aggregation: true, + }} + /> + ); } diff --git a/docs/data/data-grid/aggregation/AggregationRemoveFunctionOneColumn.tsx b/docs/data/data-grid/aggregation/AggregationRemoveFunctionOneColumn.tsx index b012b22360e4..38feb3c4f778 100644 --- a/docs/data/data-grid/aggregation/AggregationRemoveFunctionOneColumn.tsx +++ b/docs/data/data-grid/aggregation/AggregationRemoveFunctionOneColumn.tsx @@ -36,22 +36,22 @@ export default function AggregationRemoveFunctionOneColumn() { const data = useMovieData(); return ( - + + }} + experimentalFeatures={{ + private_aggregation: true, + }} + /> + ); } diff --git a/docs/data/data-grid/aggregation/AggregationRemoveFunctionOneColumn.tsx.preview b/docs/data/data-grid/aggregation/AggregationRemoveFunctionOneColumn.tsx.preview new file mode 100644 index 000000000000..c4f6e5c458d3 --- /dev/null +++ b/docs/data/data-grid/aggregation/AggregationRemoveFunctionOneColumn.tsx.preview @@ -0,0 +1,15 @@ + \ No newline at end of file diff --git a/docs/data/data-grid/aggregation/AggregationRenderCell.js b/docs/data/data-grid/aggregation/AggregationRenderCell.js index 5c2590305a26..a6f4bd5c8fc2 100644 --- a/docs/data/data-grid/aggregation/AggregationRenderCell.js +++ b/docs/data/data-grid/aggregation/AggregationRenderCell.js @@ -34,27 +34,25 @@ export default function AggregationRenderCell() { // We take movies with the highest and lowest rating to have a visual difference const rows = React.useMemo(() => { - const sortedRows = [...data.rows].sort((a, b) => b.imdbRating - a.imdbRating); - - return [...sortedRows.slice(0, 2), ...sortedRows.slice(-1)]; + return [...data.rows].sort((a, b) => b.imdbRating - a.imdbRating); }, [data.rows]); return ( - + + }} + experimentalFeatures={{ + private_aggregation: true, + }} + /> + ); } diff --git a/docs/data/data-grid/aggregation/AggregationRenderCell.tsx b/docs/data/data-grid/aggregation/AggregationRenderCell.tsx index 932976faff40..9a3d79c9193a 100644 --- a/docs/data/data-grid/aggregation/AggregationRenderCell.tsx +++ b/docs/data/data-grid/aggregation/AggregationRenderCell.tsx @@ -34,27 +34,25 @@ export default function AggregationRenderCell() { // We take movies with the highest and lowest rating to have a visual difference const rows = React.useMemo(() => { - const sortedRows = [...data.rows].sort((a, b) => b.imdbRating - a.imdbRating); - - return [...sortedRows.slice(0, 2), ...sortedRows.slice(-1)]; + return [...data.rows].sort((a, b) => b.imdbRating - a.imdbRating); }, [data.rows]); return ( - + + }} + experimentalFeatures={{ + private_aggregation: true, + }} + /> + ); } diff --git a/docs/data/data-grid/aggregation/AggregationRenderCell.tsx.preview b/docs/data/data-grid/aggregation/AggregationRenderCell.tsx.preview index 394040be23d0..0e6241af7af3 100644 --- a/docs/data/data-grid/aggregation/AggregationRenderCell.tsx.preview +++ b/docs/data/data-grid/aggregation/AggregationRenderCell.tsx.preview @@ -1,7 +1,5 @@ + + }} + experimentalFeatures={{ + private_aggregation: true, + }} + /> + ); } diff --git a/docs/data/data-grid/aggregation/AggregationValueFormatter.tsx b/docs/data/data-grid/aggregation/AggregationValueFormatter.tsx index db920e9537ed..3ac1457773f0 100644 --- a/docs/data/data-grid/aggregation/AggregationValueFormatter.tsx +++ b/docs/data/data-grid/aggregation/AggregationValueFormatter.tsx @@ -56,25 +56,25 @@ export default function AggregationValueFormatter() { const data = useMovieData(); return ( - + + }} + experimentalFeatures={{ + private_aggregation: true, + }} + /> + ); } diff --git a/docs/data/data-grid/aggregation/aggregation-next.md b/docs/data/data-grid/aggregation/aggregation-next.md index 32bbf7a92a1c..cecf9c3bde15 100644 --- a/docs/data/data-grid/aggregation/aggregation-next.md +++ b/docs/data/data-grid/aggregation/aggregation-next.md @@ -19,10 +19,6 @@ This feature is experimental, it needs to be explicitly activated using the `agg ::: -:::warning -The footer row will be pinned at the bottom of the grid once [#1251](https://github.com/mui/mui-x/issues/1251) is ready. -::: - {{"demo": "AggregationInitialState.js", "bg": "inline", "defaultCodeOpen": false}} ## Pass aggregation to the grid diff --git a/docs/data/data-grid/getting-started/getting-started.md b/docs/data/data-grid/getting-started/getting-started.md index 3f3dd6117503..a7ba597f69cf 100644 --- a/docs/data/data-grid/getting-started/getting-started.md +++ b/docs/data/data-grid/getting-started/getting-started.md @@ -171,7 +171,7 @@ The enterprise components come in two plans: Pro and Premium. | [Row height](/x/react-data-grid/rows/#row-height) | ✅ | ✅ | ✅ | | [Row spanning](/x/react-data-grid/rows/#row-spanning) | 🚧 | 🚧 | 🚧 | | [Row reordering](/x/react-data-grid/rows/#row-reorder) | ❌ | ✅ | ✅ | -| [Row pinning](/x/react-data-grid/rows/#row-pinning) | ❌ | 🚧 | 🚧 | +| [Row pinning](/x/react-data-grid/rows/#row-pinning) | ❌ | ✅ | ✅ | | **Selection** | | | | | [Single row selection](/x/react-data-grid/selection/#single-row-selection) | ✅ | ✅ | ✅ | | [Checkbox selection](/x/react-data-grid/selection/#checkbox-selection) | ✅ | ✅ | ✅ | diff --git a/docs/data/data-grid/overview/overview.md b/docs/data/data-grid/overview/overview.md index c2cfde309a9e..3cd2ab48e92e 100644 --- a/docs/data/data-grid/overview/overview.md +++ b/docs/data/data-grid/overview/overview.md @@ -88,6 +88,7 @@ Please see [the Licensing page](/x/introduction/licensing/) for details. - Server-side data - [Column hiding](/x/react-data-grid/column-visibility/) - [Column pinning](/x/react-data-grid/column-pinning/) +- [Row pinning](/x/react-data-grid/rows/#row-pinning) - [Accessible](/x/react-data-grid/accessibility/) - [Localization](/x/react-data-grid/localization/) diff --git a/docs/data/data-grid/rows/RowPinning.js b/docs/data/data-grid/rows/RowPinning.js new file mode 100644 index 000000000000..0a189a7b85e4 --- /dev/null +++ b/docs/data/data-grid/rows/RowPinning.js @@ -0,0 +1,53 @@ +import * as React from 'react'; +import { DataGridPro } from '@mui/x-data-grid-pro'; +import { + randomCity, + randomEmail, + randomId, + randomInt, + randomTraderName, + randomUserName, +} from '@mui/x-data-grid-generator'; + +const columns = [ + { field: 'name', headerName: 'Name', width: 150 }, + { field: 'city', headerName: 'City', width: 150 }, + { field: 'username', headerName: 'Username' }, + { field: 'email', headerName: 'Email', width: 200 }, + { field: 'age', type: 'number', headerName: 'Age' }, +]; + +const rows = []; + +function getRow() { + return { + id: randomId(), + name: randomTraderName(), + city: randomCity(), + username: randomUserName(), + email: randomEmail(), + age: randomInt(10, 80), + }; +} + +for (let i = 0; i < 10; i += 1) { + rows.push(getRow()); +} + +const pinnedRows = { + top: [getRow(), getRow()], + bottom: [getRow()], +}; + +export default function RowPinning() { + return ( +
+ +
+ ); +} diff --git a/docs/data/data-grid/rows/RowPinning.tsx b/docs/data/data-grid/rows/RowPinning.tsx new file mode 100644 index 000000000000..2269a6dce879 --- /dev/null +++ b/docs/data/data-grid/rows/RowPinning.tsx @@ -0,0 +1,53 @@ +import * as React from 'react'; +import { DataGridPro, GridPinnedRowsProp } from '@mui/x-data-grid-pro'; +import { + randomCity, + randomEmail, + randomId, + randomInt, + randomTraderName, + randomUserName, +} from '@mui/x-data-grid-generator'; + +const columns = [ + { field: 'name', headerName: 'Name', width: 150 }, + { field: 'city', headerName: 'City', width: 150 }, + { field: 'username', headerName: 'Username' }, + { field: 'email', headerName: 'Email', width: 200 }, + { field: 'age', type: 'number', headerName: 'Age' }, +]; + +const rows: object[] = []; + +function getRow() { + return { + id: randomId(), + name: randomTraderName(), + city: randomCity(), + username: randomUserName(), + email: randomEmail(), + age: randomInt(10, 80), + }; +} + +for (let i = 0; i < 10; i += 1) { + rows.push(getRow()); +} + +const pinnedRows: GridPinnedRowsProp = { + top: [getRow(), getRow()], + bottom: [getRow()], +}; + +export default function RowPinning() { + return ( +
+ +
+ ); +} diff --git a/docs/data/data-grid/rows/RowPinning.tsx.preview b/docs/data/data-grid/rows/RowPinning.tsx.preview new file mode 100644 index 000000000000..a96f72064f95 --- /dev/null +++ b/docs/data/data-grid/rows/RowPinning.tsx.preview @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/docs/data/data-grid/rows/RowPinningWithActions.js b/docs/data/data-grid/rows/RowPinningWithActions.js new file mode 100644 index 000000000000..387a6343ee33 --- /dev/null +++ b/docs/data/data-grid/rows/RowPinningWithActions.js @@ -0,0 +1,140 @@ +import * as React from 'react'; +import { DataGridPro, GridActionsCellItem } from '@mui/x-data-grid-pro'; +import ArrowUpIcon from '@mui/icons-material/ArrowUpward'; +import ArrowDownIcon from '@mui/icons-material/ArrowDownward'; +import Tooltip from '@mui/material/Tooltip'; +import { + randomId, + randomTraderName, + randomCity, + randomUserName, + randomEmail, +} from '@mui/x-data-grid-generator'; + +const data = []; + +function getRow() { + return { + id: randomId(), + name: randomTraderName(), + city: randomCity(), + username: randomUserName(), + email: randomEmail(), + }; +} + +for (let i = 0; i < 20; i += 1) { + data.push(getRow()); +} + +export default function RowPinningWithActions() { + const [pinnedRowsIds, setPinnedRowsIds] = React.useState({ + top: [], + bottom: [], + }); + + const { rows, pinnedRows } = React.useMemo(() => { + const rowsData = []; + const pinnedRowsData = { + top: [], + bottom: [], + }; + + data.forEach((row) => { + if (pinnedRowsIds.top.includes(row.id)) { + pinnedRowsData.top.push(row); + } else if (pinnedRowsIds.bottom.includes(row.id)) { + pinnedRowsData.bottom.push(row); + } else { + rowsData.push(row); + } + }); + + return { + rows: rowsData, + pinnedRows: pinnedRowsData, + }; + }, [pinnedRowsIds]); + + const columns = React.useMemo( + () => [ + { + field: 'actions', + type: 'actions', + width: 100, + getActions: (params) => { + const isPinnedTop = pinnedRowsIds.top.includes(params.id); + const isPinnedBottom = pinnedRowsIds.bottom.includes(params.id); + if (isPinnedTop || isPinnedBottom) { + return [ + + {isPinnedTop ? : } + + } + onClick={() => + setPinnedRowsIds((prevPinnedRowsIds) => ({ + top: prevPinnedRowsIds.top.filter( + (rowId) => rowId !== params.id, + ), + bottom: prevPinnedRowsIds.bottom.filter( + (rowId) => rowId !== params.id, + ), + })) + } + />, + ]; + } + return [ + + + + } + label="Pin at the top" + onClick={() => + setPinnedRowsIds((prevPinnedRowsIds) => ({ + ...prevPinnedRowsIds, + top: [...prevPinnedRowsIds.top, params.id], + })) + } + />, + + + + } + label="Pin at the bottom" + onClick={() => + setPinnedRowsIds((prevPinnedRowsIds) => ({ + ...prevPinnedRowsIds, + bottom: [...prevPinnedRowsIds.bottom, params.id], + })) + } + />, + ]; + }, + }, + { field: 'name', headerName: 'Name', width: 150 }, + { field: 'city', headerName: 'City', width: 150 }, + { field: 'username', headerName: 'Username' }, + { field: 'email', headerName: 'Email', width: 200 }, + ], + [pinnedRowsIds], + ); + + return ( +
+ +
+ ); +} diff --git a/docs/data/data-grid/rows/RowPinningWithActions.tsx b/docs/data/data-grid/rows/RowPinningWithActions.tsx new file mode 100644 index 000000000000..36f42d4bbfa5 --- /dev/null +++ b/docs/data/data-grid/rows/RowPinningWithActions.tsx @@ -0,0 +1,149 @@ +import * as React from 'react'; +import { + DataGridPro, + GridRowModel, + GridActionsCellItem, + GridColumns, + GridRowId, +} from '@mui/x-data-grid-pro'; +import ArrowUpIcon from '@mui/icons-material/ArrowUpward'; +import ArrowDownIcon from '@mui/icons-material/ArrowDownward'; +import Tooltip from '@mui/material/Tooltip'; +import { + randomId, + randomTraderName, + randomCity, + randomUserName, + randomEmail, +} from '@mui/x-data-grid-generator'; + +const data: GridRowModel[] = []; + +function getRow() { + return { + id: randomId(), + name: randomTraderName(), + city: randomCity(), + username: randomUserName(), + email: randomEmail(), + }; +} + +for (let i = 0; i < 20; i += 1) { + data.push(getRow()); +} + +export default function RowPinningWithActions() { + const [pinnedRowsIds, setPinnedRowsIds] = React.useState<{ + top: GridRowId[]; + bottom: GridRowId[]; + }>({ + top: [], + bottom: [], + }); + + const { rows, pinnedRows } = React.useMemo(() => { + const rowsData: GridRowModel[] = []; + const pinnedRowsData: { top: GridRowModel[]; bottom: GridRowModel[] } = { + top: [], + bottom: [], + }; + + data.forEach((row) => { + if (pinnedRowsIds.top.includes(row.id)) { + pinnedRowsData.top.push(row); + } else if (pinnedRowsIds.bottom.includes(row.id)) { + pinnedRowsData.bottom.push(row); + } else { + rowsData.push(row); + } + }); + + return { + rows: rowsData, + pinnedRows: pinnedRowsData, + }; + }, [pinnedRowsIds]); + + const columns = React.useMemo>( + () => [ + { + field: 'actions', + type: 'actions', + width: 100, + getActions: (params) => { + const isPinnedTop = pinnedRowsIds.top.includes(params.id); + const isPinnedBottom = pinnedRowsIds.bottom.includes(params.id); + if (isPinnedTop || isPinnedBottom) { + return [ + + {isPinnedTop ? : } + + } + onClick={() => + setPinnedRowsIds((prevPinnedRowsIds) => ({ + top: prevPinnedRowsIds.top.filter( + (rowId) => rowId !== params.id, + ), + bottom: prevPinnedRowsIds.bottom.filter( + (rowId) => rowId !== params.id, + ), + })) + } + />, + ]; + } + return [ + + + + } + label="Pin at the top" + onClick={() => + setPinnedRowsIds((prevPinnedRowsIds) => ({ + ...prevPinnedRowsIds, + top: [...prevPinnedRowsIds.top, params.id], + })) + } + />, + + + + } + label="Pin at the bottom" + onClick={() => + setPinnedRowsIds((prevPinnedRowsIds) => ({ + ...prevPinnedRowsIds, + bottom: [...prevPinnedRowsIds.bottom, params.id], + })) + } + />, + ]; + }, + }, + { field: 'name', headerName: 'Name', width: 150 }, + { field: 'city', headerName: 'City', width: 150 }, + { field: 'username', headerName: 'Username' }, + { field: 'email', headerName: 'Email', width: 200 }, + ], + [pinnedRowsIds], + ); + + return ( +
+ +
+ ); +} diff --git a/docs/data/data-grid/rows/RowPinningWithActions.tsx.preview b/docs/data/data-grid/rows/RowPinningWithActions.tsx.preview new file mode 100644 index 000000000000..3bacf37f5571 --- /dev/null +++ b/docs/data/data-grid/rows/RowPinningWithActions.tsx.preview @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/docs/data/data-grid/rows/RowPinningWithPagination.js b/docs/data/data-grid/rows/RowPinningWithPagination.js new file mode 100644 index 000000000000..7e21652a4508 --- /dev/null +++ b/docs/data/data-grid/rows/RowPinningWithPagination.js @@ -0,0 +1,43 @@ +import * as React from 'react'; +import { DataGridPro } from '@mui/x-data-grid-pro'; +import { useDemoData } from '@mui/x-data-grid-generator/'; + +export default function RowPinningWithPagination() { + const { data } = useDemoData({ + dataSet: 'Commodity', + rowLength: 100, + maxColumns: 20, + }); + + const rowsData = React.useMemo(() => { + if (!data.rows || data.rows.length === 0) { + return { rows: data.rows }; + } + const [firstRow, secondRow, thirdRow, ...rows] = data.rows; + return { + rows, + pinnedRows: { + top: [firstRow], + bottom: [secondRow, thirdRow], + }, + }; + }, [data.rows]); + + return ( +
+ +
+ ); +} diff --git a/docs/data/data-grid/rows/RowPinningWithPagination.tsx b/docs/data/data-grid/rows/RowPinningWithPagination.tsx new file mode 100644 index 000000000000..7e21652a4508 --- /dev/null +++ b/docs/data/data-grid/rows/RowPinningWithPagination.tsx @@ -0,0 +1,43 @@ +import * as React from 'react'; +import { DataGridPro } from '@mui/x-data-grid-pro'; +import { useDemoData } from '@mui/x-data-grid-generator/'; + +export default function RowPinningWithPagination() { + const { data } = useDemoData({ + dataSet: 'Commodity', + rowLength: 100, + maxColumns: 20, + }); + + const rowsData = React.useMemo(() => { + if (!data.rows || data.rows.length === 0) { + return { rows: data.rows }; + } + const [firstRow, secondRow, thirdRow, ...rows] = data.rows; + return { + rows, + pinnedRows: { + top: [firstRow], + bottom: [secondRow, thirdRow], + }, + }; + }, [data.rows]); + + return ( +
+ +
+ ); +} diff --git a/docs/data/data-grid/rows/RowPinningWithPagination.tsx.preview b/docs/data/data-grid/rows/RowPinningWithPagination.tsx.preview new file mode 100644 index 000000000000..b671f2f9c1fc --- /dev/null +++ b/docs/data/data-grid/rows/RowPinningWithPagination.tsx.preview @@ -0,0 +1,13 @@ + \ No newline at end of file diff --git a/docs/data/data-grid/rows/rows.md b/docs/data/data-grid/rows/rows.md index acd6724a669c..ef9abd5bf801 100644 --- a/docs/data/data-grid/rows/rows.md +++ b/docs/data/data-grid/rows/rows.md @@ -279,28 +279,83 @@ For now, row reordering is disabled if sorting is applied to the grid. In addition, if row grouping or tree data is being used, the row reordering is also disabled. ::: -## 🚧 Row spanning +## Row pinning [](https://mui.com/store/items/material-ui-pro/) + +Pinned (or frozen, locked or floating) rows are those visible at all times while the user scrolls the grid vertically. :::warning -This feature isn't implemented yet. It's coming. +This feature is experimental, it needs to be explicitly activated using the `rowPinning` experimental feature flag. + +```tsx + +``` -👍 Upvote [issue #207](https://github.com/mui/mui-x/issues/207) if you want to see it land faster. ::: -Each cell takes up the width of one row. -Row spanning allows to change this default behavior. -It allows cells to span multiple rows. -This is very close to the "row spanning" in an HTML ``. +You can pin rows at the top or bottom of the grid by passing pinned rows data through the `pinnedRows` prop: + +```tsx +const pinnedRows: GridPinnedRowsProp = { + top: [{ id: 0, brand: 'Nike' }], + bottom: [ + { id: 1, brand: 'Adidas' }, + { id: 2, brand: 'Puma' }, + ], +}; + +; +``` + +The data format for pinned rows is the same as for the `rows` prop (see [Feeding data](/x/react-data-grid/rows/#feeding-data)). + +Pinned rows data should also meet [Row identifier](/x/react-data-grid/rows/#row-identifier) requirements. + +{{"demo": "RowPinning.js", "disableAd": true, "bg": "inline"}} + +:::warning +Just like the `rows` prop, `pinnedRows` prop should keep the same reference between two renders. +Otherwise, the grid will re-apply heavy work like sorting and filtering. +::: + +### Controlling pinned rows + +You can control which rows are pinned by changing `pinnedRows`. + +In the demo below we use `actions` column type to add buttons to pin a row either at the top or bottom and change `pinnedRows` prop dynamically. -## 🚧 Row pinning [](https://mui.com/store/items/mui-x-pro/) +{{"demo": "RowPinningWithActions.js", "disableAd": true, "bg": "inline", "defaultCodeOpen": false}} + +### Usage with other features + +Pinned rows are not affected by sorting and filtering. + +Pagination does not impact pinned rows as well - they stay pinned regardless the page number or page size. + +{{"demo": "RowPinningWithPagination.js", "disableAd": true, "bg": "inline", "defaultCodeOpen": false}} + +:::info +Pinned rows do not support the following features: + +- editing ([issue #5591](https://github.com/mui/mui-x/issues/5591)) +- selection +- row grouping +- tree data +- row reordering +- master detail + ::: + +## 🚧 Row spanning :::warning This feature isn't implemented yet. It's coming. -👍 Upvote [issue #1251](https://github.com/mui/mui-x/issues/1251) if you want to see it land faster. +👍 Upvote [issue #207](https://github.com/mui/mui-x/issues/207) if you want to see it land faster. ::: -Pinned (or frozen, locked, or sticky) rows are rows that are visible at all times while the user scrolls the grid vertically. +Each cell takes up the width of one row. +Row spanning allows to change this default behavior. +It allows cells to span multiple rows. +This is very close to the "row spanning" in an HTML `
`. ## API diff --git a/docs/pages/x/api/data-grid/data-grid-premium.json b/docs/pages/x/api/data-grid/data-grid-premium.json index d830efde804e..a63f4bfe8e50 100644 --- a/docs/pages/x/api/data-grid/data-grid-premium.json +++ b/docs/pages/x/api/data-grid/data-grid-premium.json @@ -60,7 +60,7 @@ "experimentalFeatures": { "type": { "name": "shape", - "description": "{ newEditingApi?: bool, preventCommitWhileValidating?: bool, private_aggregation?: bool, warnIfFocusStateIsNotSynced?: bool }" + "description": "{ newEditingApi?: bool, preventCommitWhileValidating?: bool, private_aggregation?: bool, rowPinning?: bool, warnIfFocusStateIsNotSynced?: bool }" } }, "filterMode": { @@ -179,6 +179,7 @@ "description": "{ left?: Array<string>, right?: Array<string> }" } }, + "pinnedRows": { "type": { "name": "shape", "description": "{ bottom?: array, top?: array }" } }, "processRowUpdate": { "type": { "name": "func" } }, "rowBuffer": { "type": { "name": "number" }, "default": "3" }, "rowCount": { "type": { "name": "number" } }, @@ -346,7 +347,11 @@ "treeDataGroupingCell", "treeDataGroupingCellToggle", "groupingCriteriaCell", - "groupingCriteriaCellToggle" + "groupingCriteriaCellToggle", + "pinnedRows", + "pinnedRows--top", + "pinnedRows--bottom", + "pinnedRowsRenderZone" ], "globalClasses": {}, "name": "MuiDataGrid" diff --git a/docs/pages/x/api/data-grid/data-grid-pro.json b/docs/pages/x/api/data-grid/data-grid-pro.json index 32595caa2c8d..062061ab0cfa 100644 --- a/docs/pages/x/api/data-grid/data-grid-pro.json +++ b/docs/pages/x/api/data-grid/data-grid-pro.json @@ -59,7 +59,7 @@ "experimentalFeatures": { "type": { "name": "shape", - "description": "{ newEditingApi?: bool, preventCommitWhileValidating?: bool, warnIfFocusStateIsNotSynced?: bool }" + "description": "{ newEditingApi?: bool, preventCommitWhileValidating?: bool, rowPinning?: bool, warnIfFocusStateIsNotSynced?: bool }" } }, "filterMode": { @@ -177,6 +177,7 @@ "description": "{ left?: Array<string>, right?: Array<string> }" } }, + "pinnedRows": { "type": { "name": "shape", "description": "{ bottom?: array, top?: array }" } }, "processRowUpdate": { "type": { "name": "func" } }, "rowBuffer": { "type": { "name": "number" }, "default": "3" }, "rowCount": { "type": { "name": "number" } }, @@ -413,7 +414,11 @@ "treeDataGroupingCell", "treeDataGroupingCellToggle", "groupingCriteriaCell", - "groupingCriteriaCellToggle" + "groupingCriteriaCellToggle", + "pinnedRows", + "pinnedRows--top", + "pinnedRows--bottom", + "pinnedRowsRenderZone" ], "globalClasses": {}, "name": "MuiDataGrid" diff --git a/docs/pages/x/api/data-grid/data-grid.json b/docs/pages/x/api/data-grid/data-grid.json index 52c78a7830c3..1759807ad9b5 100644 --- a/docs/pages/x/api/data-grid/data-grid.json +++ b/docs/pages/x/api/data-grid/data-grid.json @@ -368,7 +368,11 @@ "treeDataGroupingCell", "treeDataGroupingCellToggle", "groupingCriteriaCell", - "groupingCriteriaCellToggle" + "groupingCriteriaCellToggle", + "pinnedRows", + "pinnedRows--top", + "pinnedRows--bottom", + "pinnedRowsRenderZone" ], "globalClasses": {}, "name": "MuiDataGrid" diff --git a/docs/pages/x/api/data-grid/grid-api.md b/docs/pages/x/api/data-grid/grid-api.md index 26e9d5428f10..520a4b0ec1e9 100644 --- a/docs/pages/x/api/data-grid/grid-api.md +++ b/docs/pages/x/api/data-grid/grid-api.md @@ -117,6 +117,7 @@ import { GridApi } from '@mui/x-data-grid-pro'; | toggleColumnMenu | (field: string) => void | Toggles the column menu under the `field` column. | | toggleDetailPanel [](https://mui.com/store/items/mui-x-pro/) | (id: GridRowId) => void | Expands or collapses the detail panel of a row. | | unpinColumn [](https://mui.com/store/items/mui-x-pro/) | (field: string) => void | Unpins a column. | +| unstable_setPinnedRows [](https://mui.com/store/items/mui-x-pro/) | (pinnedRows?: GridPinnedRowsProp) => void | Changes the pinned rows. | | updateColumn | (col: GridColDef) => void | Updates the definition of a column. | | updateColumns | (cols: GridColDef[]) => void | Updates the definition of multiple columns at the same time. | | updateRows | (updates: GridRowModelUpdate[]) => void | Allows to updates, insert and delete rows in a single call. | diff --git a/docs/pages/x/api/data-grid/selectors.json b/docs/pages/x/api/data-grid/selectors.json index 28ed1dab21e6..1f0955ae154c 100644 --- a/docs/pages/x/api/data-grid/selectors.json +++ b/docs/pages/x/api/data-grid/selectors.json @@ -240,78 +240,24 @@ "description": "", "supportsApiRef": true }, - { - "name": "gridRowCountSelector", - "returnType": "number", - "description": "", - "supportsApiRef": true - }, { "name": "gridRowGroupingModelSelector", "returnType": "GridRowGroupingModel", "description": "", "supportsApiRef": true }, - { - "name": "gridRowGroupingNameSelector", - "returnType": "string", - "description": "", - "supportsApiRef": true - }, { "name": "gridRowGroupingSanitizedModelSelector", "returnType": "string[]", "description": "", "supportsApiRef": true }, - { - "name": "gridRowIdsSelector", - "returnType": "GridRowId[]", - "description": "", - "supportsApiRef": true - }, - { - "name": "gridRowTreeDepthSelector", - "returnType": "number", - "description": "", - "supportsApiRef": true - }, - { - "name": "gridRowTreeSelector", - "returnType": "GridRowTreeConfig", - "description": "", - "supportsApiRef": true - }, - { - "name": "gridRowsIdToIdLookupSelector", - "returnType": "Record", - "description": "", - "supportsApiRef": true - }, - { - "name": "gridRowsLoadingSelector", - "returnType": "boolean | undefined", - "description": "", - "supportsApiRef": true - }, - { - "name": "gridRowsLookupSelector", - "returnType": "GridRowsLookup", - "description": "", - "supportsApiRef": true - }, { "name": "gridRowsMetaSelector", "returnType": "GridRowsMetaState", "description": "", "supportsApiRef": false }, - { - "name": "gridRowsStateSelector", - "returnType": "GridRowsState", - "description": "", - "supportsApiRef": false - }, { "name": "gridSelectionStateSelector", "returnType": "GridSelectionModel", @@ -357,12 +303,6 @@ "description": "", "supportsApiRef": false }, - { - "name": "gridTopLevelRowCountSelector", - "returnType": "number", - "description": "", - "supportsApiRef": true - }, { "name": "gridVisibleColumnDefinitionsSelector", "returnType": "GridStateColDef[]", diff --git a/docs/translations/api-docs/data-grid/data-grid-premium-pt.json b/docs/translations/api-docs/data-grid/data-grid-premium-pt.json index 90e0b3aed6d7..5392d947b5b6 100644 --- a/docs/translations/api-docs/data-grid/data-grid-premium-pt.json +++ b/docs/translations/api-docs/data-grid/data-grid-premium-pt.json @@ -118,6 +118,7 @@ "pagination": "If true, pagination is enabled.", "paginationMode": "Pagination can be processed on the server or client-side. Set it to 'client' if you would like to handle the pagination on the client-side. Set it to 'server' if you would like to handle the pagination on the server-side.", "pinnedColumns": "The column fields to display pinned to left or right.", + "pinnedRows": "Rows data to pin on top or bottom.", "processRowUpdate": "Callback called before updating a row with new values in the row and cell editing. Only applied if props.experimentalFeatures.newEditingApi: true.

Signature:
function(newRow: R, oldRow: R) => Promise<R> | R
newRow: Row object with the new values.
oldRow: Row object with the old values.
returns (Promise | R): The final values to update the row.", "rowBuffer": "Number of extra rows to be rendered before/after the visible slice.", "rowCount": "Set the total number of rows, if it is different from the length of the value rows prop. If some rows have children (for instance in the tree data), this number represents the amount of top level rows.", @@ -599,6 +600,22 @@ }, "groupingCriteriaCellToggle": { "description": "Styles applied to the toggle of the grouping criteria cell" + }, + "pinnedRows": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the pinned rows container" + }, + "pinnedRows--top": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the top pinned rows container" + }, + "pinnedRows--bottom": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the bottom pinned rows container" + }, + "pinnedRowsRenderZone": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "pinned rows render zones" } }, "slotDescriptions": {} diff --git a/docs/translations/api-docs/data-grid/data-grid-premium-zh.json b/docs/translations/api-docs/data-grid/data-grid-premium-zh.json index 90e0b3aed6d7..5392d947b5b6 100644 --- a/docs/translations/api-docs/data-grid/data-grid-premium-zh.json +++ b/docs/translations/api-docs/data-grid/data-grid-premium-zh.json @@ -118,6 +118,7 @@ "pagination": "If true, pagination is enabled.", "paginationMode": "Pagination can be processed on the server or client-side. Set it to 'client' if you would like to handle the pagination on the client-side. Set it to 'server' if you would like to handle the pagination on the server-side.", "pinnedColumns": "The column fields to display pinned to left or right.", + "pinnedRows": "Rows data to pin on top or bottom.", "processRowUpdate": "Callback called before updating a row with new values in the row and cell editing. Only applied if props.experimentalFeatures.newEditingApi: true.

Signature:
function(newRow: R, oldRow: R) => Promise<R> | R
newRow: Row object with the new values.
oldRow: Row object with the old values.
returns (Promise | R): The final values to update the row.", "rowBuffer": "Number of extra rows to be rendered before/after the visible slice.", "rowCount": "Set the total number of rows, if it is different from the length of the value rows prop. If some rows have children (for instance in the tree data), this number represents the amount of top level rows.", @@ -599,6 +600,22 @@ }, "groupingCriteriaCellToggle": { "description": "Styles applied to the toggle of the grouping criteria cell" + }, + "pinnedRows": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the pinned rows container" + }, + "pinnedRows--top": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the top pinned rows container" + }, + "pinnedRows--bottom": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the bottom pinned rows container" + }, + "pinnedRowsRenderZone": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "pinned rows render zones" } }, "slotDescriptions": {} diff --git a/docs/translations/api-docs/data-grid/data-grid-premium.json b/docs/translations/api-docs/data-grid/data-grid-premium.json index 90e0b3aed6d7..5392d947b5b6 100644 --- a/docs/translations/api-docs/data-grid/data-grid-premium.json +++ b/docs/translations/api-docs/data-grid/data-grid-premium.json @@ -118,6 +118,7 @@ "pagination": "If true, pagination is enabled.", "paginationMode": "Pagination can be processed on the server or client-side. Set it to 'client' if you would like to handle the pagination on the client-side. Set it to 'server' if you would like to handle the pagination on the server-side.", "pinnedColumns": "The column fields to display pinned to left or right.", + "pinnedRows": "Rows data to pin on top or bottom.", "processRowUpdate": "Callback called before updating a row with new values in the row and cell editing. Only applied if props.experimentalFeatures.newEditingApi: true.

Signature:
function(newRow: R, oldRow: R) => Promise<R> | R
newRow: Row object with the new values.
oldRow: Row object with the old values.
returns (Promise | R): The final values to update the row.", "rowBuffer": "Number of extra rows to be rendered before/after the visible slice.", "rowCount": "Set the total number of rows, if it is different from the length of the value rows prop. If some rows have children (for instance in the tree data), this number represents the amount of top level rows.", @@ -599,6 +600,22 @@ }, "groupingCriteriaCellToggle": { "description": "Styles applied to the toggle of the grouping criteria cell" + }, + "pinnedRows": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the pinned rows container" + }, + "pinnedRows--top": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the top pinned rows container" + }, + "pinnedRows--bottom": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the bottom pinned rows container" + }, + "pinnedRowsRenderZone": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "pinned rows render zones" } }, "slotDescriptions": {} diff --git a/docs/translations/api-docs/data-grid/data-grid-pro-pt.json b/docs/translations/api-docs/data-grid/data-grid-pro-pt.json index 2046218941a8..afa2c83b0035 100644 --- a/docs/translations/api-docs/data-grid/data-grid-pro-pt.json +++ b/docs/translations/api-docs/data-grid/data-grid-pro-pt.json @@ -116,6 +116,7 @@ "pagination": "If true, pagination is enabled.", "paginationMode": "Pagination can be processed on the server or client-side. Set it to 'client' if you would like to handle the pagination on the client-side. Set it to 'server' if you would like to handle the pagination on the server-side.", "pinnedColumns": "The column fields to display pinned to left or right.", + "pinnedRows": "Rows data to pin on top or bottom.", "processRowUpdate": "Callback called before updating a row with new values in the row and cell editing. Only applied if props.experimentalFeatures.newEditingApi: true.

Signature:
function(newRow: R, oldRow: R) => Promise<R> | R
newRow: Row object with the new values.
oldRow: Row object with the old values.
returns (Promise | R): The final values to update the row.", "rowBuffer": "Number of extra rows to be rendered before/after the visible slice.", "rowCount": "Set the total number of rows, if it is different from the length of the value rows prop. If some rows have children (for instance in the tree data), this number represents the amount of top level rows.", @@ -595,6 +596,22 @@ }, "groupingCriteriaCellToggle": { "description": "Styles applied to the toggle of the grouping criteria cell" + }, + "pinnedRows": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the pinned rows container" + }, + "pinnedRows--top": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the top pinned rows container" + }, + "pinnedRows--bottom": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the bottom pinned rows container" + }, + "pinnedRowsRenderZone": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "pinned rows render zones" } }, "slotDescriptions": { diff --git a/docs/translations/api-docs/data-grid/data-grid-pro-zh.json b/docs/translations/api-docs/data-grid/data-grid-pro-zh.json index 2046218941a8..afa2c83b0035 100644 --- a/docs/translations/api-docs/data-grid/data-grid-pro-zh.json +++ b/docs/translations/api-docs/data-grid/data-grid-pro-zh.json @@ -116,6 +116,7 @@ "pagination": "If true, pagination is enabled.", "paginationMode": "Pagination can be processed on the server or client-side. Set it to 'client' if you would like to handle the pagination on the client-side. Set it to 'server' if you would like to handle the pagination on the server-side.", "pinnedColumns": "The column fields to display pinned to left or right.", + "pinnedRows": "Rows data to pin on top or bottom.", "processRowUpdate": "Callback called before updating a row with new values in the row and cell editing. Only applied if props.experimentalFeatures.newEditingApi: true.

Signature:
function(newRow: R, oldRow: R) => Promise<R> | R
newRow: Row object with the new values.
oldRow: Row object with the old values.
returns (Promise | R): The final values to update the row.", "rowBuffer": "Number of extra rows to be rendered before/after the visible slice.", "rowCount": "Set the total number of rows, if it is different from the length of the value rows prop. If some rows have children (for instance in the tree data), this number represents the amount of top level rows.", @@ -595,6 +596,22 @@ }, "groupingCriteriaCellToggle": { "description": "Styles applied to the toggle of the grouping criteria cell" + }, + "pinnedRows": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the pinned rows container" + }, + "pinnedRows--top": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the top pinned rows container" + }, + "pinnedRows--bottom": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the bottom pinned rows container" + }, + "pinnedRowsRenderZone": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "pinned rows render zones" } }, "slotDescriptions": { diff --git a/docs/translations/api-docs/data-grid/data-grid-pro.json b/docs/translations/api-docs/data-grid/data-grid-pro.json index 2046218941a8..afa2c83b0035 100644 --- a/docs/translations/api-docs/data-grid/data-grid-pro.json +++ b/docs/translations/api-docs/data-grid/data-grid-pro.json @@ -116,6 +116,7 @@ "pagination": "If true, pagination is enabled.", "paginationMode": "Pagination can be processed on the server or client-side. Set it to 'client' if you would like to handle the pagination on the client-side. Set it to 'server' if you would like to handle the pagination on the server-side.", "pinnedColumns": "The column fields to display pinned to left or right.", + "pinnedRows": "Rows data to pin on top or bottom.", "processRowUpdate": "Callback called before updating a row with new values in the row and cell editing. Only applied if props.experimentalFeatures.newEditingApi: true.

Signature:
function(newRow: R, oldRow: R) => Promise<R> | R
newRow: Row object with the new values.
oldRow: Row object with the old values.
returns (Promise | R): The final values to update the row.", "rowBuffer": "Number of extra rows to be rendered before/after the visible slice.", "rowCount": "Set the total number of rows, if it is different from the length of the value rows prop. If some rows have children (for instance in the tree data), this number represents the amount of top level rows.", @@ -595,6 +596,22 @@ }, "groupingCriteriaCellToggle": { "description": "Styles applied to the toggle of the grouping criteria cell" + }, + "pinnedRows": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the pinned rows container" + }, + "pinnedRows--top": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the top pinned rows container" + }, + "pinnedRows--bottom": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the bottom pinned rows container" + }, + "pinnedRowsRenderZone": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "pinned rows render zones" } }, "slotDescriptions": { diff --git a/docs/translations/api-docs/data-grid/data-grid-pt.json b/docs/translations/api-docs/data-grid/data-grid-pt.json index 70ae24a77b86..d8b2e16f6142 100644 --- a/docs/translations/api-docs/data-grid/data-grid-pt.json +++ b/docs/translations/api-docs/data-grid/data-grid-pt.json @@ -566,6 +566,22 @@ }, "groupingCriteriaCellToggle": { "description": "Styles applied to the toggle of the grouping criteria cell" + }, + "pinnedRows": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the pinned rows container" + }, + "pinnedRows--top": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the top pinned rows container" + }, + "pinnedRows--bottom": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the bottom pinned rows container" + }, + "pinnedRowsRenderZone": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "pinned rows render zones" } }, "slotDescriptions": { diff --git a/docs/translations/api-docs/data-grid/data-grid-zh.json b/docs/translations/api-docs/data-grid/data-grid-zh.json index 70ae24a77b86..d8b2e16f6142 100644 --- a/docs/translations/api-docs/data-grid/data-grid-zh.json +++ b/docs/translations/api-docs/data-grid/data-grid-zh.json @@ -566,6 +566,22 @@ }, "groupingCriteriaCellToggle": { "description": "Styles applied to the toggle of the grouping criteria cell" + }, + "pinnedRows": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the pinned rows container" + }, + "pinnedRows--top": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the top pinned rows container" + }, + "pinnedRows--bottom": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the bottom pinned rows container" + }, + "pinnedRowsRenderZone": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "pinned rows render zones" } }, "slotDescriptions": { diff --git a/docs/translations/api-docs/data-grid/data-grid.json b/docs/translations/api-docs/data-grid/data-grid.json index 70ae24a77b86..d8b2e16f6142 100644 --- a/docs/translations/api-docs/data-grid/data-grid.json +++ b/docs/translations/api-docs/data-grid/data-grid.json @@ -566,6 +566,22 @@ }, "groupingCriteriaCellToggle": { "description": "Styles applied to the toggle of the grouping criteria cell" + }, + "pinnedRows": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the pinned rows container" + }, + "pinnedRows--top": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the top pinned rows container" + }, + "pinnedRows--bottom": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the bottom pinned rows container" + }, + "pinnedRowsRenderZone": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "pinned rows render zones" } }, "slotDescriptions": { diff --git a/docsTech/processing.md b/docsTech/processing.md index 6f595e40b92d..220ed641bb91 100644 --- a/docsTech/processing.md +++ b/docsTech/processing.md @@ -103,37 +103,34 @@ useGridRegisterPipeProcessor(apiRef, 'rowHeight', addDetailHeight); **Example**: ```ts -const addTopFooterRowColumn = React.useCallback>( - (columnsState) => { - const ids = [...groupingParams.ids]; - const idRowsLookup = { ...groupingParams.idRowsLookup }; - const tree = { ...groupingParams.tree }; - - const footerId = 'auto-generated-group-footer-root'; - - ids.push(footerId); - idRowsLookup[footerId] = {}; - tree[footerId] = { - id: footerId, - isAutoGenerated: true, - parent: null, - depth: 0, - groupingKey: null, - groupingField: null, - position: 'footer', - }; - - return { - ...groupingParams, - ids, - idRowsLookup, - tree, - }; - }, - [apiRef, classes], -); - -useGridRegisterPipeProcessor(apiRef, 'hydrateColumns', updateSelectionColumn); +const addGroupFooterRows = React.useCallback>((groupingParams) => { + const ids = [...groupingParams.ids]; + const idRowsLookup = { ...groupingParams.idRowsLookup }; + const tree = { ...groupingParams.tree }; + + const footerId = 'auto-generated-group-footer-root'; + + ids.push(footerId); + idRowsLookup[footerId] = {}; + tree[footerId] = { + id: footerId, + isAutoGenerated: true, + parent: null, + depth: 0, + groupingKey: null, + groupingField: null, + position: 'footer', + }; + + return { + ...groupingParams, + ids, + idRowsLookup, + tree, + }; +}, []); + +useGridRegisterPipeProcessor(apiRef, 'hydrateRows', addGroupFooterRows); ``` ### Add custom behavior to an api method diff --git a/packages/grid/x-data-grid-premium/src/DataGridPremium/DataGridPremium.tsx b/packages/grid/x-data-grid-premium/src/DataGridPremium/DataGridPremium.tsx index cd1478c4ef59..5cc8ea560d73 100644 --- a/packages/grid/x-data-grid-premium/src/DataGridPremium/DataGridPremium.tsx +++ b/packages/grid/x-data-grid-premium/src/DataGridPremium/DataGridPremium.tsx @@ -267,6 +267,7 @@ DataGridPremiumRaw.propTypes = { newEditingApi: PropTypes.bool, preventCommitWhileValidating: PropTypes.bool, private_aggregation: PropTypes.bool, + rowPinning: PropTypes.bool, warnIfFocusStateIsNotSynced: PropTypes.bool, }), /** @@ -784,6 +785,13 @@ DataGridPremiumRaw.propTypes = { left: PropTypes.arrayOf(PropTypes.string), right: PropTypes.arrayOf(PropTypes.string), }), + /** + * Rows data to pin on top or bottom. + */ + pinnedRows: PropTypes.shape({ + bottom: PropTypes.array, + top: PropTypes.array, + }), /** * Aggregation functions available on the grid. * @default GRID_AGGREGATION_FUNCTIONS diff --git a/packages/grid/x-data-grid-premium/src/DataGridPremium/useDataGridPremiumComponent.tsx b/packages/grid/x-data-grid-premium/src/DataGridPremium/useDataGridPremiumComponent.tsx index 0629d7fb478c..1f34b5363eb8 100644 --- a/packages/grid/x-data-grid-premium/src/DataGridPremium/useDataGridPremiumComponent.tsx +++ b/packages/grid/x-data-grid-premium/src/DataGridPremium/useDataGridPremiumComponent.tsx @@ -55,6 +55,9 @@ import { useGridColumnSpanning, useGridRowReorder, useGridRowReorderPreProcessors, + useGridRowPinning, + useGridRowPinningPreProcessors, + rowPinningStateInitializer, } from '@mui/x-data-grid-pro/internals'; import { GridApiPremium } from '../models/gridApiPremium'; import { DataGridPremiumProcessedProps } from '../models/dataGridPremiumProps'; @@ -84,6 +87,7 @@ export const useDataGridPremiumComponent = ( useGridRowReorderPreProcessors(apiRef, props); useGridRowGroupingPreProcessors(apiRef, props); useGridTreeDataPreProcessors(apiRef, props); + useGridRowPinningPreProcessors(apiRef); useGridAggregationPreProcessors(apiRef, props); useGridDetailPanelPreProcessors(apiRef, props); // The column pinning `hydrateColumns` pre-processor must be after every other `hydrateColumns` pre-processors @@ -100,6 +104,7 @@ export const useDataGridPremiumComponent = ( useGridInitializeState(detailPanelStateInitializer, apiRef, props); useGridInitializeState(columnPinningStateInitializer, apiRef, props); useGridInitializeState(columnsStateInitializer, apiRef, props); + useGridInitializeState(rowPinningStateInitializer, apiRef, props); useGridInitializeState(rowsStateInitializer, apiRef, props); useGridInitializeState( props.experimentalFeatures?.newEditingApi @@ -125,6 +130,7 @@ export const useDataGridPremiumComponent = ( useGridKeyboardNavigation(apiRef, props); useGridSelection(apiRef, props); useGridColumnPinning(apiRef, props); + useGridRowPinning(apiRef, props); useGridColumns(apiRef, props); useGridRows(apiRef, props); useGridParamsApi(apiRef); diff --git a/packages/grid/x-data-grid-premium/src/hooks/features/aggregation/gridAggregationUtils.ts b/packages/grid/x-data-grid-premium/src/hooks/features/aggregation/gridAggregationUtils.ts index 4255dd41686f..231f9b844626 100644 --- a/packages/grid/x-data-grid-premium/src/hooks/features/aggregation/gridAggregationUtils.ts +++ b/packages/grid/x-data-grid-premium/src/hooks/features/aggregation/gridAggregationUtils.ts @@ -2,6 +2,7 @@ import * as React from 'react'; import { capitalize } from '@mui/material'; import { GridColDef, GridRowId, GridRowTreeNodeConfig } from '@mui/x-data-grid-pro'; import { + addPinnedRow, GridColumnRawLookup, GridRowTreeCreationValue, isDeepEqual, @@ -114,10 +115,12 @@ export const addFooterRows = ({ groupingParams, aggregationRules, getAggregationPosition, + apiRef, }: { groupingParams: GridRowTreeCreationValue; aggregationRules: GridAggregationRules; getAggregationPosition: DataGridPremiumProcessedProps['private_getAggregationPosition']; + apiRef: React.MutableRefObject; }) => { if (Object.keys(aggregationRules).length === 0) { return groupingParams; @@ -156,8 +159,6 @@ export const addFooterRows = ({ } }; - addGroupFooter(null); - // If the tree is flat, we don't need to loop through the rows if (groupingParams.treeDepth > 1) { groupingParams.ids.forEach((parentId) => { @@ -170,11 +171,26 @@ export const addFooterRows = ({ }); } - return { + let newGroupingParams = { ...groupingParams, - ids, - idRowsLookup, tree, + idRowsLookup, + ids, + }; + + if (getAggregationPosition(null) === 'footer') { + newGroupingParams = addPinnedRow({ + groupingParams: newGroupingParams, + rowModel: {}, + rowId: private_getAggregationFooterRowIdFromGroupId(null), + position: 'bottom', + apiRef, + }); + } + + return { + ...groupingParams, + ...newGroupingParams, }; }; diff --git a/packages/grid/x-data-grid-premium/src/hooks/features/aggregation/useGridAggregationPreProcessors.tsx b/packages/grid/x-data-grid-premium/src/hooks/features/aggregation/useGridAggregationPreProcessors.tsx index d1be07c11328..e587ca5e2bd9 100644 --- a/packages/grid/x-data-grid-premium/src/hooks/features/aggregation/useGridAggregationPreProcessors.tsx +++ b/packages/grid/x-data-grid-premium/src/hooks/features/aggregation/useGridAggregationPreProcessors.tsx @@ -101,6 +101,7 @@ export const useGridAggregationPreProcessors = ( groupingParams, aggregationRules, getAggregationPosition: props.private_getAggregationPosition, + apiRef, }); } } diff --git a/packages/grid/x-data-grid-premium/src/models/gridApiPremium.ts b/packages/grid/x-data-grid-premium/src/models/gridApiPremium.ts index d7091cc51ade..2f1544902146 100644 --- a/packages/grid/x-data-grid-premium/src/models/gridApiPremium.ts +++ b/packages/grid/x-data-grid-premium/src/models/gridApiPremium.ts @@ -4,6 +4,7 @@ import { GridStatePersistenceApi, GridColumnPinningApi, GridDetailPanelApi, + GridRowPinningApi, } from '@mui/x-data-grid-pro'; import { GridInitialStatePremium, GridStatePremium } from './gridStatePremium'; import type { GridRowGroupingApi, GridExcelExportApi, GridAggregationApi } from '../hooks'; @@ -24,4 +25,5 @@ export interface GridApiPremium GridDetailPanelApi, GridRowGroupingApi, GridExcelExportApi, - GridAggregationApi {} + GridAggregationApi, + GridRowPinningApi {} diff --git a/packages/grid/x-data-grid-premium/src/tests/aggregation.DataGridPremium.test.tsx b/packages/grid/x-data-grid-premium/src/tests/aggregation.DataGridPremium.test.tsx index 3535c7a19af5..8b4c11c2b371 100644 --- a/packages/grid/x-data-grid-premium/src/tests/aggregation.DataGridPremium.test.tsx +++ b/packages/grid/x-data-grid-premium/src/tests/aggregation.DataGridPremium.test.tsx @@ -597,9 +597,7 @@ describe(' - Aggregation', () => { />, ); - const callForAggCell = renderCell - .getCalls() - .find((call) => call.firstArg.rowNode.position === 'footer'); + const callForAggCell = renderCell.getCalls().find((call) => call.firstArg.rowNode.isPinned); expect(callForAggCell!.firstArg.aggregation.hasCellUnit).to.equal(true); }); @@ -625,9 +623,7 @@ describe(' - Aggregation', () => { />, ); - const callForAggCell = renderCell - .getCalls() - .find((call) => call.firstArg.rowNode.position === 'footer'); + const callForAggCell = renderCell.getCalls().find((call) => call.firstArg.rowNode.isPinned); expect(callForAggCell!.firstArg.aggregation.hasCellUnit).to.equal(false); }); }); diff --git a/packages/grid/x-data-grid-premium/src/tests/rowPinning.DataGridPremium.test.tsx b/packages/grid/x-data-grid-premium/src/tests/rowPinning.DataGridPremium.test.tsx new file mode 100644 index 000000000000..d4e7aaa05c8f --- /dev/null +++ b/packages/grid/x-data-grid-premium/src/tests/rowPinning.DataGridPremium.test.tsx @@ -0,0 +1,88 @@ +// @ts-ignore Remove once the test utils are typed +import { createRenderer } from '@mui/monorepo/test/utils'; +import * as React from 'react'; +import { expect } from 'chai'; +import { + DataGridPremium, + DataGridPremiumProps, + GridRowsProp, + gridClasses, +} from '@mui/x-data-grid-premium'; + +const isJSDOM = /jsdom/.test(window.navigator.userAgent); + +const rows: GridRowsProp = [ + { id: 0, category1: 'Cat A', category2: 'Cat 1' }, + { id: 1, category1: 'Cat A', category2: 'Cat 2' }, + { id: 2, category1: 'Cat A', category2: 'Cat 2' }, + { id: 3, category1: 'Cat B', category2: 'Cat 2' }, + { id: 4, category1: 'Cat B', category2: 'Cat 1' }, +]; + +const baselineProps: DataGridPremiumProps = { + autoHeight: isJSDOM, + disableVirtualization: true, + rows, + columns: [ + { + field: 'id', + type: 'number', + }, + { + field: 'category1', + }, + { + field: 'category2', + }, + ], +}; + +describe(' - Row pinning', () => { + const { render } = createRenderer({ clock: 'fake' }); + + function getRowById(id: number | string) { + return document.querySelector(`[data-id="${id}"]`); + } + + function getTopPinnedRowsContainer() { + return document.querySelector(`.${gridClasses['pinnedRows--top']}`) as HTMLElement; + } + function getBottomPinnedRowsContainer() { + return document.querySelector(`.${gridClasses['pinnedRows--bottom']}`) as HTMLElement; + } + + function isRowPinned(row: Element | null, section: 'top' | 'bottom') { + const container = + section === 'top' ? getTopPinnedRowsContainer() : getBottomPinnedRowsContainer(); + if (!row || !container) { + return false; + } + return container.contains(row); + } + + it('should render pinned rows outside of row groups', () => { + const Test = () => { + const [pinnedRow0, pinnedRow1, ...rowsData] = rows; + + return ( +
+ +
+ ); + }; + + render(); + + expect(isRowPinned(getRowById(0), 'top')).to.equal(true, '#0 pinned top'); + expect(isRowPinned(getRowById(1), 'bottom')).to.equal(true, '#1 pinned bottom'); + }); +}); diff --git a/packages/grid/x-data-grid-pro/src/DataGridPro/DataGridPro.tsx b/packages/grid/x-data-grid-pro/src/DataGridPro/DataGridPro.tsx index 8cef61199e7d..ac2859acb32a 100644 --- a/packages/grid/x-data-grid-pro/src/DataGridPro/DataGridPro.tsx +++ b/packages/grid/x-data-grid-pro/src/DataGridPro/DataGridPro.tsx @@ -258,6 +258,7 @@ DataGridProRaw.propTypes = { experimentalFeatures: PropTypes.shape({ newEditingApi: PropTypes.bool, preventCommitWhileValidating: PropTypes.bool, + rowPinning: PropTypes.bool, warnIfFocusStateIsNotSynced: PropTypes.bool, }), /** @@ -769,6 +770,13 @@ DataGridProRaw.propTypes = { left: PropTypes.arrayOf(PropTypes.string), right: PropTypes.arrayOf(PropTypes.string), }), + /** + * Rows data to pin on top or bottom. + */ + pinnedRows: PropTypes.shape({ + bottom: PropTypes.array, + top: PropTypes.array, + }), /** * Callback called before updating a row with new values in the row and cell editing. * Only applied if `props.experimentalFeatures.newEditingApi: true`. diff --git a/packages/grid/x-data-grid-pro/src/DataGridPro/useDataGridProComponent.tsx b/packages/grid/x-data-grid-pro/src/DataGridPro/useDataGridProComponent.tsx index 95ef6bbae1bb..f27af1021f70 100644 --- a/packages/grid/x-data-grid-pro/src/DataGridPro/useDataGridProComponent.tsx +++ b/packages/grid/x-data-grid-pro/src/DataGridPro/useDataGridProComponent.tsx @@ -67,6 +67,11 @@ import { import { useGridDetailPanelPreProcessors } from '../hooks/features/detailPanel/useGridDetailPanelPreProcessors'; import { useGridRowReorder } from '../hooks/features/rowReorder/useGridRowReorder'; import { useGridRowReorderPreProcessors } from '../hooks/features/rowReorder/useGridRowReorderPreProcessors'; +import { + useGridRowPinning, + rowPinningStateInitializer, +} from '../hooks/features/rowPinning/useGridRowPinning'; +import { useGridRowPinningPreProcessors } from '../hooks/features/rowPinning/useGridRowPinningPreProcessors'; export const useDataGridProComponent = ( inputApiRef: React.MutableRefObject | undefined, @@ -80,6 +85,7 @@ export const useDataGridProComponent = ( useGridSelectionPreProcessors(apiRef, props); useGridRowReorderPreProcessors(apiRef, props); useGridTreeDataPreProcessors(apiRef, props); + useGridRowPinningPreProcessors(apiRef); useGridDetailPanelPreProcessors(apiRef, props); // The column pinning `hydrateColumns` pre-processor must be after every other `hydrateColumns` pre-processors // Because it changes the order of the columns. @@ -93,6 +99,7 @@ export const useDataGridProComponent = ( useGridInitializeState(detailPanelStateInitializer, apiRef, props); useGridInitializeState(columnPinningStateInitializer, apiRef, props); useGridInitializeState(columnsStateInitializer, apiRef, props); + useGridInitializeState(rowPinningStateInitializer, apiRef, props); useGridInitializeState(rowsStateInitializer, apiRef, props); useGridInitializeState( props.experimentalFeatures?.newEditingApi @@ -116,6 +123,7 @@ export const useDataGridProComponent = ( useGridKeyboardNavigation(apiRef, props); useGridSelection(apiRef, props); useGridColumnPinning(apiRef, props); + useGridRowPinning(apiRef, props); useGridColumns(apiRef, props); useGridRows(apiRef, props); useGridParamsApi(apiRef); diff --git a/packages/grid/x-data-grid-pro/src/components/DataGridProVirtualScroller.tsx b/packages/grid/x-data-grid-pro/src/components/DataGridProVirtualScroller.tsx index fff6894e0a49..cd065bd03c19 100644 --- a/packages/grid/x-data-grid-pro/src/components/DataGridProVirtualScroller.tsx +++ b/packages/grid/x-data-grid-pro/src/components/DataGridProVirtualScroller.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { styled, alpha } from '@mui/material/styles'; +import { styled, alpha, Theme } from '@mui/material/styles'; import { unstable_composeClasses as composeClasses } from '@mui/material'; import { useGridSelector, @@ -15,6 +15,7 @@ import { GridVirtualScrollerContent, GridVirtualScrollerRenderZone, useGridVirtualScroller, + calculatePinnedRowsHeight, } from '@mui/x-data-grid/internals'; import { useGridApiContext } from '../hooks/utils/useGridApiContext'; import { useGridRootProps } from '../hooks/utils/useGridRootProps'; @@ -30,6 +31,7 @@ import { gridDetailPanelExpandedRowIdsSelector, } from '../hooks/features/detailPanel'; import { GridDetailPanel } from './GridDetailPanel'; +import { gridPinnedRowsSelector } from '../hooks/features/rowPinning/gridRowPinningSelector'; export const filterColumns = ( pinnedColumns: GridPinnedColumns, @@ -61,22 +63,17 @@ export const filterColumns = ( type OwnerState = { classes: DataGridProProcessedProps['classes']; - leftPinnedColumns: GridPinnedColumns['left']; - rightPinnedColumns: GridPinnedColumns['right']; }; const useUtilityClasses = (ownerState: OwnerState) => { - const { classes, leftPinnedColumns, rightPinnedColumns } = ownerState; + const { classes } = ownerState; const slots = { - leftPinnedColumns: [ - 'pinnedColumns', - leftPinnedColumns && leftPinnedColumns.length > 0 && 'pinnedColumns--left', - ], - rightPinnedColumns: [ - 'pinnedColumns', - rightPinnedColumns && rightPinnedColumns.length > 0 && 'pinnedColumns--right', - ], + leftPinnedColumns: ['pinnedColumns', 'pinnedColumns--left'], + rightPinnedColumns: ['pinnedColumns', 'pinnedColumns--right'], + topPinnedRows: ['pinnedRows', 'pinnedRows--top'], + bottomPinnedRows: ['pinnedRows', 'pinnedRows--bottom'], + pinnedRowsRenderZone: ['pinnedRowsRenderZone'], detailPanels: ['detailPanels'], detailPanel: ['detailPanel'], }; @@ -99,6 +96,10 @@ const getOverlayAlpha = (elevation: number) => { return alphaValue / 100; }; +const getBoxShadowColor = (theme: Theme) => { + return alpha(theme.palette.common.black, 0.21); +}; + const VirtualScrollerDetailPanels = styled('div', { name: 'MuiDataGrid', slot: 'DetailPanels', @@ -107,6 +108,11 @@ const VirtualScrollerDetailPanels = styled('div', { position: 'relative', }); +const darkModeBackgroundImage = `linear-gradient(${alpha('#fff', getOverlayAlpha(2))}, ${alpha( + '#fff', + getOverlayAlpha(2), +)})`; + const VirtualScrollerPinnedColumns = styled('div', { name: 'MuiDataGrid', slot: 'PinnedColumns', @@ -115,21 +121,57 @@ const VirtualScrollerPinnedColumns = styled('div', { { [`&.${gridClasses['pinnedColumns--right']}`]: styles['pinnedColumns--right'] }, styles.pinnedColumns, ], -})<{ ownerState: VirtualScrollerPinnedColumnsProps }>(({ theme, ownerState }) => ({ - position: 'sticky', - overflow: 'hidden', - zIndex: 1, - boxShadow: theme.shadows[2], - backgroundColor: theme.palette.background.default, - ...(theme.palette.mode === 'dark' && { - backgroundImage: `linear-gradient(${alpha('#fff', getOverlayAlpha(2))}, ${alpha( - '#fff', - getOverlayAlpha(2), - )})`, - }), - ...(ownerState.side === GridPinnedPosition.left && { left: 0, float: 'left' }), - ...(ownerState.side === GridPinnedPosition.right && { right: 0, float: 'right' }), -})); +})<{ ownerState: VirtualScrollerPinnedColumnsProps }>(({ theme, ownerState }) => { + const boxShadowColor = getBoxShadowColor(theme); + return { + position: 'sticky', + overflow: 'hidden', + zIndex: 1, + backgroundColor: theme.palette.background.default, + ...(theme.palette.mode === 'dark' && { backgroundImage: darkModeBackgroundImage }), + ...(ownerState.side === GridPinnedPosition.left && { + left: 0, + float: 'left', + boxShadow: `2px 0px 4px -2px ${boxShadowColor}`, + }), + ...(ownerState.side === GridPinnedPosition.right && { + right: 0, + float: 'right', + boxShadow: `-2px 0px 4px -2px ${boxShadowColor}`, + }), + }; +}); + +const VirtualScrollerPinnedRows = styled('div', { + name: 'MuiDataGrid', + slot: 'PinnedRows', + overridesResolver: (props, styles) => [ + { [`&.${gridClasses['pinnedRows--top']}`]: styles['pinnedRows--top'] }, + { [`&.${gridClasses['pinnedRows--bottom']}`]: styles['pinnedRows--bottom'] }, + styles.pinnedRows, + ], +})<{ ownerState: { position: 'top' | 'bottom' } }>(({ theme, ownerState }) => { + const boxShadowColor = getBoxShadowColor(theme); + return { + position: 'sticky', + // should be above the detail panel + zIndex: 3, + backgroundColor: theme.palette.background.default, + ...(theme.palette.mode === 'dark' && { backgroundImage: darkModeBackgroundImage }), + ...(ownerState.position === 'top' && { + top: 0, + boxShadow: `0px 3px 4px -2px ${boxShadowColor}`, + }), + ...(ownerState.position === 'bottom' && { + boxShadow: `0px -3px 4px -2px ${boxShadowColor}`, + bottom: 0, + }), + }; +}); + +const VirtualScrollerPinnedRowsRenderZone = styled('div')({ + position: 'absolute', +}); interface DataGridProVirtualScrollerProps extends React.HTMLAttributes { disableVirtualization?: boolean; @@ -154,14 +196,22 @@ const DataGridProVirtualScroller = React.forwardRef< ); const leftColumns = React.useRef(null); const rightColumns = React.useRef(null); + const topPinnedRowsRenderZoneRef = React.useRef(null); + const bottomPinnedRowsRenderZoneRef = React.useRef(null); - const handleRenderZonePositioning = React.useCallback(({ top }) => { + const handleRenderZonePositioning = React.useCallback(({ top, left }) => { if (leftColumns.current) { leftColumns.current!.style.transform = `translate3d(0px, ${top}px, 0px)`; } if (rightColumns.current) { rightColumns.current!.style.transform = `translate3d(0px, ${top}px, 0px)`; } + if (topPinnedRowsRenderZoneRef.current) { + topPinnedRowsRenderZoneRef.current!.style.transform = `translate3d(${left}px, 0px, 0px)`; + } + if (bottomPinnedRowsRenderZoneRef.current) { + bottomPinnedRowsRenderZoneRef.current!.style.transform = `translate3d(${left}px, 0px, 0px)`; + } }, []); const getRowProps = (id: GridRowId) => { @@ -175,7 +225,17 @@ const DataGridProVirtualScroller = React.forwardRef< const pinnedColumns = useGridSelector(apiRef, gridPinnedColumnsSelector); const [leftPinnedColumns, rightPinnedColumns] = filterColumns(pinnedColumns, visibleColumnFields); - const ownerState = { classes: rootProps.classes, leftPinnedColumns, rightPinnedColumns }; + const pinnedRows = useGridSelector(apiRef, gridPinnedRowsSelector); + const topPinnedRowsData = React.useMemo(() => pinnedRows?.top || [], [pinnedRows?.top]); + const bottomPinnedRowsData = React.useMemo(() => pinnedRows?.bottom || [], [pinnedRows?.bottom]); + + const ownerState = { + classes: rootProps.classes, + leftPinnedColumns, + rightPinnedColumns, + topPinnedRowsCount: topPinnedRowsData.length, + bottomPinnedRowsCount: bottomPinnedRowsData.length, + }; const classes = useUtilityClasses(ownerState); const { @@ -222,12 +282,6 @@ const DataGridProVirtualScroller = React.forwardRef< } : null; - const contentProps = getContentProps(); - - const pinnedColumnsStyle = { - minHeight: contentProps.style.minHeight, - }; - const getDetailPanels = () => { const panels: React.ReactNode[] = []; @@ -274,8 +328,77 @@ const DataGridProVirtualScroller = React.forwardRef< const detailPanels = getDetailPanels(); + const topPinnedRows = getRows({ renderContext, rows: topPinnedRowsData }); + + const pinnedRowsHeight = calculatePinnedRowsHeight(apiRef); + + const mainRows = getRows({ + renderContext, + rowIndexOffset: topPinnedRowsData.length, + }); + + const bottomPinnedRows = getRows({ + renderContext, + rows: bottomPinnedRowsData, + rowIndexOffset: topPinnedRowsData.length + (mainRows ? mainRows.length : 0), + }); + + const contentProps = getContentProps(); + + const pinnedColumnsStyle = { minHeight: contentProps.style.minHeight }; + + if (contentProps.style.minHeight && contentProps.style.minHeight === '100%') { + contentProps.style.minHeight = `calc(100% - ${pinnedRowsHeight.top}px - ${pinnedRowsHeight.bottom}px)`; + } + return ( + {topPinnedRowsData.length > 0 ? ( + + {leftRenderContext && ( + + {getRows({ + renderContext: leftRenderContext, + minFirstColumn: leftRenderContext.firstColumnIndex, + maxLastColumn: leftRenderContext.lastColumnIndex, + availableSpace: 0, + ignoreAutoHeight: true, + rows: topPinnedRowsData, + })} + + )} + + {topPinnedRows} + + {rightRenderContext && ( + + {getRows({ + renderContext: rightRenderContext, + minFirstColumn: rightRenderContext.firstColumnIndex, + maxLastColumn: rightRenderContext.lastColumnIndex, + ignoreAutoHeight: true, + availableSpace: 0, + rows: topPinnedRowsData, + })} + + )} + + ) : null} {leftRenderContext && ( )} - {getRows({ renderContext })} + {mainRows} {rightRenderContext && ( )} @@ -318,6 +443,54 @@ const DataGridProVirtualScroller = React.forwardRef< )} + {bottomPinnedRowsData.length > 0 ? ( + + {leftRenderContext && ( + + {getRows({ + renderContext: leftRenderContext, + minFirstColumn: leftRenderContext.firstColumnIndex, + maxLastColumn: leftRenderContext.lastColumnIndex, + availableSpace: 0, + ignoreAutoHeight: true, + rows: bottomPinnedRowsData, + rowIndexOffset: topPinnedRowsData.length + (mainRows ? mainRows.length : 0), + })} + + )} + + {bottomPinnedRows} + + {rightRenderContext && ( + + {getRows({ + renderContext: rightRenderContext, + minFirstColumn: rightRenderContext.firstColumnIndex, + maxLastColumn: rightRenderContext.lastColumnIndex, + availableSpace: 0, + ignoreAutoHeight: true, + rows: bottomPinnedRowsData, + rowIndexOffset: topPinnedRowsData.length + (mainRows ? mainRows.length : 0), + })} + + )} + + ) : null} ); }); diff --git a/packages/grid/x-data-grid-pro/src/components/GridRowReorderCell.tsx b/packages/grid/x-data-grid-pro/src/components/GridRowReorderCell.tsx index 6741d4f27cea..1f0b49db7dcb 100644 --- a/packages/grid/x-data-grid-pro/src/components/GridRowReorderCell.tsx +++ b/packages/grid/x-data-grid-pro/src/components/GridRowReorderCell.tsx @@ -103,6 +103,9 @@ const GridRowReorderCell = (params: GridRenderCellParams) => { export { GridRowReorderCell }; -export const renderRowReorderCell = (params: GridRenderCellParams) => ( - -); +export const renderRowReorderCell = (params: GridRenderCellParams) => { + if (params.rowNode.isPinned) { + return null; + } + return ; +}; diff --git a/packages/grid/x-data-grid-pro/src/hooks/features/index.ts b/packages/grid/x-data-grid-pro/src/hooks/features/index.ts index 2660e86b255e..2ad8b18e908e 100644 --- a/packages/grid/x-data-grid-pro/src/hooks/features/index.ts +++ b/packages/grid/x-data-grid-pro/src/hooks/features/index.ts @@ -5,3 +5,4 @@ export * from './columnResize'; export * from './rowReorder'; export * from './treeData'; export * from './detailPanel'; +export * from './rowPinning'; diff --git a/packages/grid/x-data-grid-pro/src/hooks/features/rowPinning/gridRowPinningInterface.ts b/packages/grid/x-data-grid-pro/src/hooks/features/rowPinning/gridRowPinningInterface.ts new file mode 100644 index 000000000000..6924489d128d --- /dev/null +++ b/packages/grid/x-data-grid-pro/src/hooks/features/rowPinning/gridRowPinningInterface.ts @@ -0,0 +1,20 @@ +import { GridRowId, GridRowsLookup, GridRowsProp } from '@mui/x-data-grid'; + +export interface GridPinnedRowsProp { + top?: GridRowsProp; + bottom?: GridRowsProp; +} + +export interface GridRowPinningApi { + /** + * Changes the pinned rows. + * @param {GridPinnedRowsProp} pinnedRows An object containing the rows to pin. + */ + unstable_setPinnedRows: (pinnedRows?: GridPinnedRowsProp) => void; +} + +export interface GridRowPinningInternalCache { + topIds: GridRowId[]; + bottomIds: GridRowId[]; + idLookup: GridRowsLookup; +} diff --git a/packages/grid/x-data-grid-pro/src/hooks/features/rowPinning/gridRowPinningSelector.ts b/packages/grid/x-data-grid-pro/src/hooks/features/rowPinning/gridRowPinningSelector.ts new file mode 100644 index 000000000000..f06a4228fdfa --- /dev/null +++ b/packages/grid/x-data-grid-pro/src/hooks/features/rowPinning/gridRowPinningSelector.ts @@ -0,0 +1,4 @@ +export { + gridAdditionalRowGroupsSelector, + gridPinnedRowsSelector, +} from '@mui/x-data-grid/internals'; diff --git a/packages/grid/x-data-grid-pro/src/hooks/features/rowPinning/index.ts b/packages/grid/x-data-grid-pro/src/hooks/features/rowPinning/index.ts new file mode 100644 index 000000000000..488a313811f6 --- /dev/null +++ b/packages/grid/x-data-grid-pro/src/hooks/features/rowPinning/index.ts @@ -0,0 +1 @@ +export * from './gridRowPinningInterface'; diff --git a/packages/grid/x-data-grid-pro/src/hooks/features/rowPinning/useGridRowPinning.ts b/packages/grid/x-data-grid-pro/src/hooks/features/rowPinning/useGridRowPinning.ts new file mode 100644 index 000000000000..451bfaf1068b --- /dev/null +++ b/packages/grid/x-data-grid-pro/src/hooks/features/rowPinning/useGridRowPinning.ts @@ -0,0 +1,99 @@ +import * as React from 'react'; +import { useGridApiMethod } from '@mui/x-data-grid'; +import { getRowIdFromRowModel, GridStateInitializer } from '@mui/x-data-grid/internals'; + +import { GridApiPro } from '../../../models/gridApiPro'; +import { DataGridProProcessedProps, DataGridProProps } from '../../../models/dataGridProProps'; +import { + GridPinnedRowsProp, + GridRowPinningApi, + GridRowPinningInternalCache, +} from './gridRowPinningInterface'; + +function createPinnedRowsInternalCache( + pinnedRows: GridPinnedRowsProp | undefined, + getRowId: DataGridProProps['getRowId'], +) { + const cache: GridRowPinningInternalCache = { + topIds: [], + bottomIds: [], + idLookup: {}, + }; + + pinnedRows?.top?.forEach((rowModel) => { + const id = getRowIdFromRowModel(rowModel, getRowId); + cache.topIds.push(id); + cache.idLookup[id] = rowModel; + }); + + pinnedRows?.bottom?.forEach((rowModel) => { + const id = getRowIdFromRowModel(rowModel, getRowId); + cache.bottomIds.push(id); + cache.idLookup[id] = rowModel; + }); + + return cache; +} + +export const rowPinningStateInitializer: GridStateInitializer< + Pick +> = (state, props, apiRef) => { + if (!props.experimentalFeatures?.rowPinning) { + return state; + } + + apiRef.current.unstable_caches.pinnedRows = createPinnedRowsInternalCache( + props.pinnedRows, + props.getRowId, + ); + + return { + ...state, + rows: { + ...state.rows, + additionalRowGroups: { + ...state.rows?.additionalRowGroups, + pinnedRows: { top: [], bottom: [] }, + }, + }, + }; +}; + +export const useGridRowPinning = ( + apiRef: React.MutableRefObject, + props: Pick, +): void => { + const setPinnedRows = React.useCallback( + (newPinnedRows) => { + if (!props.experimentalFeatures?.rowPinning) { + return; + } + + apiRef.current.unstable_caches.pinnedRows = createPinnedRowsInternalCache( + newPinnedRows, + props.getRowId, + ); + + apiRef.current.unstable_requestPipeProcessorsApplication('hydrateRows'); + }, + [apiRef, props.experimentalFeatures?.rowPinning, props.getRowId], + ); + + useGridApiMethod( + apiRef, + { + unstable_setPinnedRows: setPinnedRows, + }, + 'rowPinningApi', + ); + + const isFirstRender = React.useRef(true); + + React.useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + apiRef.current.unstable_setPinnedRows(props.pinnedRows); + }, [apiRef, props.pinnedRows]); +}; diff --git a/packages/grid/x-data-grid-pro/src/hooks/features/rowPinning/useGridRowPinningPreProcessors.ts b/packages/grid/x-data-grid-pro/src/hooks/features/rowPinning/useGridRowPinningPreProcessors.ts new file mode 100644 index 000000000000..87fd16201a5b --- /dev/null +++ b/packages/grid/x-data-grid-pro/src/hooks/features/rowPinning/useGridRowPinningPreProcessors.ts @@ -0,0 +1,111 @@ +import * as React from 'react'; +import { + GridHydrateRowsValue, + GridPipeProcessor, + useGridRegisterPipeProcessor, +} from '@mui/x-data-grid/internals'; +import { GridRowEntry, GridRowId, GridRowModel } from '@mui/x-data-grid'; +import { GridApiPro } from '../../../models/gridApiPro'; +import { GridPinnedRowsProp } from './gridRowPinningInterface'; + +type GridPinnedRowPosition = keyof GridPinnedRowsProp; + +export function addPinnedRow({ + groupingParams, + rowModel, + rowId, + position, + apiRef, +}: { + groupingParams: GridHydrateRowsValue; + rowModel: GridRowModel; + rowId: GridRowId; + position: GridPinnedRowPosition; + apiRef: React.MutableRefObject; +}) { + const idRowsLookup = { ...groupingParams.idRowsLookup }; + const tree = { ...groupingParams.tree }; + + // TODO: warn if id is already present in `props.rows` + idRowsLookup[rowId] = rowModel; + // Do not push it to ids list so that pagination is not affected by pinned rows + // ids.push(rowId); + tree[rowId] = { + id: rowId, + isAutoGenerated: false, + parent: null, + depth: 0, + groupingKey: null, + groupingField: null, + isPinned: true, + }; + + apiRef.current.unstable_caches.rows.idRowsLookup[rowId] = { ...rowModel }; + apiRef.current.unstable_caches.rows.idToIdLookup[rowId] = rowId; + + const previousPinnedRows = groupingParams.additionalRowGroups?.pinnedRows || {}; + + const newPinnedRow: GridRowEntry = { id: rowId, model: rowModel }; + + return { + ...groupingParams, + idRowsLookup, + tree, + additionalRowGroups: { + ...groupingParams.additionalRowGroups, + pinnedRows: { + ...previousPinnedRows, + [position]: [...(previousPinnedRows[position] || []), newPinnedRow], + }, + }, + }; +} + +export const useGridRowPinningPreProcessors = (apiRef: React.MutableRefObject) => { + const addPinnedRows = React.useCallback>( + (groupingParams) => { + const pinnedRowsCache = apiRef.current.unstable_caches.pinnedRows || {}; + + let newGroupingParams = { + ...groupingParams, + additionalRowGroups: { + ...groupingParams.additionalRowGroups, + // reset pinned rows state + pinnedRows: {}, + }, + }; + + pinnedRowsCache.topIds?.forEach((rowId) => { + newGroupingParams = addPinnedRow({ + groupingParams: newGroupingParams, + rowModel: pinnedRowsCache.idLookup[rowId], + rowId, + position: 'top', + apiRef, + }); + }); + pinnedRowsCache.bottomIds?.forEach((rowId) => { + newGroupingParams = addPinnedRow({ + groupingParams: newGroupingParams, + rowModel: pinnedRowsCache.idLookup[rowId], + rowId, + position: 'bottom', + apiRef, + }); + }); + + // If row with the same `id` is present both in `rows` and `pinnedRows` - remove it from `ids` + newGroupingParams.ids = newGroupingParams.ids.filter((rowId) => { + if (newGroupingParams.tree[rowId] && newGroupingParams.tree[rowId].isPinned) { + return false; + } + return true; + }); + + return newGroupingParams; + }, + [apiRef], + ); + + useGridRegisterPipeProcessor(apiRef, 'hydrateRows', addPinnedRows); +}; diff --git a/packages/grid/x-data-grid-pro/src/hooks/features/rowReorder/useGridRowReorder.tsx b/packages/grid/x-data-grid-pro/src/hooks/features/rowReorder/useGridRowReorder.tsx index b0ae1c55a71b..a752c47b5c0a 100644 --- a/packages/grid/x-data-grid-pro/src/hooks/features/rowReorder/useGridRowReorder.tsx +++ b/packages/grid/x-data-grid-pro/src/hooks/features/rowReorder/useGridRowReorder.tsx @@ -91,6 +91,10 @@ export const useGridRowReorder = ( return; } + if (apiRef.current.getRowNode(params.id)?.isPinned) { + return; + } + logger.debug(`Dragging over row ${params.id}`); event.preventDefault(); // Prevent drag events propagation. diff --git a/packages/grid/x-data-grid-pro/src/internals/index.ts b/packages/grid/x-data-grid-pro/src/internals/index.ts index ef260b2a579d..2f223edf17a3 100644 --- a/packages/grid/x-data-grid-pro/src/internals/index.ts +++ b/packages/grid/x-data-grid-pro/src/internals/index.ts @@ -27,6 +27,14 @@ export { useGridRowReorderPreProcessors } from '../hooks/features/rowReorder/use export { useGridTreeData } from '../hooks/features/treeData/useGridTreeData'; export { useGridTreeDataPreProcessors } from '../hooks/features/treeData/useGridTreeDataPreProcessors'; export { TREE_DATA_STRATEGY } from '../hooks/features/treeData/gridTreeDataUtils'; +export { + useGridRowPinning, + rowPinningStateInitializer, +} from '../hooks/features/rowPinning/useGridRowPinning'; +export { + useGridRowPinningPreProcessors, + addPinnedRow, +} from '../hooks/features/rowPinning/useGridRowPinningPreProcessors'; export type { GridExperimentalProFeatures, diff --git a/packages/grid/x-data-grid-pro/src/models/dataGridProProps.ts b/packages/grid/x-data-grid-pro/src/models/dataGridProProps.ts index ee917391364e..595bfbdd9ff5 100644 --- a/packages/grid/x-data-grid-pro/src/models/dataGridProProps.ts +++ b/packages/grid/x-data-grid-pro/src/models/dataGridProProps.ts @@ -15,6 +15,7 @@ import { DataGridPropsWithComplexDefaultValueBeforeProcessing, } from '@mui/x-data-grid/internals'; import type { GridPinnedColumns } from '../hooks/features/columnPinning'; +import type { GridPinnedRowsProp } from '../hooks/features/rowPinning'; import { GridApiPro } from './gridApiPro'; import { GridGroupingColDefOverride, @@ -22,7 +23,9 @@ import { } from './gridGroupingColDefOverride'; import { GridInitialStatePro } from './gridStatePro'; -export interface GridExperimentalProFeatures extends GridExperimentalFeatures {} +export interface GridExperimentalProFeatures extends GridExperimentalFeatures { + rowPinning: boolean; +} /** * The props users can give to the `DataGridProProps` component. @@ -33,13 +36,7 @@ export interface DataGridProProps DataGridPropsWithComplexDefaultValueBeforeProcessing & DataGridProPropsWithoutDefaultValue, DataGridProForcedPropsKey - > { - /** - * Features under development. - * For each feature, if the flag is not explicitly set to `true`, the feature will be fully disabled and any property / method call will not have any effect. - */ - experimentalFeatures?: Partial; -} + > {} /** * The props of the `DataGridPro` component after the pre-processing phase. @@ -121,6 +118,11 @@ 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"]. @@ -192,4 +194,8 @@ export interface DataGridProPropsWithoutDefaultValue; + /** + * Rows data to pin on top or bottom. + */ + pinnedRows?: GridPinnedRowsProp; } diff --git a/packages/grid/x-data-grid-pro/src/models/gridApiPro.ts b/packages/grid/x-data-grid-pro/src/models/gridApiPro.ts index 8e6f53c50320..d87f52124101 100644 --- a/packages/grid/x-data-grid-pro/src/models/gridApiPro.ts +++ b/packages/grid/x-data-grid-pro/src/models/gridApiPro.ts @@ -1,6 +1,6 @@ import { GridApiCommon, GridStateApi, GridStatePersistenceApi } from '@mui/x-data-grid'; import { GridInitialStatePro, GridStatePro } from './gridStatePro'; -import type { GridColumnPinningApi, GridDetailPanelApi } from '../hooks'; +import type { GridColumnPinningApi, GridDetailPanelApi, GridRowPinningApi } from '../hooks'; type GridStateApiUntyped = { [key in keyof (GridStateApi & GridStatePersistenceApi)]: any; @@ -14,4 +14,5 @@ export interface GridApiPro GridStateApi, GridStatePersistenceApi, GridColumnPinningApi, - GridDetailPanelApi {} + GridDetailPanelApi, + GridRowPinningApi {} diff --git a/packages/grid/x-data-grid-pro/src/tests/rowPinning.DataGridPro.test.tsx b/packages/grid/x-data-grid-pro/src/tests/rowPinning.DataGridPro.test.tsx new file mode 100644 index 000000000000..f9cb64b725e7 --- /dev/null +++ b/packages/grid/x-data-grid-pro/src/tests/rowPinning.DataGridPro.test.tsx @@ -0,0 +1,765 @@ +import * as React from 'react'; +import { + DataGridPro, + gridClasses, + useGridApiRef, + GridApi, + GridRowsProp, + DataGridProProps, +} from '@mui/x-data-grid-pro'; +import { expect } from 'chai'; +// @ts-expect-error Remove once the test utils are typed +import { createRenderer, waitFor, fireEvent, screen } from '@mui/monorepo/test/utils'; +import { getData } from 'storybook/src/data/data-service'; +import { + getActiveCell, + getActiveColumnHeader, + getCell, + getColumnHeaderCell, + getColumnValues, + getRows, +} from 'test/utils/helperFn'; + +const isJSDOM = /jsdom/.test(window.navigator.userAgent); + +describe(' - Row pinning', () => { + const { render } = createRenderer({ clock: 'fake' }); + + function getRowById(id: number | string) { + return document.querySelector(`[data-id="${id}"]`); + } + + function getTopPinnedRowsContainer() { + return document.querySelector(`.${gridClasses['pinnedRows--top']}`) as HTMLElement; + } + function getBottomPinnedRowsContainer() { + return document.querySelector(`.${gridClasses['pinnedRows--bottom']}`) as HTMLElement; + } + + function isRowPinned(row: Element | null, section: 'top' | 'bottom') { + const container = + section === 'top' ? getTopPinnedRowsContainer() : getBottomPinnedRowsContainer(); + if (!row || !container) { + return false; + } + return container.contains(row); + } + + const BaselineTestCase = ({ + rowCount, + colCount, + height = 300, + ...props + }: { + rowCount: number; + colCount: number; + height?: number | string; + } & Partial) => { + const data = getData(rowCount, colCount); + const [pinnedRow0, pinnedRow1, ...rows] = data.rows; + + return ( +
+ +
+ ); + }; + + it('should render pinned rows in pinned containers', () => { + render(); + + expect(isRowPinned(getRowById(0), 'top')).to.equal(true, '#0 pinned top'); + expect(isRowPinned(getRowById(1), 'bottom')).to.equal(true, '#1 pinned bottom'); + }); + + it('should treat row as pinned even if row with the same id is present in `rows` prop', () => { + const rowCount = 5; + + const TestCase = ({ pinRows = true }) => { + const data = getData(rowCount, 5); + + const pinnedRows = React.useMemo(() => { + if (pinRows) { + return { + top: [data.rows[0]], + bottom: [data.rows[1]], + }; + } + return undefined; + }, [pinRows, data.rows]); + + return ( +
+ +
+ ); + }; + + const { setProps } = render(); + + expect(isRowPinned(getRowById(0), 'top')).to.equal(true, '#0 pinned top'); + expect(isRowPinned(getRowById(1), 'bottom')).to.equal(true, '#1 pinned bottom'); + expect(getColumnValues(0)).to.deep.equal(['0', '2', '3', '4', '1']); + expect(screen.getByText(`Total Rows: ${rowCount - 2}`)).not.to.equal(null); + + setProps({ pinRows: false }); + + expect(isRowPinned(getRowById(0), 'top')).to.equal(false, '#0 not pinned'); + expect(isRowPinned(getRowById(1), 'bottom')).to.equal(false, '#1 not pinned'); + expect(getColumnValues(0)).to.deep.equal(['0', '1', '2', '3', '4']); + expect(screen.getByText(`Total Rows: ${rowCount}`)).not.to.equal(null); + + setProps({ pinRows: true }); + + expect(isRowPinned(getRowById(0), 'top')).to.equal(true, '#0 pinned top'); + expect(isRowPinned(getRowById(1), 'bottom')).to.equal(true, '#1 pinned bottom'); + expect(getColumnValues(0)).to.deep.equal(['0', '2', '3', '4', '1']); + expect(screen.getByText(`Total Rows: ${rowCount - 2}`)).not.to.equal(null); + }); + + it('should keep rows pinned on rows scroll', function test() { + if (isJSDOM) { + // Need layouting + this.skip(); + } + + render(); + + const virtualScroller = document.querySelector(`.${gridClasses.virtualScroller}`)!; + expect(virtualScroller.scrollTop).to.equal(0); + + expect(isRowPinned(getRowById(0), 'top')).to.equal(true, '#0 pinned top'); + expect(isRowPinned(getRowById(1), 'bottom')).to.equal(true, '#1 pinned bottom'); + + // scroll to the very bottom + virtualScroller.scrollTop = 1000; + virtualScroller.dispatchEvent(new Event('scroll')); + + expect(isRowPinned(getRowById(0), 'top')).to.equal(true, '#0 pinned top'); + expect(isRowPinned(getRowById(1), 'bottom')).to.equal(true, '#1 pinned bottom'); + }); + + it('should update pinned rows when `pinnedRows` prop change', () => { + const data = getData(20, 5); + const TestCase = (props: any) => { + const [pinnedRow0, pinnedRow1, ...rows] = data.rows; + return ( +
+ +
+ ); + }; + + const { setProps } = render(); + + expect(isRowPinned(getRowById(0), 'top')).to.equal(true, '#0 pinned top'); + expect(isRowPinned(getRowById(1), 'bottom')).to.equal(true, '#1 pinned bottom'); + + const pinnedRows = { top: [data.rows[11]], bottom: [data.rows[3]] }; + const rows = data.rows.filter((row) => row.id !== 11 && row.id !== 3); + + setProps({ pinnedRows, rows }); + + expect(isRowPinned(getRowById(0), 'top')).to.equal(false, '#0 pinned top'); + expect(isRowPinned(getRowById(1), 'bottom')).to.equal(false, '#1 pinned bottom'); + + expect(isRowPinned(getRowById(11), 'top')).to.equal(true, '#11 pinned top'); + expect(isRowPinned(getRowById(3), 'bottom')).to.equal(true, '#3 pinned bottom'); + }); + + it('should update pinned rows when calling `apiRef.current.setPinnedRows` method', async () => { + const data = getData(20, 5); + let apiRef!: React.MutableRefObject; + + const TestCase = (props: any) => { + const [pinnedRow0, pinnedRow1, ...rows] = data.rows; + apiRef = useGridApiRef(); + return ( +
+ +
+ ); + }; + + render(); + + expect(isRowPinned(getRowById(0), 'top')).to.equal(true, '#0 pinned top'); + expect(isRowPinned(getRowById(1), 'bottom')).to.equal(true, '#1 pinned bottom'); + + let pinnedRows = { top: [data.rows[11]], bottom: [data.rows[3]] }; + let rows = data.rows.filter((row) => row.id !== 11 && row.id !== 3); + + // should work when calling `setPinnedRows` before `setRows` + apiRef.current.unstable_setPinnedRows(pinnedRows); + apiRef.current.setRows(rows); + + await waitFor(() => { + expect(isRowPinned(getRowById(0), 'top')).to.equal(false, '#0 pinned top'); + expect(isRowPinned(getRowById(1), 'bottom')).to.equal(false, '#1 pinned bottom'); + + expect(isRowPinned(getRowById(11), 'top')).to.equal(true, '#11 pinned top'); + expect(isRowPinned(getRowById(3), 'bottom')).to.equal(true, '#3 pinned bottom'); + }); + + pinnedRows = { top: [data.rows[8]], bottom: [data.rows[5]] }; + rows = data.rows.filter((row) => row.id !== 8 && row.id !== 5); + + // should work when calling `setPinnedRows` after `setRows` + apiRef.current.setRows(rows); + apiRef.current.unstable_setPinnedRows(pinnedRows); + + await waitFor(() => { + expect(isRowPinned(getRowById(11), 'top')).to.equal(false, '#11 pinned top'); + expect(isRowPinned(getRowById(3), 'bottom')).to.equal(false, '#3 pinned bottom'); + + expect(isRowPinned(getRowById(8), 'top')).to.equal(true, '#8 pinned top'); + expect(isRowPinned(getRowById(5), 'bottom')).to.equal(true, '#5 pinned bottom'); + }); + }); + + it('should work with `getRowId`', () => { + const TestCase = () => { + const data = getData(20, 5); + + const rowsData = data.rows.map((row) => { + const { id, ...rowData } = row; + return { + ...rowData, + productId: id, + }; + }); + + const [pinnedRow0, pinnedRow1, ...rows] = rowsData; + + const getRowId = React.useCallback((row) => row.productId, []); + + return ( +
+ +
+ ); + }; + + render(); + + expect(isRowPinned(getRowById(0), 'top')).to.equal(true, '#0 pinned top'); + expect(isRowPinned(getRowById(1), 'bottom')).to.equal(true, '#1 pinned bottom'); + }); + + it('should not be impacted by sorting', () => { + render(); + + expect(isRowPinned(getRowById(0), 'top')).to.equal(true, '#0 pinned top'); + expect(isRowPinned(getRowById(1), 'bottom')).to.equal(true, '#1 pinned bottom'); + expect(getColumnValues(0)).to.deep.equal(['0', '2', '3', '4', '1']); + + fireEvent.click(getColumnHeaderCell(0)); + + expect(isRowPinned(getRowById(0), 'top')).to.equal(true, '#0 pinned top'); + expect(isRowPinned(getRowById(1), 'bottom')).to.equal(true, '#1 pinned bottom'); + expect(getColumnValues(0)).to.deep.equal(['0', '2', '3', '4', '1']); + + fireEvent.click(getColumnHeaderCell(0)); + + expect(isRowPinned(getRowById(0), 'top')).to.equal(true, '#0 pinned top'); + expect(isRowPinned(getRowById(1), 'bottom')).to.equal(true, '#1 pinned bottom'); + expect(getColumnValues(0)).to.deep.equal(['0', '4', '3', '2', '1']); + }); + + it('should not be impacted by filtering', () => { + const { setProps } = render(); + + expect(isRowPinned(getRowById(0), 'top')).to.equal(true, '#0 pinned top'); + expect(isRowPinned(getRowById(1), 'bottom')).to.equal(true, '#1 pinned bottom'); + + setProps({ + filterModel: { + items: [{ columnField: 'currencyPair', operatorValue: 'equals', value: 'GBPEUR' }], + }, + }); + + expect(isRowPinned(getRowById(0), 'top')).to.equal(true, '#0 pinned top'); + expect(isRowPinned(getRowById(1), 'bottom')).to.equal(true, '#1 pinned bottom'); + + // should show pinned rows even if there's no filtering results + setProps({ + filterModel: { + items: [{ columnField: 'currencyPair', operatorValue: 'equals', value: 'whatever' }], + }, + }); + + expect(isRowPinned(getRowById(0), 'top')).to.equal(true, '#0 pinned top'); + expect(isRowPinned(getRowById(1), 'bottom')).to.equal(true, '#1 pinned bottom'); + }); + + it('should work when there is no rows data', () => { + render(); + + expect(isRowPinned(getRowById(0), 'top')).to.equal(true, '#0 pinned top'); + expect(isRowPinned(getRowById(1), 'bottom')).to.equal(true, '#1 pinned bottom'); + }); + + describe('keyboard navigation', () => { + function fireClickEvent(cell: HTMLElement) { + fireEvent.mouseUp(cell); + fireEvent.click(cell); + } + + function getActiveCellRowId() { + const cell = document.activeElement; + if (!cell || cell.getAttribute('role') !== 'cell') { + return undefined; + } + return cell.parentElement!.getAttribute('data-id'); + } + + it('should work with top pinned rows', () => { + const TestCase = () => { + const data = getData(20, 5); + const [pinnedRow0, pinnedRow1, ...rows] = data.rows; + + return ( +
+ +
+ ); + }; + + render(); + + expect(isRowPinned(getRowById(1), 'top')).to.equal(true, '#1 pinned top'); + expect(isRowPinned(getRowById(0), 'top')).to.equal(true, '#0 pinned top'); + + fireClickEvent(getCell(0, 0)); + // first top pinned row + expect(getActiveCellRowId()).to.equal('1'); + + fireEvent.keyDown(getCell(0, 0), { key: 'ArrowDown' }); + // second top pinned row + expect(getActiveCellRowId()).to.equal('0'); + + fireEvent.keyDown(getCell(1, 0), { key: 'ArrowDown' }); + // first non-pinned row + expect(getActiveCellRowId()).to.equal('2'); + + fireEvent.keyDown(getCell(2, 0), { key: 'ArrowRight' }); + fireEvent.keyDown(getCell(2, 1), { key: 'ArrowUp' }); + fireEvent.keyDown(getCell(1, 1), { key: 'ArrowUp' }); + fireEvent.keyDown(getCell(0, 1), { key: 'ArrowUp' }); + expect(getActiveColumnHeader()).to.equal('1'); + }); + + it('should work with bottom pinned rows', () => { + const TestCase = () => { + const data = getData(5, 5); + const [pinnedRow0, pinnedRow1, ...rows] = data.rows; + + return ( +
+ +
+ ); + }; + + render(); + + expect(isRowPinned(getRowById(0), 'bottom')).to.equal(true, '#0 pinned top'); + expect(isRowPinned(getRowById(1), 'bottom')).to.equal(true, '#1 pinned top'); + + fireClickEvent(getCell(0, 0)); + expect(getActiveCellRowId()).to.equal('2'); + + fireEvent.keyDown(getCell(0, 0), { key: 'ArrowDown' }); + expect(getActiveCellRowId()).to.equal('3'); + + fireEvent.keyDown(getCell(1, 0), { key: 'ArrowDown' }); + expect(getActiveCellRowId()).to.equal('4'); + + fireEvent.keyDown(getCell(2, 0), { key: 'ArrowDown' }); + expect(getActiveCellRowId()).to.equal('0'); + + fireEvent.keyDown(getCell(3, 0), { key: 'ArrowDown' }); + expect(getActiveCellRowId()).to.equal('1'); + }); + + it('should work with pinned columns', function test() { + if (isJSDOM) { + // Need layouting + this.skip(); + } + + const TestCase = () => { + const data = getData(5, 7); + const [pinnedRow0, pinnedRow1, ...rows] = data.rows; + + return ( +
+ +
+ ); + }; + + render(); + + expect(isRowPinned(getRowById(1), 'top')).to.equal(true, '#1 pinned top'); + expect(isRowPinned(getRowById(0), 'bottom')).to.equal(true, '#0 pinned bottom'); + + // top-pinned row + fireClickEvent(getCell(0, 3)); + expect(getActiveCell()).to.equal('0-3'); + expect(getActiveCellRowId()).to.equal('1'); + + fireEvent.keyDown(getCell(0, 3), { key: 'ArrowRight' }); + expect(getActiveCell()).to.equal('0-4'); + + fireEvent.keyDown(getCell(0, 4), { key: 'ArrowRight' }); + expect(getActiveCell()).to.equal('0-5'); + + // right-pinned column cell + fireEvent.keyDown(getCell(0, 5), { key: 'ArrowRight' }); + expect(getActiveCell()).to.equal('0-6'); + + // go through the right-pinned column all way down to bottom-pinned row + fireEvent.keyDown(getCell(0, 6), { key: 'ArrowDown' }); + expect(getActiveCell()).to.equal('1-6'); + expect(getActiveCellRowId()).to.equal('2'); + + fireEvent.keyDown(getCell(1, 6), { key: 'ArrowDown' }); + expect(getActiveCell()).to.equal('2-6'); + expect(getActiveCellRowId()).to.equal('3'); + + fireEvent.keyDown(getCell(2, 6), { key: 'ArrowDown' }); + expect(getActiveCell()).to.equal('3-6'); + expect(getActiveCellRowId()).to.equal('4'); + + fireEvent.keyDown(getCell(3, 6), { key: 'ArrowDown' }); + expect(getActiveCell()).to.equal('4-6'); + expect(getActiveCellRowId()).to.equal('0'); + }); + }); + + it('should work with variable row height', function test() { + if (isJSDOM) { + // Need layouting + this.skip(); + } + + render( + { + if (row.id === 0) { + return 100; + } + if (row.id === 1) { + return 20; + } + return undefined; + }} + />, + ); + + expect(getRowById(0)?.clientHeight).to.equal(100); + expect(getRowById(1)?.clientHeight).to.equal(20); + }); + + it('should always update on `rowHeight` change', function test() { + if (isJSDOM) { + // Need layouting + this.skip(); + } + + const defaultRowHeight = 52; + + const { setProps } = render( + , + ); + + expect(getRowById(0)?.clientHeight).to.equal(defaultRowHeight); + expect(document.querySelector(`.${gridClasses['pinnedRows--top']}`)?.clientHeight).to.equal( + defaultRowHeight, + ); + expect(getRowById(1)?.clientHeight).to.equal(defaultRowHeight); + expect(document.querySelector(`.${gridClasses['pinnedRows--bottom']}`)?.clientHeight).to.equal( + defaultRowHeight, + ); + + setProps({ rowHeight: 36 }); + + expect(getRowById(0)?.clientHeight).to.equal(36); + expect(document.querySelector(`.${gridClasses['pinnedRows--top']}`)?.clientHeight).to.equal(36); + expect(getRowById(1)?.clientHeight).to.equal(36); + expect(document.querySelector(`.${gridClasses['pinnedRows--bottom']}`)?.clientHeight).to.equal( + 36, + ); + }); + + it('should work with `autoHeight`', function test() { + if (isJSDOM) { + // Need layouting + this.skip(); + } + + const headerHeight = 56; + const rowHeight = 52; + const rowCount = 10; + + render( + , + ); + + expect(document.querySelector(`.${gridClasses.main}`)!.clientHeight).to.equal( + headerHeight + rowHeight * rowCount, + ); + }); + + it('should work with `autoPageSize`', function test() { + if (isJSDOM) { + // Need layouting + this.skip(); + } + + render( + , + ); + + // 300px grid height - 56px header = 244px available for rows + // 244px / 52px = 4 rows = 2 rows + 1 top-pinned row + 1 bottom-pinned row + expect(getRows().length).to.equal(4); + }); + + it('should not allow to expand detail panel of pinned row', () => { + render( +
{row.id}
} + />, + ); + + const cell = getCell(0, 0); + expect(cell.querySelector('[aria-label="Expand"]')).to.have.attribute('disabled'); + }); + + it('should not allow to reorder pinned rows', () => { + render(); + + const cell = getCell(0, 0); + expect(cell.querySelector(`.${gridClasses.rowReorderCell}`)).to.equal(null); + }); + + it('should keep pinned rows on page change', () => { + render( + , + ); + + expect(isRowPinned(getRowById(0), 'top')).to.equal(true, '#0 pinned top'); + expect(isRowPinned(getRowById(1), 'bottom')).to.equal(true, '#1 pinned bottom'); + + fireEvent.click(screen.getByRole('button', { name: /next page/i })); + + expect(isRowPinned(getRowById(0), 'top')).to.equal(true, '#0 pinned top'); + expect(isRowPinned(getRowById(1), 'bottom')).to.equal(true, '#1 pinned bottom'); + + fireEvent.click(screen.getByRole('button', { name: /next page/i })); + + expect(isRowPinned(getRowById(0), 'top')).to.equal(true, '#0 pinned top'); + expect(isRowPinned(getRowById(1), 'bottom')).to.equal(true, '#1 pinned bottom'); + }); + + it('should not count pinned rows as part of the page', () => { + const pageSize = 3; + + render( + , + ); + + expect(getRows().length).to.equal(pageSize + 2); // + 2 pinned rows + }); + + it('should render pinned rows outside of the tree data', () => { + const rows: GridRowsProp = [ + { id: 0, name: 'A' }, + { id: 1, name: 'A.B' }, + { id: 2, name: 'A.A' }, + { id: 3, name: 'B.A' }, + { id: 4, name: 'B.B' }, + ]; + + const columns = [{ field: 'name', width: 200 }]; + + const Test = () => { + const [pinnedRow0, pinnedRow1, ...rowsData] = rows; + + return ( +
+ row.name.split('.')} + rows={rowsData} + columns={columns} + pinnedRows={{ + top: [pinnedRow0], + bottom: [pinnedRow1], + }} + experimentalFeatures={{ rowPinning: true }} + /> +
+ ); + }; + + render(); + + expect(isRowPinned(getRowById(0), 'top')).to.equal(true, '#0 pinned top'); + expect(isRowPinned(getRowById(1), 'bottom')).to.equal(true, '#1 pinned bottom'); + }); + + it('should not be selectable', () => { + let apiRef: React.MutableRefObject; + + const TestCase = () => { + apiRef = useGridApiRef(); + return ; + }; + + render(); + + fireEvent.click(getCell(0, 0)); + expect(apiRef!.current.isRowSelected(0)).to.equal(false); + }); + + it('should not render selection checkbox for pinned rows', () => { + render(); + + expect(getRowById(0)!.querySelector('input[type="checkbox"]')).to.equal(null); + expect(getRowById(1)!.querySelector('input[type="checkbox"]')).to.equal(null); + }); + + it('should export pinned rows to CSV', () => { + let apiRef: React.MutableRefObject; + + const TestCase = () => { + apiRef = useGridApiRef(); + return ; + }; + + render(); + + const csv = apiRef!.current.getDataAsCsv({ + includeHeaders: false, + }); + + const csvRows = csv.split('\r\n'); + expect(csvRows[0]).to.equal('0'); + expect(csvRows[csvRows.length - 1]).to.equal('1'); + }); + + it('should include pinned rows in `aria-rowcount` attribute', () => { + const rowCount = 10; + + render(); + + expect(screen.getByRole('grid').getAttribute('aria-rowcount')).to.equal(`${rowCount + 1}`); // +1 for header row + }); +}); diff --git a/packages/grid/x-data-grid-pro/src/typeOverloads/modules.ts b/packages/grid/x-data-grid-pro/src/typeOverloads/modules.ts index c80e9501dfe3..b2f90c61545e 100644 --- a/packages/grid/x-data-grid-pro/src/typeOverloads/modules.ts +++ b/packages/grid/x-data-grid-pro/src/typeOverloads/modules.ts @@ -5,6 +5,7 @@ import type { GridPinnedColumns, } from '../hooks/features/columnPinning/gridColumnPinningInterface'; import type { GridCanBeReorderedPreProcessingContext } from '../hooks/features/columnReorder/columnReorderInterfaces'; +import { GridRowPinningInternalCache } from '../hooks/features/rowPinning/gridRowPinningInterface'; export interface GridControlledStateEventLookupPro { /** @@ -39,6 +40,7 @@ export interface GridPipeProcessingLookupPro { export interface GridApiCachesPro { columnPinning: GridColumnPinningInternalCache; + pinnedRows: GridRowPinningInternalCache; } declare module '@mui/x-data-grid' { diff --git a/packages/grid/x-data-grid/src/components/GridRow.tsx b/packages/grid/x-data-grid/src/components/GridRow.tsx index 39f2da92cd17..91dd428cdde7 100644 --- a/packages/grid/x-data-grid/src/components/GridRow.tsx +++ b/packages/grid/x-data-grid/src/components/GridRow.tsx @@ -151,7 +151,11 @@ function GridRow(props: React.HTMLAttributes & GridRowProps) { // doesn't care about pagination and considers the rows from the current page only, so the // first row always has index=0. We need to subtract the index of the first row to make it // compatible with the index used by the virtualization. - apiRef.current.unstable_setLastMeasuredRowIndex(index - currentPage.range.firstRowIndex); + const rowIndex = apiRef.current.getRowIndexRelativeToVisibleRows(rowId); + // pinned rows are not part of the visible rows + if (rowIndex != null) { + apiRef.current.unstable_setLastMeasuredRowIndex(rowIndex); + } } const rootElement = ref.current; diff --git a/packages/grid/x-data-grid/src/components/base/GridOverlays.tsx b/packages/grid/x-data-grid/src/components/base/GridOverlays.tsx index 3233becddb99..c4f1fec86337 100644 --- a/packages/grid/x-data-grid/src/components/base/GridOverlays.tsx +++ b/packages/grid/x-data-grid/src/components/base/GridOverlays.tsx @@ -44,7 +44,7 @@ function GridOverlayWrapper(props: React.PropsWithChildren<{}>) { position: 'absolute', top: headerHeight, bottom: height === 'auto' ? 0 : undefined, - zIndex: 3, // should be above pinned columns and detail panel + zIndex: 4, // should be above pinned columns, pinned rows and detail panel pointerEvents: 'none', }} {...props} diff --git a/packages/grid/x-data-grid/src/components/columnSelection/GridCellCheckboxRenderer.tsx b/packages/grid/x-data-grid/src/components/columnSelection/GridCellCheckboxRenderer.tsx index 9a04101267d7..15ecc36fa464 100644 --- a/packages/grid/x-data-grid/src/components/columnSelection/GridCellCheckboxRenderer.tsx +++ b/packages/grid/x-data-grid/src/components/columnSelection/GridCellCheckboxRenderer.tsx @@ -97,6 +97,10 @@ const GridCellCheckboxForwardRef = React.forwardRef(function GridRo const densityValue = useGridSelector(apiRef, gridDensityValueSelector); const rootContainerRef: GridRootContainerRef = React.useRef(null); const handleRef = useForkRef(rootContainerRef, ref); + const pinnedRowsCount = useGridSelector(apiRef, gridPinnedRowsCountSelector); const ownerState = { density: densityValue, @@ -86,7 +90,7 @@ const GridRoot = React.forwardRef(function GridRo className={clsx(className, classes.root)} role="grid" aria-colcount={visibleColumns.length} - aria-rowcount={totalRowCount} + aria-rowcount={totalRowCount + pinnedRowsCount + 1} // +1 for the header row aria-multiselectable={!rootProps.disableMultipleSelection} aria-label={rootProps['aria-label']} aria-labelledby={rootProps['aria-labelledby']} diff --git a/packages/grid/x-data-grid/src/constants/gridClasses.ts b/packages/grid/x-data-grid/src/constants/gridClasses.ts index da9038a92dca..982b7eb2eb1d 100644 --- a/packages/grid/x-data-grid/src/constants/gridClasses.ts +++ b/packages/grid/x-data-grid/src/constants/gridClasses.ts @@ -446,6 +446,22 @@ export interface GridClasses { * Styles applied to the toggle of the grouping criteria cell */ groupingCriteriaCellToggle: string; + /** + * Styles applied to the pinned rows container. + */ + pinnedRows: string; + /** + * Styles applied to the top pinned rows container. + */ + 'pinnedRows--top': string; + /** + * Styles applied to the bottom pinned rows container. + */ + 'pinnedRows--bottom': string; + /** + * Styles applied to pinned rows render zones. + */ + pinnedRowsRenderZone: string; } export type GridClassKey = keyof GridClasses; @@ -565,4 +581,8 @@ export const gridClasses = generateUtilityClasses('MuiDataGrid', [ 'treeDataGroupingCellToggle', 'groupingCriteriaCell', 'groupingCriteriaCellToggle', + 'pinnedRows', + 'pinnedRows--top', + 'pinnedRows--bottom', + 'pinnedRowsRenderZone', ]); diff --git a/packages/grid/x-data-grid/src/hooks/features/dimensions/useGridDimensions.ts b/packages/grid/x-data-grid/src/hooks/features/dimensions/useGridDimensions.ts index d4d6f6eafaf8..173b50c6c9a3 100644 --- a/packages/grid/x-data-grid/src/hooks/features/dimensions/useGridDimensions.ts +++ b/packages/grid/x-data-grid/src/hooks/features/dimensions/useGridDimensions.ts @@ -20,6 +20,7 @@ import { gridDensityHeaderHeightSelector, gridDensityRowHeightSelector } from '. import { useGridSelector } from '../../utils'; import { getVisibleRows } from '../../utils/useGridVisibleRows'; import { gridRowsMetaSelector } from '../rows/gridRowsMetaSelector'; +import { calculatePinnedRowsHeight } from '../rows/gridRowsUtils'; const isTestEnvironment = process.env.NODE_ENV === 'test'; @@ -67,6 +68,7 @@ export function useGridDimensions( const updateGridDimensionsRef = React.useCallback(() => { const rootElement = apiRef.current.rootElementRef?.current; const columnsTotalWidth = gridColumnsTotalWidthSelector(apiRef); + const pinnedRowsHeight = calculatePinnedRowsHeight(apiRef); if (!rootDimensionsRef.current) { return; @@ -110,7 +112,10 @@ export function useGridDimensions( const scrollInformation = hasScroll({ content: { width: Math.round(columnsTotalWidth), height: rowsMeta.currentPageTotalHeight }, - container: viewportOuterSize, + container: { + width: viewportOuterSize.width, + height: viewportOuterSize.height - pinnedRowsHeight.top - pinnedRowsHeight.bottom, + }, scrollBarSize, }); diff --git a/packages/grid/x-data-grid/src/hooks/features/editRows/useGridEditing.new.ts b/packages/grid/x-data-grid/src/hooks/features/editRows/useGridEditing.new.ts index 6518375c01b3..13774a7cd180 100644 --- a/packages/grid/x-data-grid/src/hooks/features/editRows/useGridEditing.new.ts +++ b/packages/grid/x-data-grid/src/hooks/features/editRows/useGridEditing.new.ts @@ -47,6 +47,9 @@ export const useGridEditing = ( if (isCellEditableProp) { return isCellEditableProp(params); } + if (params.rowNode.isPinned) { + return false; + } return true; }, [isCellEditableProp], diff --git a/packages/grid/x-data-grid/src/hooks/features/editRows/useGridEditing.old.ts b/packages/grid/x-data-grid/src/hooks/features/editRows/useGridEditing.old.ts index 279a38c3320b..db609fd637ec 100644 --- a/packages/grid/x-data-grid/src/hooks/features/editRows/useGridEditing.old.ts +++ b/packages/grid/x-data-grid/src/hooks/features/editRows/useGridEditing.old.ts @@ -64,6 +64,7 @@ export function useGridEditing( const isCellEditable = React.useCallback( (params: GridCellParams) => !params.rowNode.isAutoGenerated && + !params.rowNode.isPinned && !!params.colDef.editable && !!params.colDef!.renderEditCell && (!props.isCellEditable || props.isCellEditable(params)), diff --git a/packages/grid/x-data-grid/src/hooks/features/export/utils.ts b/packages/grid/x-data-grid/src/hooks/features/export/utils.ts index da8dea2ebde7..dad03d462f8a 100644 --- a/packages/grid/x-data-grid/src/hooks/features/export/utils.ts +++ b/packages/grid/x-data-grid/src/hooks/features/export/utils.ts @@ -5,7 +5,7 @@ import { GridExportOptions, GridCsvGetRowsToExportParams } from '../../../models import { GridStateColDef } from '../../../models/colDef/gridColDef'; import { gridFilteredSortedRowIdsSelector } from '../filter'; import { GridRowId } from '../../../models'; -import { gridRowTreeSelector } from '../rows/gridRowsSelector'; +import { gridPinnedRowsSelector, gridRowTreeSelector } from '../rows/gridRowsSelector'; interface GridGetColumnsToExportParams { /** @@ -36,6 +36,12 @@ export const defaultGetRowsToExport = ({ apiRef }: GridCsvGetRowsToExportParams) const rowTree = gridRowTreeSelector(apiRef); const selectedRows = apiRef.current.getSelectedRows(); const bodyRows = filteredSortedRowIds.filter((id) => (rowTree[id].position ?? 'body') === 'body'); + const pinnedRows = gridPinnedRowsSelector(apiRef); + const topPinnedRowsIds = pinnedRows?.top?.map((row) => row.id) || []; + const bottomPinnedRowsIds = pinnedRows?.bottom?.map((row) => row.id) || []; + + bodyRows.unshift(...topPinnedRowsIds); + bodyRows.push(...bottomPinnedRowsIds); if (selectedRows.size > 0) { return bodyRows.filter((id) => selectedRows.has(id)); diff --git a/packages/grid/x-data-grid/src/hooks/features/keyboardNavigation/useGridKeyboardNavigation.ts b/packages/grid/x-data-grid/src/hooks/features/keyboardNavigation/useGridKeyboardNavigation.ts index 84e9cba3caf2..c286ce7bfe2e 100644 --- a/packages/grid/x-data-grid/src/hooks/features/keyboardNavigation/useGridKeyboardNavigation.ts +++ b/packages/grid/x-data-grid/src/hooks/features/keyboardNavigation/useGridKeyboardNavigation.ts @@ -13,6 +13,17 @@ import { gridClasses } from '../../../constants/gridClasses'; import { GridCellModes } from '../../../models/gridEditRowModel'; import { isNavigationKey } from '../../../utils/keyboardUtils'; import { GRID_DETAIL_PANEL_TOGGLE_FIELD } from '../../../constants/gridDetailPanelToggleField'; +import { GridRowEntry, GridRowId } from '../../../models'; +import { gridPinnedRowsSelector } from '../rows/gridRowsSelector'; + +function enrichPageRowsWithPinnedRows( + apiRef: React.MutableRefObject, + rows: GridRowEntry[], +) { + const pinnedRows = gridPinnedRowsSelector(apiRef) || {}; + + return [...(pinnedRows.top || []), ...rows, ...(pinnedRows.bottom || [])]; +} /** * @requires useGridSorting (method) - can be after @@ -25,10 +36,15 @@ import { GRID_DETAIL_PANEL_TOGGLE_FIELD } from '../../../constants/gridDetailPan */ export const useGridKeyboardNavigation = ( apiRef: React.MutableRefObject, - props: Pick, + props: Pick, ): void => { const logger = useGridLogger(apiRef, 'useGridKeyboardNavigation'); - const currentPage = useGridVisibleRows(apiRef, props); + const initialCurrentPageRows = useGridVisibleRows(apiRef, props).rows; + + const currentPageRows = React.useMemo( + () => enrichPageRowsWithPinnedRows(apiRef, initialCurrentPageRows), + [apiRef, initialCurrentPageRows], + ); /** * @param {number} colIndex Index of the column to focus @@ -36,9 +52,8 @@ export const useGridKeyboardNavigation = ( * @param {string} closestColumnToUse Which closest column cell to use when the cell is spanned by `colSpan`. */ const goToCell = React.useCallback( - (colIndex: number, rowIndex: number, closestColumnToUse: 'left' | 'right' = 'left') => { + (colIndex: number, rowId: GridRowId, closestColumnToUse: 'left' | 'right' = 'left') => { const visibleSortedRows = gridVisibleSortedRowEntriesSelector(apiRef); - const rowId = visibleSortedRows[rowIndex]?.id; const nextCellColSpanInfo = apiRef.current.unstable_getCellColSpanInfo(rowId, colIndex); if (nextCellColSpanInfo && nextCellColSpanInfo.spannedByColSpan) { if (closestColumnToUse === 'left') { @@ -47,8 +62,14 @@ export const useGridKeyboardNavigation = ( colIndex = nextCellColSpanInfo.rightVisibleCellIndex; } } - logger.debug(`Navigating to cell row ${rowIndex}, col ${colIndex}`); - apiRef.current.scrollToIndexes({ colIndex, rowIndex }); + // `scrollToIndexes` requires a rowIndex relative to all visible rows. + // Those rows do not include pinned rows, but pinned rows do not need scroll anyway. + const rowIndexRelativeToAllRows = visibleSortedRows.findIndex((row) => row.id === rowId); + logger.debug(`Navigating to cell row ${rowIndexRelativeToAllRows}, col ${colIndex}`); + apiRef.current.scrollToIndexes({ + colIndex, + rowIndex: rowIndexRelativeToAllRows, + }); const field = apiRef.current.getVisibleColumns()[colIndex].field; apiRef.current.setCellFocus(rowId, field); }, @@ -65,21 +86,28 @@ export const useGridKeyboardNavigation = ( [apiRef, logger], ); + const getRowIdFromIndex = React.useCallback( + (rowIndex: number) => { + return currentPageRows[rowIndex].id; + }, + [currentPageRows], + ); + const handleCellNavigationKeyDown = React.useCallback>( (params, event) => { const dimensions = apiRef.current.getRootDimensions(); - if (!currentPage.range || !dimensions) { + if (currentPageRows.length === 0 || !dimensions) { return; } const viewportPageSize = apiRef.current.unstable_getViewportPageSize(); - const visibleSortedRows = gridVisibleSortedRowEntriesSelector(apiRef); + const colIndexBefore = (params as GridCellParams).field ? apiRef.current.getColumnIndex((params as GridCellParams).field) : 0; - const rowIndexBefore = visibleSortedRows.findIndex((row) => row.id === params.id); - const firstRowIndexInPage = currentPage.range.firstRowIndex; - const lastRowIndexInPage = currentPage.range.lastRowIndex; + const rowIndexBefore = currentPageRows.findIndex((row) => row.id === params.id); + const firstRowIndexInPage = 0; + const lastRowIndexInPage = currentPageRows.length - 1; const firstColIndex = 0; const lastColIndex = gridVisibleColumnDefinitionsSelector(apiRef).length - 1; let shouldPreventDefault = true; @@ -89,14 +117,14 @@ export const useGridKeyboardNavigation = ( case 'Enter': { // "Enter" is only triggered by the row / cell editing feature if (rowIndexBefore < lastRowIndexInPage) { - goToCell(colIndexBefore, rowIndexBefore + 1); + goToCell(colIndexBefore, getRowIdFromIndex(rowIndexBefore + 1)); } break; } case 'ArrowUp': { if (rowIndexBefore > firstRowIndexInPage) { - goToCell(colIndexBefore, rowIndexBefore - 1); + goToCell(colIndexBefore, getRowIdFromIndex(rowIndexBefore - 1)); } else { goToHeader(colIndexBefore, event); } @@ -105,14 +133,14 @@ export const useGridKeyboardNavigation = ( case 'ArrowRight': { if (colIndexBefore < lastColIndex) { - goToCell(colIndexBefore + 1, rowIndexBefore, 'right'); + goToCell(colIndexBefore + 1, getRowIdFromIndex(rowIndexBefore), 'right'); } break; } case 'ArrowLeft': { if (colIndexBefore > firstColIndex) { - goToCell(colIndexBefore - 1, rowIndexBefore); + goToCell(colIndexBefore - 1, getRowIdFromIndex(rowIndexBefore)); } break; } @@ -120,9 +148,9 @@ export const useGridKeyboardNavigation = ( case 'Tab': { // "Tab" is only triggered by the row / cell editing feature if (event.shiftKey && colIndexBefore > firstColIndex) { - goToCell(colIndexBefore - 1, rowIndexBefore, 'left'); + goToCell(colIndexBefore - 1, getRowIdFromIndex(rowIndexBefore), 'left'); } else if (!event.shiftKey && colIndexBefore < lastColIndex) { - goToCell(colIndexBefore + 1, rowIndexBefore, 'right'); + goToCell(colIndexBefore + 1, getRowIdFromIndex(rowIndexBefore), 'right'); } break; } @@ -139,7 +167,7 @@ export const useGridKeyboardNavigation = ( if (!event.shiftKey && rowIndexBefore < lastRowIndexInPage) { goToCell( colIndexBefore, - Math.min(rowIndexBefore + viewportPageSize, lastRowIndexInPage), + getRowIdFromIndex(Math.min(rowIndexBefore + viewportPageSize, lastRowIndexInPage)), ); } break; @@ -149,7 +177,7 @@ export const useGridKeyboardNavigation = ( if (rowIndexBefore < lastRowIndexInPage) { goToCell( colIndexBefore, - Math.min(rowIndexBefore + viewportPageSize, lastRowIndexInPage), + getRowIdFromIndex(Math.min(rowIndexBefore + viewportPageSize, lastRowIndexInPage)), ); } break; @@ -159,7 +187,7 @@ export const useGridKeyboardNavigation = ( // Go to the first row before going to header const nextRowIndex = Math.max(rowIndexBefore - viewportPageSize, firstRowIndexInPage); if (nextRowIndex !== rowIndexBefore && nextRowIndex >= firstRowIndexInPage) { - goToCell(colIndexBefore, nextRowIndex); + goToCell(colIndexBefore, getRowIdFromIndex(nextRowIndex)); } else { goToHeader(colIndexBefore, event); } @@ -168,18 +196,18 @@ export const useGridKeyboardNavigation = ( case 'Home': { if (event.ctrlKey || event.metaKey || event.shiftKey) { - goToCell(firstColIndex, firstRowIndexInPage); + goToCell(firstColIndex, getRowIdFromIndex(firstRowIndexInPage)); } else { - goToCell(firstColIndex, rowIndexBefore); + goToCell(firstColIndex, getRowIdFromIndex(rowIndexBefore)); } break; } case 'End': { if (event.ctrlKey || event.metaKey || event.shiftKey) { - goToCell(lastColIndex, lastRowIndexInPage); + goToCell(lastColIndex, getRowIdFromIndex(lastRowIndexInPage)); } else { - goToCell(lastColIndex, rowIndexBefore); + goToCell(lastColIndex, getRowIdFromIndex(rowIndexBefore)); } break; } @@ -193,7 +221,7 @@ export const useGridKeyboardNavigation = ( event.preventDefault(); } }, - [apiRef, currentPage, goToCell, goToHeader], + [apiRef, currentPageRows, goToCell, goToHeader, getRowIdFromIndex], ); const handleColumnHeaderKeyDown = React.useCallback>( @@ -217,8 +245,8 @@ export const useGridKeyboardNavigation = ( const viewportPageSize = apiRef.current.unstable_getViewportPageSize(); const colIndexBefore = params.field ? apiRef.current.getColumnIndex(params.field) : 0; - const firstRowIndexInPage = currentPage.range?.firstRowIndex ?? null; - const lastRowIndexInPage = currentPage.range?.lastRowIndex ?? null; + const firstRowIndexInPage = 0; + const lastRowIndexInPage = currentPageRows.length - 1; const firstColIndex = 0; const lastColIndex = gridVisibleColumnDefinitionsSelector(apiRef).length - 1; let shouldPreventDefault = true; @@ -226,7 +254,7 @@ export const useGridKeyboardNavigation = ( switch (event.key) { case 'ArrowDown': { if (firstRowIndexInPage !== null) { - goToCell(colIndexBefore, firstRowIndexInPage); + goToCell(colIndexBefore, getRowIdFromIndex(firstRowIndexInPage)); } break; } @@ -249,7 +277,9 @@ export const useGridKeyboardNavigation = ( if (firstRowIndexInPage !== null && lastRowIndexInPage !== null) { goToCell( colIndexBefore, - Math.min(firstRowIndexInPage + viewportPageSize, lastRowIndexInPage), + getRowIdFromIndex( + Math.min(firstRowIndexInPage + viewportPageSize, lastRowIndexInPage), + ), ); } break; @@ -286,7 +316,7 @@ export const useGridKeyboardNavigation = ( event.preventDefault(); } }, - [apiRef, currentPage, goToCell, goToHeader], + [apiRef, currentPageRows, goToCell, goToHeader, getRowIdFromIndex], ); const handleCellKeyDown = React.useCallback>( diff --git a/packages/grid/x-data-grid/src/hooks/features/pagination/useGridPageSize.ts b/packages/grid/x-data-grid/src/hooks/features/pagination/useGridPageSize.ts index d846f5f55971..08f013151562 100644 --- a/packages/grid/x-data-grid/src/hooks/features/pagination/useGridPageSize.ts +++ b/packages/grid/x-data-grid/src/hooks/features/pagination/useGridPageSize.ts @@ -12,6 +12,7 @@ import { import { gridPageSizeSelector } from './gridPaginationSelector'; import { gridDensityRowHeightSelector } from '../density'; import { GridPipeProcessor, useGridRegisterPipeProcessor } from '../../core/pipeProcessing'; +import { calculatePinnedRowsHeight } from '../rows/gridRowsUtils'; export const defaultPageSize = (autoPageSize: boolean) => (autoPageSize ? 0 : 100); @@ -127,8 +128,11 @@ export const useGridPageSize = ( return; } + const pinnedRowsHeight = calculatePinnedRowsHeight(apiRef); + const maximumPageSizeWithoutScrollBar = Math.floor( - dimensions.viewportInnerSize.height / rowHeight, + (dimensions.viewportInnerSize.height - pinnedRowsHeight.top - pinnedRowsHeight.bottom) / + rowHeight, ); apiRef.current.setPageSize(maximumPageSizeWithoutScrollBar); }, [apiRef, props.autoPageSize, rowHeight]); diff --git a/packages/grid/x-data-grid/src/hooks/features/rows/gridRowsSelector.ts b/packages/grid/x-data-grid/src/hooks/features/rows/gridRowsSelector.ts index 6a93aa7c56fe..575ebe5889c4 100644 --- a/packages/grid/x-data-grid/src/hooks/features/rows/gridRowsSelector.ts +++ b/packages/grid/x-data-grid/src/hooks/features/rows/gridRowsSelector.ts @@ -41,3 +41,26 @@ export const gridRowTreeDepthSelector = createSelector( ); export const gridRowIdsSelector = createSelector(gridRowsStateSelector, (rows) => rows.ids); + +/** + * @ignore - do not document. + */ +export const gridAdditionalRowGroupsSelector = createSelector( + gridRowsStateSelector, + (rows) => rows?.additionalRowGroups, +); + +/** + * @ignore - do not document. + */ +export const gridPinnedRowsSelector = createSelector( + gridAdditionalRowGroupsSelector, + (additionalRowGroups) => additionalRowGroups?.pinnedRows, +); + +/** + * @ignore - do not document. + */ +export const gridPinnedRowsCountSelector = createSelector(gridPinnedRowsSelector, (pinnedRows) => { + return (pinnedRows?.top?.length || 0) + (pinnedRows?.bottom?.length || 0); +}); diff --git a/packages/grid/x-data-grid/src/hooks/features/rows/gridRowsState.ts b/packages/grid/x-data-grid/src/hooks/features/rows/gridRowsState.ts index 25e10f110e18..8166b617028c 100644 --- a/packages/grid/x-data-grid/src/hooks/features/rows/gridRowsState.ts +++ b/packages/grid/x-data-grid/src/hooks/features/rows/gridRowsState.ts @@ -1,4 +1,9 @@ -import { GridRowId, GridRowsLookup, GridRowTreeConfig } from '../../../models/gridRows'; +import { + GridRowId, + GridRowsLookup, + GridRowTreeConfig, + GridRowEntry, +} from '../../../models/gridRows'; import type { DataGridProcessedProps } from '../../../models/props/DataGridProps'; export interface GridRowTreeCreationParams { @@ -19,6 +24,9 @@ export interface GridRowTreeCreationValue { ids: GridRowId[]; idRowsLookup: GridRowsLookup; idToIdLookup: Record; + additionalRowGroups?: { + pinnedRows?: GridPinnedRowsState; + }; } export interface GridRowsInternalCache extends Omit { @@ -56,3 +64,8 @@ export interface GridRowsState extends GridRowTreeCreationValue { } export type GridHydrateRowsValue = GridRowTreeCreationValue; + +export interface GridPinnedRowsState { + top?: GridRowEntry[]; + bottom?: GridRowEntry[]; +} diff --git a/packages/grid/x-data-grid/src/hooks/features/rows/gridRowsUtils.ts b/packages/grid/x-data-grid/src/hooks/features/rows/gridRowsUtils.ts index 41c7840ecb58..49e2b87d2371 100644 --- a/packages/grid/x-data-grid/src/hooks/features/rows/gridRowsUtils.ts +++ b/packages/grid/x-data-grid/src/hooks/features/rows/gridRowsUtils.ts @@ -3,6 +3,7 @@ import { GridRowId, GridRowIdGetter, GridRowModel, GridRowTreeConfig } from '../ import { DataGridProcessedProps } from '../../../models/props/DataGridProps'; import { GridApiCommunity } from '../../../models/api/gridApiCommunity'; import { GridRowsInternalCache, GridRowsState } from './gridRowsState'; +import { gridPinnedRowsSelector } from './gridRowsSelector'; /** * A helper function to check if the id provided is valid. @@ -88,7 +89,9 @@ export const getRowsStateFromCache = ({ const dataTopLevelRowCount = processedGroupingResponse.treeDepth === 1 ? processedGroupingResponse.ids.length - : Object.values(processedGroupingResponse.tree).filter((node) => node.parent == null).length; + : Object.values(processedGroupingResponse.tree).filter( + (node) => node.parent == null && !node.isPinned, + ).length; return { ...processedGroupingResponse, @@ -121,3 +124,23 @@ export const getTreeNodeDescendants = ( return validDescendants; }; + +export function calculatePinnedRowsHeight(apiRef: React.MutableRefObject) { + const pinnedRows = gridPinnedRowsSelector(apiRef); + const topPinnedRowsHeight = + pinnedRows?.top?.reduce((acc, value) => { + acc += apiRef.current.unstable_getRowHeight(value.id); + return acc; + }, 0) || 0; + + const bottomPinnedRowsHeight = + pinnedRows?.bottom?.reduce((acc, value) => { + acc += apiRef.current.unstable_getRowHeight(value.id); + return acc; + }, 0) || 0; + + return { + top: topPinnedRowsHeight, + bottom: bottomPinnedRowsHeight, + }; +} diff --git a/packages/grid/x-data-grid/src/hooks/features/rows/index.ts b/packages/grid/x-data-grid/src/hooks/features/rows/index.ts index d555e69dcb43..ab28eca3c4be 100644 --- a/packages/grid/x-data-grid/src/hooks/features/rows/index.ts +++ b/packages/grid/x-data-grid/src/hooks/features/rows/index.ts @@ -1,5 +1,16 @@ export * from './gridRowsMetaSelector'; export * from './gridRowsMetaState'; -export * from './gridRowsSelector'; +export { + gridRowsStateSelector, + gridRowCountSelector, + gridRowsLoadingSelector, + gridTopLevelRowCountSelector, + gridRowsLookupSelector, + gridRowsIdToIdLookupSelector, + gridRowTreeSelector, + gridRowGroupingNameSelector, + gridRowTreeDepthSelector, + gridRowIdsSelector, +} from './gridRowsSelector'; export type { GridRowsState } from './gridRowsState'; export { checkGridRowIdIsValid } from './gridRowsUtils'; diff --git a/packages/grid/x-data-grid/src/hooks/features/rows/useGridRowsMeta.ts b/packages/grid/x-data-grid/src/hooks/features/rows/useGridRowsMeta.ts index 9ab2f321ef48..3174e6ed9cbf 100644 --- a/packages/grid/x-data-grid/src/hooks/features/rows/useGridRowsMeta.ts +++ b/packages/grid/x-data-grid/src/hooks/features/rows/useGridRowsMeta.ts @@ -5,7 +5,7 @@ import { GridRowsMetaApi } from '../../../models/api/gridRowsMetaApi'; import { DataGridProcessedProps } from '../../../models/props/DataGridProps'; import { useGridVisibleRows } from '../../utils/useGridVisibleRows'; import { useGridApiMethod } from '../../utils/useGridApiMethod'; -import { GridRowId } from '../../../models/gridRows'; +import { GridRowEntry, GridRowId } from '../../../models/gridRows'; import { useGridSelector } from '../../utils/useGridSelector'; import { gridDensityRowHeightSelector, @@ -16,6 +16,7 @@ import { gridPaginationSelector } from '../pagination/gridPaginationSelector'; import { gridSortingStateSelector } from '../sorting/gridSortingSelector'; import { GridStateInitializer } from '../../utils/useGridInitializeState'; import { useGridRegisterPipeApplier } from '../../core/pipeProcessing'; +import { gridPinnedRowsSelector } from './gridRowsSelector'; export const rowsMetaStateInitializer: GridStateInitializer = (state) => ({ ...state, @@ -55,6 +56,8 @@ export const useGridRowsMeta = ( const sortingState = useGridSelector(apiRef, gridSortingStateSelector); const currentPage = useGridVisibleRows(apiRef, props); + const pinnedRows = useGridSelector(apiRef, gridPinnedRowsSelector); + const hydrateRowsMeta = React.useCallback(() => { hasRowWithAutoHeight.current = false; @@ -63,10 +66,7 @@ export const useGridRowsMeta = ( apiRef.current.instanceId, ); - const positions: number[] = []; - const currentPageTotalHeight = currentPage.rows.reduce((acc, row) => { - positions.push(acc); - + const calculateRowProcessedSizes = (row: GridRowEntry) => { if (!rowsHeightLookup.current[row.id]) { rowsHeightLookup.current[row.id] = { sizes: { base: rowHeightFromDensity }, @@ -135,10 +135,27 @@ export const useGridRowsMeta = ( rowsHeightLookup.current[row.id].sizes = processedSizes; + return processedSizes; + }; + + const positions: number[] = []; + const currentPageTotalHeight = currentPage.rows.reduce((acc, row) => { + positions.push(acc); + + const processedSizes = calculateRowProcessedSizes(row); + const finalRowHeight = Object.values(processedSizes).reduce((acc2, value) => acc2 + value, 0); return acc + finalRowHeight; }, 0); + pinnedRows?.top?.forEach((row) => { + calculateRowProcessedSizes(row); + }); + + pinnedRows?.bottom?.forEach((row) => { + calculateRowProcessedSizes(row); + }); + apiRef.current.setState((state) => { return { ...state, @@ -162,6 +179,7 @@ export const useGridRowsMeta = ( getRowHeightProp, getRowSpacing, getEstimatedRowHeight, + pinnedRows, ]); const getRowHeight = React.useCallback( diff --git a/packages/grid/x-data-grid/src/hooks/features/rows/useGridRowsPreProcessors.tsx b/packages/grid/x-data-grid/src/hooks/features/rows/useGridRowsPreProcessors.tsx index e38372154b9a..f6b73d9e5615 100644 --- a/packages/grid/x-data-grid/src/hooks/features/rows/useGridRowsPreProcessors.tsx +++ b/packages/grid/x-data-grid/src/hooks/features/rows/useGridRowsPreProcessors.tsx @@ -21,7 +21,9 @@ const flatRowTreeCreationMethod: GridStrategyProcessor<'rowTreeCreation'> = ({ previousTree && previousTree[rowId] && previousTree[rowId].depth === 0 && - previousTree[rowId].parent == null + previousTree[rowId].parent == null && + // pinned row can be unpinned + !previousTree[rowId].isPinned ) { tree[rowId] = previousTree[rowId]; } else { diff --git a/packages/grid/x-data-grid/src/hooks/features/scroll/useGridScroll.ts b/packages/grid/x-data-grid/src/hooks/features/scroll/useGridScroll.ts index c1b41e5f7daf..476188cfa67c 100644 --- a/packages/grid/x-data-grid/src/hooks/features/scroll/useGridScroll.ts +++ b/packages/grid/x-data-grid/src/hooks/features/scroll/useGridScroll.ts @@ -15,6 +15,7 @@ import { GridScrollParams } from '../../../models/params/gridScrollParams'; import { GridScrollApi } from '../../../models/api/gridScrollApi'; import { useGridApiMethod } from '../../utils/useGridApiMethod'; import { gridVisibleSortedRowEntriesSelector } from '../filter/gridFilterSelector'; +import { gridClasses } from '../../../constants/gridClasses'; // Logic copied from https://www.w3.org/TR/wai-aria-practices/examples/listbox/js/listbox.js // Similar to https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView @@ -111,8 +112,15 @@ export const useGridScroll = ( ? rowsMeta.positions[elementIndex + 1] - rowsMeta.positions[elementIndex] : rowsMeta.currentPageTotalHeight - rowsMeta.positions[elementIndex]; + const topPinnedRowsHeight = + windowRef.current!.querySelector(`.${gridClasses['pinnedRows--top']}`)?.clientHeight || 0; + const bottomPinnedRowsHeight = + windowRef.current!.querySelector(`.${gridClasses['pinnedRows--bottom']}`)?.clientHeight || + 0; + scrollCoordinates.top = scrollIntoView({ - clientHeight: windowRef.current!.clientHeight, + clientHeight: + windowRef.current!.clientHeight - topPinnedRowsHeight - bottomPinnedRowsHeight, scrollTop: windowRef.current!.scrollTop, offsetHeight: targetOffsetHeight, offsetTop: rowsMeta.positions[elementIndex], diff --git a/packages/grid/x-data-grid/src/hooks/features/selection/useGridSelection.ts b/packages/grid/x-data-grid/src/hooks/features/selection/useGridSelection.ts index b7d215ffc7ba..89afe964461b 100644 --- a/packages/grid/x-data-grid/src/hooks/features/selection/useGridSelection.ts +++ b/packages/grid/x-data-grid/src/hooks/features/selection/useGridSelection.ts @@ -156,7 +156,8 @@ export const useGridSelection = ( return false; } - if (apiRef.current.getRowNode(id)?.position === 'footer') { + const rowNode = apiRef.current.getRowNode(id); + if (rowNode?.position === 'footer' || rowNode?.isPinned) { return false; } @@ -350,6 +351,10 @@ export const useGridSelection = ( } } + if (params.rowNode.isPinned) { + return; + } + if (event.shiftKey && (canHaveMultipleSelection || checkboxSelection)) { expandMouseRowRangeSelection(params.id); } else { diff --git a/packages/grid/x-data-grid/src/hooks/features/sorting/useGridSorting.ts b/packages/grid/x-data-grid/src/hooks/features/sorting/useGridSorting.ts index 5bec075de16e..e76a0b41abb1 100644 --- a/packages/grid/x-data-grid/src/hooks/features/sorting/useGridSorting.ts +++ b/packages/grid/x-data-grid/src/hooks/features/sorting/useGridSorting.ts @@ -275,6 +275,9 @@ export const useGridSorting = ( const footerRowIds: GridRowId[] = []; gridRowIdsSelector(apiRef).forEach((rowId) => { + if (rowTree[rowId].isPinned) { + return; + } if (rowTree[rowId].position === 'footer') { footerRowIds.push(rowId); } else { @@ -289,6 +292,9 @@ export const useGridSorting = ( const footerRowIds: GridRowId[] = []; Object.values(rowTree).forEach((rowNode) => { + if (rowNode.isPinned) { + return; + } if (rowNode.position === 'footer') { footerRowIds.push(rowNode.id); } else { diff --git a/packages/grid/x-data-grid/src/hooks/features/virtualization/useGridVirtualScroller.tsx b/packages/grid/x-data-grid/src/hooks/features/virtualization/useGridVirtualScroller.tsx index b57490c41589..512c89df9fbf 100644 --- a/packages/grid/x-data-grid/src/hooks/features/virtualization/useGridVirtualScroller.tsx +++ b/packages/grid/x-data-grid/src/hooks/features/virtualization/useGridVirtualScroller.tsx @@ -367,6 +367,8 @@ export const useGridVirtualScroller = (props: UseGridVirtualScrollerProps) => { maxLastColumn?: number; availableSpace?: number | null; ignoreAutoHeight?: boolean; + rows?: GridRowEntry[]; + rowIndexOffset?: number; } = { renderContext }, ) => { const { @@ -375,9 +377,10 @@ export const useGridVirtualScroller = (props: UseGridVirtualScrollerProps) => { maxLastColumn = renderZoneMaxColumnIndex, availableSpace = containerWidth, ignoreAutoHeight, + rowIndexOffset = 0, } = params; - if (!currentPage.range || !nextRenderContext || availableSpace == null) { + if (!nextRenderContext || availableSpace == null) { return null; } @@ -394,16 +397,31 @@ export const useGridVirtualScroller = (props: UseGridVirtualScrollerProps) => { const renderedRows: GridRowEntry[] = []; - for (let i = firstRowToRender; i < lastRowToRender; i += 1) { - const row = currentPage.rows[i]; - renderedRows.push(row); - - apiRef.current.unstable_calculateColSpan({ - rowId: row.id, - minFirstColumn, - maxLastColumn, - columns: visibleColumns, + if (params.rows) { + params.rows.forEach((row) => { + renderedRows.push(row); + apiRef.current.unstable_calculateColSpan({ + rowId: row.id, + minFirstColumn, + maxLastColumn, + columns: visibleColumns, + }); }); + } else { + if (!currentPage.range) { + return null; + } + + for (let i = firstRowToRender; i < lastRowToRender; i += 1) { + const row = currentPage.rows[i]; + renderedRows.push(row); + apiRef.current.unstable_calculateColSpan({ + rowId: row.id, + minFirstColumn, + maxLastColumn, + columns: visibleColumns, + }); + } } const [initialFirstColumnToRender, lastColumnToRender] = getRenderableIndexes({ @@ -455,7 +473,7 @@ export const useGridVirtualScroller = (props: UseGridVirtualScrollerProps) => { firstColumnToRender={firstColumnToRender} lastColumnToRender={lastColumnToRender} selected={isSelected} - index={currentPage.range.firstRowIndex + firstRowToRender + i} + index={rowIndexOffset + (currentPage?.range?.firstRowIndex || 0) + firstRowToRender + i} containerWidth={availableSpace} isLastVisible={lastVisibleRowIndex} {...(typeof getRowProps === 'function' ? getRowProps(id, model) : {})} diff --git a/packages/grid/x-data-grid/src/internals/index.ts b/packages/grid/x-data-grid/src/internals/index.ts index 9f89b07ddfaf..563dd994dfd3 100644 --- a/packages/grid/x-data-grid/src/internals/index.ts +++ b/packages/grid/x-data-grid/src/internals/index.ts @@ -54,9 +54,17 @@ export { useGridRowsPreProcessors } from '../hooks/features/rows/useGridRowsPreP export type { GridRowTreeCreationParams, GridRowTreeCreationValue, + GridHydrateRowsValue, + GridPinnedRowsState, } from '../hooks/features/rows/gridRowsState'; export { useGridRowsMeta, rowsMetaStateInitializer } from '../hooks/features/rows/useGridRowsMeta'; export { useGridParamsApi } from '../hooks/features/rows/useGridParamsApi'; +export { getRowIdFromRowModel } from '../hooks/features/rows/gridRowsUtils'; +export { + gridAdditionalRowGroupsSelector, + gridPinnedRowsSelector, +} from '../hooks/features/rows/gridRowsSelector'; +export { calculatePinnedRowsHeight } from '../hooks/features/rows/gridRowsUtils'; export { useGridSelection, selectionStateInitializer, diff --git a/packages/grid/x-data-grid/src/models/gridRows.ts b/packages/grid/x-data-grid/src/models/gridRows.ts index 430c0ae16346..7301ea647e8b 100644 --- a/packages/grid/x-data-grid/src/models/gridRows.ts +++ b/packages/grid/x-data-grid/src/models/gridRows.ts @@ -66,6 +66,11 @@ export interface GridRowTreeNodeConfig { * @default 'body' */ position?: 'body' | 'footer'; + /** + * If `true`, this row is pinned. + * @default false + */ + isPinned?: boolean; } /** diff --git a/scripts/x-data-grid-premium.exports.json b/scripts/x-data-grid-premium.exports.json index 09213203e997..7c2c50557eb6 100644 --- a/scripts/x-data-grid-premium.exports.json +++ b/scripts/x-data-grid-premium.exports.json @@ -350,6 +350,7 @@ { "name": "GridPinnedColumns", "kind": "Interface" }, { "name": "gridPinnedColumnsSelector", "kind": "Variable" }, { "name": "GridPinnedPosition", "kind": "Enum" }, + { "name": "GridPinnedRowsProp", "kind": "Interface" }, { "name": "GridPipeProcessingLookup", "kind": "Interface" }, { "name": "GridPreferencePanelInitialState", "kind": "TypeAlias" }, { "name": "GridPreferencePanelParams", "kind": "Interface" }, @@ -410,6 +411,8 @@ { "name": "GridRowModesModel", "kind": "TypeAlias" }, { "name": "GridRowOrderChangeParams", "kind": "Interface" }, { "name": "GridRowParams", "kind": "Interface" }, + { "name": "GridRowPinningApi", "kind": "Interface" }, + { "name": "GridRowPinningInternalCache", "kind": "Interface" }, { "name": "GridRowProps", "kind": "Interface" }, { "name": "GridRowScrollEndParams", "kind": "Interface" }, { "name": "GridRowSelectionCheckboxParams", "kind": "Interface" }, diff --git a/scripts/x-data-grid-pro.exports.json b/scripts/x-data-grid-pro.exports.json index 7f47b9c13441..de257b401375 100644 --- a/scripts/x-data-grid-pro.exports.json +++ b/scripts/x-data-grid-pro.exports.json @@ -327,6 +327,7 @@ { "name": "GridPinnedColumns", "kind": "Interface" }, { "name": "gridPinnedColumnsSelector", "kind": "Variable" }, { "name": "GridPinnedPosition", "kind": "Enum" }, + { "name": "GridPinnedRowsProp", "kind": "Interface" }, { "name": "GridPipeProcessingLookup", "kind": "Interface" }, { "name": "GridPreferencePanelInitialState", "kind": "TypeAlias" }, { "name": "GridPreferencePanelParams", "kind": "Interface" }, @@ -379,6 +380,8 @@ { "name": "GridRowModesModel", "kind": "TypeAlias" }, { "name": "GridRowOrderChangeParams", "kind": "Interface" }, { "name": "GridRowParams", "kind": "Interface" }, + { "name": "GridRowPinningApi", "kind": "Interface" }, + { "name": "GridRowPinningInternalCache", "kind": "Interface" }, { "name": "GridRowProps", "kind": "Interface" }, { "name": "GridRowScrollEndParams", "kind": "Interface" }, { "name": "GridRowSelectionCheckboxParams", "kind": "Interface" },