diff --git a/docs/data/data-grid/column-groups/BasicGroupingDemo.js b/docs/data/data-grid/column-groups/BasicGroupingDemo.js new file mode 100644 index 0000000000000..e9cc4d0f8046f --- /dev/null +++ b/docs/data/data-grid/column-groups/BasicGroupingDemo.js @@ -0,0 +1,67 @@ +import * as React from 'react'; +import { DataGridPro } from '@mui/x-data-grid-pro'; + +const columns = [ + { field: 'id', headerName: 'ID', width: 90 }, + { + field: 'firstName', + headerName: 'First name', + width: 150, + }, + { + field: 'lastName', + headerName: 'Last name', + width: 150, + }, + { + field: 'age', + headerName: 'Age', + type: 'number', + width: 110, + }, +]; + +const rows = [ + { id: 1, lastName: 'Snow', firstName: 'Jon', age: 35 }, + { id: 2, lastName: 'Lannister', firstName: 'Cersei', age: 42 }, + { id: 3, lastName: 'Lannister', firstName: 'Jaime', age: 45 }, + { id: 4, lastName: 'Stark', firstName: 'Arya', age: 16 }, + { id: 5, lastName: 'Targaryen', firstName: 'Daenerys', age: null }, + { id: 6, lastName: 'Melisandre', firstName: null, age: 150 }, + { id: 7, lastName: 'Clifford', firstName: 'Ferrara', age: 44 }, + { id: 8, lastName: 'Frances', firstName: 'Rossini', age: 36 }, + { id: 9, lastName: 'Roxie', firstName: 'Harvey', age: 65 }, +]; + +const columnGroupingModel = [ + { + groupId: 'Internal', + description: '', + children: [{ field: 'id' }], + }, + { + groupId: 'Basic info', + children: [ + { + groupId: 'Full name', + children: [{ field: 'lastName' }, { field: 'firstName' }], + }, + { field: 'age' }, + ], + }, +]; + +export default function BasicGroupingDemo() { + return ( +
+ +
+ ); +} diff --git a/docs/data/data-grid/column-groups/BasicGroupingDemo.tsx b/docs/data/data-grid/column-groups/BasicGroupingDemo.tsx new file mode 100644 index 0000000000000..39b149a79ca26 --- /dev/null +++ b/docs/data/data-grid/column-groups/BasicGroupingDemo.tsx @@ -0,0 +1,71 @@ +import * as React from 'react'; +import { + DataGridPro, + GridColDef, + GridColumnGroupingModel, +} from '@mui/x-data-grid-pro'; + +const columns: GridColDef[] = [ + { field: 'id', headerName: 'ID', width: 90 }, + { + field: 'firstName', + headerName: 'First name', + width: 150, + }, + { + field: 'lastName', + headerName: 'Last name', + width: 150, + }, + { + field: 'age', + headerName: 'Age', + type: 'number', + width: 110, + }, +]; + +const rows = [ + { id: 1, lastName: 'Snow', firstName: 'Jon', age: 35 }, + { id: 2, lastName: 'Lannister', firstName: 'Cersei', age: 42 }, + { id: 3, lastName: 'Lannister', firstName: 'Jaime', age: 45 }, + { id: 4, lastName: 'Stark', firstName: 'Arya', age: 16 }, + { id: 5, lastName: 'Targaryen', firstName: 'Daenerys', age: null }, + { id: 6, lastName: 'Melisandre', firstName: null, age: 150 }, + { id: 7, lastName: 'Clifford', firstName: 'Ferrara', age: 44 }, + { id: 8, lastName: 'Frances', firstName: 'Rossini', age: 36 }, + { id: 9, lastName: 'Roxie', firstName: 'Harvey', age: 65 }, +]; + +const columnGroupingModel: GridColumnGroupingModel = [ + { + groupId: 'Internal', + description: '', + children: [{ field: 'id' }], + }, + { + groupId: 'Basic info', + children: [ + { + groupId: 'Full name', + children: [{ field: 'lastName' }, { field: 'firstName' }], + }, + { field: 'age' }, + ], + }, +]; + +export default function BasicGroupingDemo() { + return ( +
+ +
+ ); +} diff --git a/docs/data/data-grid/column-groups/BasicGroupingDemo.tsx.preview b/docs/data/data-grid/column-groups/BasicGroupingDemo.tsx.preview new file mode 100644 index 0000000000000..d421de0b52667 --- /dev/null +++ b/docs/data/data-grid/column-groups/BasicGroupingDemo.tsx.preview @@ -0,0 +1,8 @@ + \ No newline at end of file diff --git a/docs/data/data-grid/column-groups/BreakingGroupDemo.js b/docs/data/data-grid/column-groups/BreakingGroupDemo.js new file mode 100644 index 0000000000000..462958c566b18 --- /dev/null +++ b/docs/data/data-grid/column-groups/BreakingGroupDemo.js @@ -0,0 +1,65 @@ +import * as React from 'react'; +import { DataGridPro } from '@mui/x-data-grid-pro'; + +const columns = [ + { field: 'id', headerName: 'ID', width: 100 }, + { field: 'isAdmin', type: 'boolean', headerName: 'is admin', width: 100 }, + { + field: 'firstName', + headerName: 'First name', + width: 150, + }, + { + field: 'lastName', + headerName: 'Last name', + width: 150, + }, + { + field: 'age', + headerName: 'Age', + type: 'number', + width: 110, + }, +]; + +const rows = [ + { id: 1, isAdmin: false, lastName: 'Snow', firstName: 'Jon', age: 35 }, + { id: 2, isAdmin: true, lastName: 'Lannister', firstName: 'Cersei', age: 42 }, + { id: 3, isAdmin: false, lastName: 'Lannister', firstName: 'Jaime', age: 45 }, + { id: 4, isAdmin: false, lastName: 'Stark', firstName: 'Arya', age: 16 }, + { id: 5, isAdmin: true, lastName: 'Targaryen', firstName: 'Daenerys', age: null }, + { id: 6, isAdmin: true, lastName: 'Melisandre', firstName: null, age: 150 }, + { id: 7, isAdmin: false, lastName: 'Clifford', firstName: 'Ferrara', age: 44 }, + { id: 8, isAdmin: false, lastName: 'Frances', firstName: 'Rossini', age: 36 }, + { id: 9, isAdmin: false, lastName: 'Roxie', firstName: 'Harvey', age: 65 }, +]; + +const columnGroupingModel = [ + { + groupId: 'internal_data', + headerName: 'Internal (not freeReordering)', + description: '', + children: [{ field: 'id' }, { field: 'isAdmin' }], + }, + { + groupId: 'naming', + headerName: 'Full name (freeReordering)', + freeReordering: true, + children: [{ field: 'lastName' }, { field: 'firstName' }], + }, +]; + +export default function BreakingGroupDemo() { + return ( +
+ +
+ ); +} diff --git a/docs/data/data-grid/column-groups/BreakingGroupDemo.tsx b/docs/data/data-grid/column-groups/BreakingGroupDemo.tsx new file mode 100644 index 0000000000000..385111a7e483d --- /dev/null +++ b/docs/data/data-grid/column-groups/BreakingGroupDemo.tsx @@ -0,0 +1,69 @@ +import * as React from 'react'; +import { + DataGridPro, + GridColDef, + GridColumnGroupingModel, +} from '@mui/x-data-grid-pro'; + +const columns: GridColDef[] = [ + { field: 'id', headerName: 'ID', width: 100 }, + { field: 'isAdmin', type: 'boolean', headerName: 'is admin', width: 100 }, + { + field: 'firstName', + headerName: 'First name', + width: 150, + }, + { + field: 'lastName', + headerName: 'Last name', + width: 150, + }, + { + field: 'age', + headerName: 'Age', + type: 'number', + width: 110, + }, +]; + +const rows = [ + { id: 1, isAdmin: false, lastName: 'Snow', firstName: 'Jon', age: 35 }, + { id: 2, isAdmin: true, lastName: 'Lannister', firstName: 'Cersei', age: 42 }, + { id: 3, isAdmin: false, lastName: 'Lannister', firstName: 'Jaime', age: 45 }, + { id: 4, isAdmin: false, lastName: 'Stark', firstName: 'Arya', age: 16 }, + { id: 5, isAdmin: true, lastName: 'Targaryen', firstName: 'Daenerys', age: null }, + { id: 6, isAdmin: true, lastName: 'Melisandre', firstName: null, age: 150 }, + { id: 7, isAdmin: false, lastName: 'Clifford', firstName: 'Ferrara', age: 44 }, + { id: 8, isAdmin: false, lastName: 'Frances', firstName: 'Rossini', age: 36 }, + { id: 9, isAdmin: false, lastName: 'Roxie', firstName: 'Harvey', age: 65 }, +]; + +const columnGroupingModel: GridColumnGroupingModel = [ + { + groupId: 'internal_data', + headerName: 'Internal (not freeReordering)', + description: '', + children: [{ field: 'id' }, { field: 'isAdmin' }], + }, + { + groupId: 'naming', + headerName: 'Full name (freeReordering)', + freeReordering: true, + children: [{ field: 'lastName' }, { field: 'firstName' }], + }, +]; + +export default function BreakingGroupDemo() { + return ( +
+ +
+ ); +} diff --git a/docs/data/data-grid/column-groups/BreakingGroupDemo.tsx.preview b/docs/data/data-grid/column-groups/BreakingGroupDemo.tsx.preview new file mode 100644 index 0000000000000..75d805bb7b2cf --- /dev/null +++ b/docs/data/data-grid/column-groups/BreakingGroupDemo.tsx.preview @@ -0,0 +1,8 @@ + \ No newline at end of file diff --git a/docs/data/data-grid/column-groups/CustomizationDemo.js b/docs/data/data-grid/column-groups/CustomizationDemo.js new file mode 100644 index 0000000000000..e0373d6f1662b --- /dev/null +++ b/docs/data/data-grid/column-groups/CustomizationDemo.js @@ -0,0 +1,125 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { styled } from '@mui/material/styles'; +import Box from '@mui/material/Box'; +import { DataGridPro } from '@mui/x-data-grid-pro'; +import BuildIcon from '@mui/icons-material/Build'; +import PersonIcon from '@mui/icons-material/Person'; + +const columns = [ + { field: 'id', headerName: 'ID', width: 150 }, + { + field: 'firstName', + headerName: 'First name', + width: 150, + }, + { + field: 'lastName', + headerName: 'Last name', + width: 150, + }, + { + field: 'age', + headerName: 'Age', + type: 'number', + width: 110, + }, +]; + +const rows = [ + { id: 1, lastName: 'Snow', firstName: 'Jon', age: 35 }, + { id: 2, lastName: 'Lannister', firstName: 'Cersei', age: 42 }, + { id: 3, lastName: 'Lannister', firstName: 'Jaime', age: 45 }, + { id: 4, lastName: 'Stark', firstName: 'Arya', age: 16 }, + { id: 5, lastName: 'Targaryen', firstName: 'Daenerys', age: null }, + { id: 6, lastName: 'Melisandre', firstName: null, age: 150 }, + { id: 7, lastName: 'Clifford', firstName: 'Ferrara', age: 44 }, + { id: 8, lastName: 'Frances', firstName: 'Rossini', age: 36 }, + { id: 9, lastName: 'Roxie', firstName: 'Harvey', age: 65 }, +]; + +const HeaderWithIconRoot = styled('div')(({ theme }) => ({ + overflow: 'hidden', + display: 'flex', + alignItems: 'center', + '& span': { + overflow: 'hidden', + textOverflow: 'ellipsis', + marginRight: theme.spacing(0.5), + }, +})); + +const HeaderWithIcon = (props) => { + const { icon, ...params } = props; + + return ( + + {params.headerName ?? params.groupId} {icon} + + ); +}; + +HeaderWithIcon.propTypes = { + /** + * A unique string identifying the group. + */ + groupId: PropTypes.oneOfType([PropTypes.oneOf([null]), PropTypes.string]) + .isRequired, + /** + * The title of the column rendered in the column header cell. + */ + headerName: PropTypes.string, + icon: PropTypes.node, +}; + +const columnGroupingModel = [ + { + groupId: 'internal_data', + headerName: 'Internal', + description: '', + renderHeaderGroup: (params) => ( + } /> + ), + children: [{ field: 'id' }], + }, + { + groupId: 'character', + description: 'Information about the character', + headerName: 'Basic info', + renderHeaderGroup: (params) => ( + } /> + ), + children: [ + { + groupId: 'naming', + headerName: 'Names', + headerClassName: 'my-super-theme--naming-group', + children: [{ field: 'lastName' }, { field: 'firstName' }], + }, + { field: 'age' }, + ], + }, +]; + +export default function CustomizationDemo() { + return ( + + + + ); +} diff --git a/docs/data/data-grid/column-groups/CustomizationDemo.tsx b/docs/data/data-grid/column-groups/CustomizationDemo.tsx new file mode 100644 index 0000000000000..4918e926884c9 --- /dev/null +++ b/docs/data/data-grid/column-groups/CustomizationDemo.tsx @@ -0,0 +1,120 @@ +import * as React from 'react'; +import { styled } from '@mui/material/styles'; +import Box from '@mui/material/Box'; +import { + DataGridPro, + GridColDef, + GridColumnGroupHeaderParams, + GridColumnGroupingModel, +} from '@mui/x-data-grid-pro'; +import BuildIcon from '@mui/icons-material/Build'; +import PersonIcon from '@mui/icons-material/Person'; + +const columns: GridColDef[] = [ + { field: 'id', headerName: 'ID', width: 150 }, + { + field: 'firstName', + headerName: 'First name', + width: 150, + }, + { + field: 'lastName', + headerName: 'Last name', + width: 150, + }, + { + field: 'age', + headerName: 'Age', + type: 'number', + width: 110, + }, +]; + +const rows = [ + { id: 1, lastName: 'Snow', firstName: 'Jon', age: 35 }, + { id: 2, lastName: 'Lannister', firstName: 'Cersei', age: 42 }, + { id: 3, lastName: 'Lannister', firstName: 'Jaime', age: 45 }, + { id: 4, lastName: 'Stark', firstName: 'Arya', age: 16 }, + { id: 5, lastName: 'Targaryen', firstName: 'Daenerys', age: null }, + { id: 6, lastName: 'Melisandre', firstName: null, age: 150 }, + { id: 7, lastName: 'Clifford', firstName: 'Ferrara', age: 44 }, + { id: 8, lastName: 'Frances', firstName: 'Rossini', age: 36 }, + { id: 9, lastName: 'Roxie', firstName: 'Harvey', age: 65 }, +]; + +interface HeaderWithIconProps extends GridColumnGroupHeaderParams { + icon: React.ReactNode; +} + +const HeaderWithIconRoot = styled('div')(({ theme }) => ({ + overflow: 'hidden', + display: 'flex', + alignItems: 'center', + '& span': { + overflow: 'hidden', + textOverflow: 'ellipsis', + marginRight: theme.spacing(0.5), + }, +})); + +const HeaderWithIcon = (props: HeaderWithIconProps) => { + const { icon, ...params } = props; + + return ( + + {params.headerName ?? params.groupId} {icon} + + ); +}; + +const columnGroupingModel: GridColumnGroupingModel = [ + { + groupId: 'internal_data', + headerName: 'Internal', + description: '', + renderHeaderGroup: (params) => ( + } /> + ), + children: [{ field: 'id' }], + }, + { + groupId: 'character', + description: 'Information about the character', + headerName: 'Basic info', + renderHeaderGroup: (params) => ( + } /> + ), + children: [ + { + groupId: 'naming', + headerName: 'Names', + headerClassName: 'my-super-theme--naming-group', + children: [{ field: 'lastName' }, { field: 'firstName' }], + }, + { field: 'age' }, + ], + }, +]; + +export default function CustomizationDemo() { + return ( + + + + ); +} diff --git a/docs/data/data-grid/column-groups/CustomizationDemo.tsx.preview b/docs/data/data-grid/column-groups/CustomizationDemo.tsx.preview new file mode 100644 index 0000000000000..75d805bb7b2cf --- /dev/null +++ b/docs/data/data-grid/column-groups/CustomizationDemo.tsx.preview @@ -0,0 +1,8 @@ + \ No newline at end of file diff --git a/docs/data/data-grid/column-groups/column-groups.md b/docs/data/data-grid/column-groups/column-groups.md index 5532addd39b6f..1221d196460bd 100644 --- a/docs/data/data-grid/column-groups/column-groups.md +++ b/docs/data/data-grid/column-groups/column-groups.md @@ -1,18 +1,99 @@ --- -title: Data Grid - Column groups 🚧 +title: Data Grid - Column groups --- -# Data grid - Column groups 🚧 +# Data grid - Column groups

Group your columns.

+Grouping columns allows you to have a multi-level hierarchy of columns in your header. + +:::warning +This feature is experimental, it needs to be explicitly activated using the `columnGrouping` experimental feature flag. + +```tsx + +``` + +::: + +## Define column groups + +You can define column groups with the `columnGroupingModel` prop. +This prop receives an array of column groups. + +A column group is defined by at least two properties: + +- `groupId`: a string used to identify the group +- `children`: an array containing the children of the group + +The `children` attribute can contain two types of objects: + +- leafs with type `{ field: string }`, which add the column with the corresponding `field` to this group. +- other column groups which allows you to have nested groups. + +:::warning +A column can only be associated with one group. +::: + +```jsx + +``` + +{{"demo": "BasicGroupingDemo.js", "bg": "inline"}} + +## Customize column group + +In addition to the required `groupId` and `children`, you can use the following optional properties to customize a column group: + +- `headerName`: the string displayed as the column's name (instead of `groupId`). +- `description`: a text for the tooltip. +- `headerClassName`: a CSS class for styling customization. +- `renderHeaderGroup`: a function returning custom React component. + +{{"demo": "CustomizationDemo.js", "bg": "inline"}} + +## Column reordering [](https://mui.com/store/items/mui-x-pro/) + +By default, the columns that are part of a group cannot be dragged to outside their group. +You can customize this behavior on specific groups by setting `freeReordering: true` in a column group object. + +In the example below, the `Full name` column group can be divided, but not other column groups. + +{{"demo": "BreakingGroupDemo.js", "disableAd": true, "bg": "inline"}} + +## Manage group visibility 🚧 + :::warning This feature isn't implemented yet. It's coming. +::: + +The column group should allow to switch between an extended/collapsed view which hide/show some columns -👍 Upvote [issue #195](https://github.com/mui/mui-x/issues/195) if you want to see it land faster. +## Reordering groups 🚧[](https://mui.com/store/items/mui-x-pro/) + +:::warning +This feature isn't implemented yet. It's coming. ::: -Grouping columns allows you to have multiple levels of columns in your header and the ability, if needed, to 'open and close' column groups to show and hide additional columns. +Users could drag and drop group header to move all the group children at once ## API diff --git a/docs/data/pages.ts b/docs/data/pages.ts index bd75b5919ccbb..e4efc55274960 100644 --- a/docs/data/pages.ts +++ b/docs/data/pages.ts @@ -35,7 +35,7 @@ const pages: MuiPage[] = [ { pathname: '/x/react-data-grid/column-ordering' }, { pathname: '/x/react-data-grid/column-pinning', plan: 'pro' }, { pathname: '/x/react-data-grid/column-spanning' }, - { pathname: '/x/react-data-grid/column-groups', title: 'Column groups 🚧' }, + { pathname: '/x/react-data-grid/column-groups' }, ], }, { 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 2c23eadd12e3f..52d19949beedd 100644 --- a/docs/pages/x/api/data-grid/data-grid-premium.json +++ b/docs/pages/x/api/data-grid/data-grid-premium.json @@ -70,7 +70,7 @@ "experimentalFeatures": { "type": { "name": "shape", - "description": "{ aggregation?: bool, newEditingApi?: bool, preventCommitWhileValidating?: bool, rowPinning?: bool, warnIfFocusStateIsNotSynced?: bool }" + "description": "{ aggregation?: bool, columnGrouping?: bool, newEditingApi?: bool, preventCommitWhileValidating?: bool, rowPinning?: bool, warnIfFocusStateIsNotSynced?: bool }" } }, "filterMode": { @@ -279,6 +279,7 @@ "columnHeader--sorted", "columnHeader--filtered", "columnHeader", + "columnGroupHeader", "columnHeaderCheckbox", "columnHeaderDraggableContainer", "rowReorderCellPlaceholder", @@ -286,6 +287,9 @@ "columnHeaderTitle", "columnHeaderTitleContainer", "columnHeaderTitleContainerContent", + "columnHeader--filledGroup", + "columnHeader--emptyGroup", + "columnHeader--showColumnBorder", "columnHeaders", "columnHeadersInner", "columnHeadersInner--scrollable", 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 71dda53361c76..38615bff95fdb 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, rowPinning?: bool, warnIfFocusStateIsNotSynced?: bool }" + "description": "{ columnGrouping?: bool, newEditingApi?: bool, preventCommitWhileValidating?: bool, rowPinning?: bool, warnIfFocusStateIsNotSynced?: bool }" } }, "filterMode": { @@ -331,6 +331,7 @@ "columnHeader--sorted", "columnHeader--filtered", "columnHeader", + "columnGroupHeader", "columnHeaderCheckbox", "columnHeaderDraggableContainer", "rowReorderCellPlaceholder", @@ -338,6 +339,9 @@ "columnHeaderTitle", "columnHeaderTitleContainer", "columnHeaderTitleContainerContent", + "columnHeader--filledGroup", + "columnHeader--emptyGroup", + "columnHeader--showColumnBorder", "columnHeaders", "columnHeadersInner", "columnHeadersInner--scrollable", diff --git a/docs/pages/x/api/data-grid/data-grid.json b/docs/pages/x/api/data-grid/data-grid.json index a9eb815c3b24c..6de4912093c29 100644 --- a/docs/pages/x/api/data-grid/data-grid.json +++ b/docs/pages/x/api/data-grid/data-grid.json @@ -39,7 +39,7 @@ "experimentalFeatures": { "type": { "name": "shape", - "description": "{ newEditingApi?: bool, preventCommitWhileValidating?: bool, warnIfFocusStateIsNotSynced?: bool }" + "description": "{ columnGrouping?: bool, newEditingApi?: bool, preventCommitWhileValidating?: bool, warnIfFocusStateIsNotSynced?: bool }" } }, "filterMode": { @@ -285,6 +285,7 @@ "columnHeader--sorted", "columnHeader--filtered", "columnHeader", + "columnGroupHeader", "columnHeaderCheckbox", "columnHeaderDraggableContainer", "rowReorderCellPlaceholder", @@ -292,6 +293,9 @@ "columnHeaderTitle", "columnHeaderTitleContainer", "columnHeaderTitleContainerContent", + "columnHeader--filledGroup", + "columnHeader--emptyGroup", + "columnHeader--showColumnBorder", "columnHeaders", "columnHeadersInner", "columnHeadersInner--scrollable", diff --git a/docs/pages/x/api/data-grid/grid-api.md b/docs/pages/x/api/data-grid/grid-api.md index 5f9f9cf226607..c63564268ae43 100644 --- a/docs/pages/x/api/data-grid/grid-api.md +++ b/docs/pages/x/api/data-grid/grid-api.md @@ -86,7 +86,7 @@ import { GridApi } from '@mui/x-data-grid-pro'; | setColumnVisibility | (field: string, isVisible: boolean) => void | Changes the visibility of the column referred by `field`. | | setColumnVisibilityModel | (model: GridColumnVisibilityModel) => void | Sets the column visibility model to the one given by `model`. | | setColumnWidth | (field: string, width: number) => void | Updates the width of a column. | -| setDensity | (density: GridDensity, headerHeight?: number, rowHeight?: number) => void | Sets the density of the grid. | +| setDensity | (density: GridDensity, headerHeight?: number, rowHeight?: number, maxDepth?: number) => void | Sets the density of the grid. | | setEditCellValue | (params: GridEditCellValueParams, event?: MuiBaseEvent) => Promise<boolean> \| void | Sets the value of the edit cell.
Commonly used inside the edit cell component. | | setEditRowsModel | (model: GridEditRowsModel) => void | Set the edit rows model of the grid. | | setExpandedDetailPanels [](/x/introduction/licensing/#pro-plan) | (ids: GridRowId[]) => void | Changes which rows to expand the detail panel. | @@ -118,6 +118,8 @@ import { GridApi } from '@mui/x-data-grid-pro'; | toggleColumnMenu | (field: string) => void | Toggles the column menu under the `field` column. | | toggleDetailPanel [](/x/introduction/licensing/#pro-plan) | (id: GridRowId) => void | Expands or collapses the detail panel of a row. | | unpinColumn [](/x/introduction/licensing/#pro-plan) | (field: string) => void | Unpins a column. | +| unstable_getAllGroupDetails | () => GridColumnGroupLookup | Returns the column group lookup. | +| unstable_getColumnGroupPath | (field: string) => GridColumnGroup['groupId'][] | Returns the id of the groups leading to the requested column.
The array is ordered by increasing depth (the last element is the direct parent of the column). | | unstable_setPinnedRows [](/x/introduction/licensing/#pro-plan) | (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. | diff --git a/docs/pages/x/api/data-grid/selectors.json b/docs/pages/x/api/data-grid/selectors.json index 1f0955ae154ce..17845cffa07ca 100644 --- a/docs/pages/x/api/data-grid/selectors.json +++ b/docs/pages/x/api/data-grid/selectors.json @@ -25,6 +25,12 @@ "description": "Get the field of each column.", "supportsApiRef": true }, + { + "name": "gridColumnGroupsLookupSelector", + "returnType": "GridColumnGroupLookup", + "description": "", + "supportsApiRef": true + }, { "name": "gridColumnLookupSelector", "returnType": "GridColumnLookup", @@ -83,6 +89,12 @@ "description": "", "supportsApiRef": true }, + { + "name": "gridDensityHeaderGroupingMaxDepthSelector", + "returnType": "number", + "description": "", + "supportsApiRef": true + }, { "name": "gridDensityHeaderHeightSelector", "returnType": "number", @@ -101,6 +113,12 @@ "description": "", "supportsApiRef": false }, + { + "name": "gridDensityTotalHeaderHeightSelector", + "returnType": "number", + "description": "", + "supportsApiRef": true + }, { "name": "gridDensityValueSelector", "returnType": "GridDensity", diff --git a/docs/scripts/generateProptypes.ts b/docs/scripts/generateProptypes.ts index 37661c037c0fe..9a6ad47e3734b 100644 --- a/docs/scripts/generateProptypes.ts +++ b/docs/scripts/generateProptypes.ts @@ -34,6 +34,7 @@ async function generateProptypes(program: ttp.ts.Program, sourceFile: string) { 'groupingColDef', 'rowNode', 'localeText', + 'columnGroupingModel', ]; if (propsToNotResolve.includes(name)) { return false; 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 58b85bb7d5161..2086662587c46 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 @@ -279,6 +279,10 @@ "description": "Styles applied to {{nodeName}}.", "nodeName": "the column header element" }, + "columnGroupHeader": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the column group header element" + }, "columnHeaderCheckbox": { "description": "Styles applied to {{nodeName}}.", "nodeName": "the header checkbox cell element" @@ -305,6 +309,20 @@ "description": "Styles applied to {{nodeName}}.", "nodeName": "the column header's title excepted buttons" }, + "columnHeader--filledGroup": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the column group header cell", + "conditions": "not empty" + }, + "columnHeader--emptyGroup": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the empty column group header cell" + }, + "columnHeader--showColumnBorder": { + "description": "Styles applied to {{nodeName}} when {{conditions}}.", + "nodeName": "the column group header cell", + "conditions": "show column border" + }, "columnHeaders": { "description": "Styles applied to {{nodeName}}.", "nodeName": "the column headers" 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 58b85bb7d5161..2086662587c46 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 @@ -279,6 +279,10 @@ "description": "Styles applied to {{nodeName}}.", "nodeName": "the column header element" }, + "columnGroupHeader": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the column group header element" + }, "columnHeaderCheckbox": { "description": "Styles applied to {{nodeName}}.", "nodeName": "the header checkbox cell element" @@ -305,6 +309,20 @@ "description": "Styles applied to {{nodeName}}.", "nodeName": "the column header's title excepted buttons" }, + "columnHeader--filledGroup": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the column group header cell", + "conditions": "not empty" + }, + "columnHeader--emptyGroup": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the empty column group header cell" + }, + "columnHeader--showColumnBorder": { + "description": "Styles applied to {{nodeName}} when {{conditions}}.", + "nodeName": "the column group header cell", + "conditions": "show column border" + }, "columnHeaders": { "description": "Styles applied to {{nodeName}}.", "nodeName": "the column headers" 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 58b85bb7d5161..2086662587c46 100644 --- a/docs/translations/api-docs/data-grid/data-grid-premium.json +++ b/docs/translations/api-docs/data-grid/data-grid-premium.json @@ -279,6 +279,10 @@ "description": "Styles applied to {{nodeName}}.", "nodeName": "the column header element" }, + "columnGroupHeader": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the column group header element" + }, "columnHeaderCheckbox": { "description": "Styles applied to {{nodeName}}.", "nodeName": "the header checkbox cell element" @@ -305,6 +309,20 @@ "description": "Styles applied to {{nodeName}}.", "nodeName": "the column header's title excepted buttons" }, + "columnHeader--filledGroup": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the column group header cell", + "conditions": "not empty" + }, + "columnHeader--emptyGroup": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the empty column group header cell" + }, + "columnHeader--showColumnBorder": { + "description": "Styles applied to {{nodeName}} when {{conditions}}.", + "nodeName": "the column group header cell", + "conditions": "show column border" + }, "columnHeaders": { "description": "Styles applied to {{nodeName}}.", "nodeName": "the column headers" 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 ef43b2e3c26ee..6e06b5e2898d5 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 @@ -269,6 +269,10 @@ "description": "Styles applied to {{nodeName}}.", "nodeName": "the column header element" }, + "columnGroupHeader": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the column group header element" + }, "columnHeaderCheckbox": { "description": "Styles applied to {{nodeName}}.", "nodeName": "the header checkbox cell element" @@ -295,6 +299,20 @@ "description": "Styles applied to {{nodeName}}.", "nodeName": "the column header's title excepted buttons" }, + "columnHeader--filledGroup": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the column group header cell", + "conditions": "not empty" + }, + "columnHeader--emptyGroup": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the empty column group header cell" + }, + "columnHeader--showColumnBorder": { + "description": "Styles applied to {{nodeName}} when {{conditions}}.", + "nodeName": "the column group header cell", + "conditions": "show column border" + }, "columnHeaders": { "description": "Styles applied to {{nodeName}}.", "nodeName": "the column headers" 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 ef43b2e3c26ee..6e06b5e2898d5 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 @@ -269,6 +269,10 @@ "description": "Styles applied to {{nodeName}}.", "nodeName": "the column header element" }, + "columnGroupHeader": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the column group header element" + }, "columnHeaderCheckbox": { "description": "Styles applied to {{nodeName}}.", "nodeName": "the header checkbox cell element" @@ -295,6 +299,20 @@ "description": "Styles applied to {{nodeName}}.", "nodeName": "the column header's title excepted buttons" }, + "columnHeader--filledGroup": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the column group header cell", + "conditions": "not empty" + }, + "columnHeader--emptyGroup": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the empty column group header cell" + }, + "columnHeader--showColumnBorder": { + "description": "Styles applied to {{nodeName}} when {{conditions}}.", + "nodeName": "the column group header cell", + "conditions": "show column border" + }, "columnHeaders": { "description": "Styles applied to {{nodeName}}.", "nodeName": "the column headers" 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 ef43b2e3c26ee..6e06b5e2898d5 100644 --- a/docs/translations/api-docs/data-grid/data-grid-pro.json +++ b/docs/translations/api-docs/data-grid/data-grid-pro.json @@ -269,6 +269,10 @@ "description": "Styles applied to {{nodeName}}.", "nodeName": "the column header element" }, + "columnGroupHeader": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the column group header element" + }, "columnHeaderCheckbox": { "description": "Styles applied to {{nodeName}}.", "nodeName": "the header checkbox cell element" @@ -295,6 +299,20 @@ "description": "Styles applied to {{nodeName}}.", "nodeName": "the column header's title excepted buttons" }, + "columnHeader--filledGroup": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the column group header cell", + "conditions": "not empty" + }, + "columnHeader--emptyGroup": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the empty column group header cell" + }, + "columnHeader--showColumnBorder": { + "description": "Styles applied to {{nodeName}} when {{conditions}}.", + "nodeName": "the column group header cell", + "conditions": "show column border" + }, "columnHeaders": { "description": "Styles applied to {{nodeName}}.", "nodeName": "the column headers" 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 cbf150db637c1..20c31c00daabc 100644 --- a/docs/translations/api-docs/data-grid/data-grid-pt.json +++ b/docs/translations/api-docs/data-grid/data-grid-pt.json @@ -239,6 +239,10 @@ "description": "Styles applied to {{nodeName}}.", "nodeName": "the column header element" }, + "columnGroupHeader": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the column group header element" + }, "columnHeaderCheckbox": { "description": "Styles applied to {{nodeName}}.", "nodeName": "the header checkbox cell element" @@ -265,6 +269,20 @@ "description": "Styles applied to {{nodeName}}.", "nodeName": "the column header's title excepted buttons" }, + "columnHeader--filledGroup": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the column group header cell", + "conditions": "not empty" + }, + "columnHeader--emptyGroup": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the empty column group header cell" + }, + "columnHeader--showColumnBorder": { + "description": "Styles applied to {{nodeName}} when {{conditions}}.", + "nodeName": "the column group header cell", + "conditions": "show column border" + }, "columnHeaders": { "description": "Styles applied to {{nodeName}}.", "nodeName": "the column headers" 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 cbf150db637c1..20c31c00daabc 100644 --- a/docs/translations/api-docs/data-grid/data-grid-zh.json +++ b/docs/translations/api-docs/data-grid/data-grid-zh.json @@ -239,6 +239,10 @@ "description": "Styles applied to {{nodeName}}.", "nodeName": "the column header element" }, + "columnGroupHeader": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the column group header element" + }, "columnHeaderCheckbox": { "description": "Styles applied to {{nodeName}}.", "nodeName": "the header checkbox cell element" @@ -265,6 +269,20 @@ "description": "Styles applied to {{nodeName}}.", "nodeName": "the column header's title excepted buttons" }, + "columnHeader--filledGroup": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the column group header cell", + "conditions": "not empty" + }, + "columnHeader--emptyGroup": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the empty column group header cell" + }, + "columnHeader--showColumnBorder": { + "description": "Styles applied to {{nodeName}} when {{conditions}}.", + "nodeName": "the column group header cell", + "conditions": "show column border" + }, "columnHeaders": { "description": "Styles applied to {{nodeName}}.", "nodeName": "the column headers" diff --git a/docs/translations/api-docs/data-grid/data-grid.json b/docs/translations/api-docs/data-grid/data-grid.json index cbf150db637c1..20c31c00daabc 100644 --- a/docs/translations/api-docs/data-grid/data-grid.json +++ b/docs/translations/api-docs/data-grid/data-grid.json @@ -239,6 +239,10 @@ "description": "Styles applied to {{nodeName}}.", "nodeName": "the column header element" }, + "columnGroupHeader": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the column group header element" + }, "columnHeaderCheckbox": { "description": "Styles applied to {{nodeName}}.", "nodeName": "the header checkbox cell element" @@ -265,6 +269,20 @@ "description": "Styles applied to {{nodeName}}.", "nodeName": "the column header's title excepted buttons" }, + "columnHeader--filledGroup": { + "description": "Styles applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the column group header cell", + "conditions": "not empty" + }, + "columnHeader--emptyGroup": { + "description": "Styles applied to {{nodeName}}.", + "nodeName": "the empty column group header cell" + }, + "columnHeader--showColumnBorder": { + "description": "Styles applied to {{nodeName}} when {{conditions}}.", + "nodeName": "the column group header cell", + "conditions": "show column border" + }, "columnHeaders": { "description": "Styles applied to {{nodeName}}.", "nodeName": "the column headers" 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 e62c89ceb2ad4..bbdfc8f442b2f 100644 --- a/packages/grid/x-data-grid-premium/src/DataGridPremium/DataGridPremium.tsx +++ b/packages/grid/x-data-grid-premium/src/DataGridPremium/DataGridPremium.tsx @@ -134,6 +134,7 @@ DataGridPremiumRaw.propTypes = { * @default 3 */ columnBuffer: PropTypes.number, + columnGroupingModel: PropTypes.arrayOf(PropTypes.object), /** * Set of columns of type [[GridColumns]]. */ @@ -286,6 +287,7 @@ DataGridPremiumRaw.propTypes = { */ experimentalFeatures: PropTypes.shape({ aggregation: PropTypes.bool, + columnGrouping: PropTypes.bool, newEditingApi: PropTypes.bool, preventCommitWhileValidating: PropTypes.bool, rowPinning: PropTypes.bool, 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 1f34b5363eb8a..18f424aa07d79 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,7 @@ import { useGridColumnSpanning, useGridRowReorder, useGridRowReorderPreProcessors, + useGridColumnGroupingPreProcessors, useGridRowPinning, useGridRowPinningPreProcessors, rowPinningStateInitializer, @@ -83,6 +84,7 @@ export const useDataGridPremiumComponent = ( /** * Register all pre-processors called during state initialization here. */ + useGridColumnGroupingPreProcessors(apiRef, props); useGridSelectionPreProcessors(apiRef, props); useGridRowReorderPreProcessors(apiRef, props); useGridRowGroupingPreProcessors(apiRef, props); 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 5715c7910a03b..b36812c31aa73 100644 --- a/packages/grid/x-data-grid-pro/src/DataGridPro/DataGridPro.tsx +++ b/packages/grid/x-data-grid-pro/src/DataGridPro/DataGridPro.tsx @@ -115,6 +115,7 @@ DataGridProRaw.propTypes = { * @default 3 */ columnBuffer: PropTypes.number, + columnGroupingModel: PropTypes.arrayOf(PropTypes.object), /** * Set of columns of type [[GridColumns]]. */ @@ -256,6 +257,7 @@ DataGridProRaw.propTypes = { * 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: PropTypes.shape({ + columnGrouping: PropTypes.bool, newEditingApi: PropTypes.bool, preventCommitWhileValidating: PropTypes.bool, rowPinning: PropTypes.bool, 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 f27af1021f70f..c633ae78f5076 100644 --- a/packages/grid/x-data-grid-pro/src/DataGridPro/useDataGridProComponent.tsx +++ b/packages/grid/x-data-grid-pro/src/DataGridPro/useDataGridProComponent.tsx @@ -40,6 +40,9 @@ import { preferencePanelStateInitializer, rowsMetaStateInitializer, selectionStateInitializer, + useGridColumnGrouping, + columnGroupsStateInitializer, + useGridColumnGroupingPreProcessors, } from '@mui/x-data-grid/internals'; import { GridApiPro } from '../models/gridApiPro'; import { DataGridProProcessedProps } from '../models/dataGridProProps'; @@ -82,6 +85,7 @@ export const useDataGridProComponent = ( /** * Register all pre-processors called during state initialization here. */ + useGridColumnGroupingPreProcessors(apiRef, props); useGridSelectionPreProcessors(apiRef, props); useGridRowReorderPreProcessors(apiRef, props); useGridTreeDataPreProcessors(apiRef, props); @@ -118,6 +122,7 @@ export const useDataGridProComponent = ( useGridInitializeState(paginationStateInitializer, apiRef, props); useGridInitializeState(rowsMetaStateInitializer, apiRef, props); useGridInitializeState(columnMenuStateInitializer, apiRef, props); + useGridInitializeState(columnGroupsStateInitializer, apiRef, props); useGridTreeData(apiRef); useGridKeyboardNavigation(apiRef, props); @@ -129,6 +134,7 @@ export const useDataGridProComponent = ( useGridParamsApi(apiRef); useGridDetailPanel(apiRef, props); useGridColumnSpanning(apiRef); + useGridColumnGrouping(apiRef, props); const useGridEditing = props.experimentalFeatures?.newEditingApi ? useGridEditing_new diff --git a/packages/grid/x-data-grid-pro/src/components/DataGridProColumnHeaders.tsx b/packages/grid/x-data-grid-pro/src/components/DataGridProColumnHeaders.tsx index 28059d10d5acd..6112388a963ad 100644 --- a/packages/grid/x-data-grid-pro/src/components/DataGridProColumnHeaders.tsx +++ b/packages/grid/x-data-grid-pro/src/components/DataGridProColumnHeaders.tsx @@ -77,6 +77,7 @@ const GridColumnHeadersPinnedColumnHeaders = styled('div', { height: '100%', zIndex: 1, display: 'flex', + flexDirection: 'column', boxShadow: theme.shadows[2], backgroundColor: theme.palette.background.default, ...(theme.palette.mode === 'dark' && { @@ -120,11 +121,17 @@ export const DataGridProColumnHeaders = React.forwardRef< const pinnedColumns = useGridSelector(apiRef, gridPinnedColumnsSelector); const [leftPinnedColumns, rightPinnedColumns] = filterColumns(pinnedColumns, visibleColumnFields); - const { isDragging, renderContext, getRootProps, getInnerProps, getColumns } = - useGridColumnHeaders({ - innerRef, - minColumnIndex: leftPinnedColumns.length, - }); + const { + isDragging, + renderContext, + getRootProps, + getInnerProps, + getColumnHeaders, + getColumnGroupHeaders, + } = useGridColumnHeaders({ + innerRef, + minColumnIndex: leftPinnedColumns.length, + }); const ownerState = { leftPinnedColumns, rightPinnedColumns, classes: rootProps.classes }; const classes = useUtilityClasses(ownerState); @@ -151,7 +158,6 @@ export const DataGridProColumnHeaders = React.forwardRef< const pinnedColumnHeadersProps = { role: innerProps.role, - 'aria-rowindex': innerProps['aria-rowindex'], }; return ( @@ -162,7 +168,12 @@ export const DataGridProColumnHeaders = React.forwardRef< ownerState={{ side: GridPinnedPosition.left }} {...pinnedColumnHeadersProps} > - {getColumns( + {getColumnGroupHeaders({ + renderContext: leftRenderContext, + minFirstColumn: leftRenderContext.firstColumnIndex, + maxLastColumn: leftRenderContext.lastColumnIndex, + })} + {getColumnHeaders( { renderContext: leftRenderContext, minFirstColumn: leftRenderContext.firstColumnIndex, @@ -173,7 +184,12 @@ export const DataGridProColumnHeaders = React.forwardRef< )} - {getColumns({ + {getColumnGroupHeaders({ + renderContext, + minFirstColumn: leftPinnedColumns.length, + maxLastColumn: visibleColumnFields.length - rightPinnedColumns.length, + })} + {getColumnHeaders({ renderContext, minFirstColumn: leftPinnedColumns.length, maxLastColumn: visibleColumnFields.length - rightPinnedColumns.length, @@ -186,7 +202,12 @@ export const DataGridProColumnHeaders = React.forwardRef< style={{ paddingRight: scrollbarSize }} {...pinnedColumnHeadersProps} > - {getColumns( + {getColumnGroupHeaders({ + renderContext: rightRenderContext, + minFirstColumn: rightRenderContext.firstColumnIndex, + maxLastColumn: rightRenderContext.lastColumnIndex, + })} + {getColumnHeaders( { renderContext: rightRenderContext, minFirstColumn: rightRenderContext.firstColumnIndex, diff --git a/packages/grid/x-data-grid-pro/src/hooks/features/columnReorder/useGridColumnReorder.tsx b/packages/grid/x-data-grid-pro/src/hooks/features/columnReorder/useGridColumnReorder.tsx index 673afb7bd7655..baf3674e8df61 100644 --- a/packages/grid/x-data-grid-pro/src/hooks/features/columnReorder/useGridColumnReorder.tsx +++ b/packages/grid/x-data-grid-pro/src/hooks/features/columnReorder/useGridColumnReorder.tsx @@ -62,6 +62,7 @@ export const useGridColumnReorder = ( y: 0, }); const originColumnIndex = React.useRef(null); + const forbiddenIndexes = React.useRef<{ [key: number]: boolean }>({}); const removeDnDStylesTimeout = React.useRef(); const ownerState = { classes: props.classes }; const classes = useUtilityClasses(ownerState); @@ -97,6 +98,66 @@ export const useGridColumnReorder = ( }); originColumnIndex.current = apiRef.current.getColumnIndex(params.field, false); + + const draggingColumnGroupPath = apiRef.current.unstable_getColumnGroupPath(params.field); + + const columnIndex = originColumnIndex.current; + const allColumns = apiRef.current.getAllColumns(); + const groupsLookup = apiRef.current.unstable_getAllGroupDetails(); + + // The limitingGroupId is the id of the group from which the dragged column should not escape + let limitingGroupId: string | null = null; + + draggingColumnGroupPath.forEach((groupId) => { + if (!groupsLookup[groupId]?.freeReordering) { + // Only consider group that are made of more than one column + if (columnIndex > 0 && allColumns[columnIndex - 1].groupPath?.includes(groupId)) { + limitingGroupId = groupId; + } else if ( + columnIndex + 1 < allColumns.length && + allColumns[columnIndex + 1].groupPath?.includes(groupId) + ) { + limitingGroupId = groupId; + } + } + }); + + forbiddenIndexes.current = {}; + + for (let indexToForbid = 0; indexToForbid < allColumns.length; indexToForbid += 1) { + const leftIndex = indexToForbid <= columnIndex ? indexToForbid - 1 : indexToForbid; + const rightIndex = indexToForbid < columnIndex ? indexToForbid : indexToForbid + 1; + + if (limitingGroupId !== null) { + // verify this indexToForbid will be linked to the limiting group. Otherwise forbid it + let allowIndex = false; + if (leftIndex >= 0 && allColumns[leftIndex].groupPath?.includes(limitingGroupId)) { + allowIndex = true; + } else if ( + rightIndex < allColumns.length && + allColumns[rightIndex].groupPath?.includes(limitingGroupId) + ) { + allowIndex = true; + } + if (!allowIndex) { + forbiddenIndexes.current[indexToForbid] = true; + } + } + + // Verify we are not splitting another group + if (leftIndex >= 0 && rightIndex < allColumns.length) { + allColumns[rightIndex]?.groupPath?.forEach((groupId) => { + if (allColumns[leftIndex].groupPath?.includes(groupId)) { + if (!draggingColumnGroupPath.includes(groupId)) { + // moving here split the group groupId in two distincts chunks + if (!groupsLookup[groupId]?.freeReordering) { + forbiddenIndexes.current[indexToForbid] = true; + } + } + } + }); + } + } }, [props.disableColumnReorder, classes.columnHeaderDragging, logger, apiRef], ); @@ -136,6 +197,7 @@ export const useGridColumnReorder = ( const targetCol = apiRef.current.getColumn(params.field); const dragColIndex = apiRef.current.getColumnIndex(dragColField, false); const visibleColumns = apiRef.current.getVisibleColumns(); + const allColumns = apiRef.current.getAllColumns(); const cursorMoveDirectionX = getCursorMoveDirectionX(cursorPosition.current, coordinates); const hasMovedLeft = @@ -145,15 +207,55 @@ export const useGridColumnReorder = ( if (hasMovedLeft || hasMovedRight) { let canBeReordered: boolean; + let indexOffsetInHiddenColumns = 0; if (!targetCol.disableReorder) { canBeReordered = true; } else if (hasMovedLeft) { canBeReordered = - targetColIndex > 0 && !visibleColumns[targetColIndex - 1].disableReorder; + targetColVisibleIndex > 0 && + !visibleColumns[targetColVisibleIndex - 1].disableReorder; } else { canBeReordered = - targetColIndex < visibleColumns.length - 1 && - !visibleColumns[targetColIndex + 1].disableReorder; + targetColVisibleIndex < visibleColumns.length - 1 && + !visibleColumns[targetColVisibleIndex + 1].disableReorder; + } + + if (forbiddenIndexes.current[targetColIndex]) { + let nextVisibleColumnField: string | null; + let indexWithOffset = targetColIndex + indexOffsetInHiddenColumns; + if (hasMovedLeft) { + nextVisibleColumnField = + targetColVisibleIndex > 0 ? visibleColumns[targetColVisibleIndex - 1].field : null; + while ( + indexWithOffset > 0 && + allColumns[indexWithOffset].field !== nextVisibleColumnField && + forbiddenIndexes.current[indexWithOffset] + ) { + indexOffsetInHiddenColumns -= 1; + indexWithOffset = targetColIndex + indexOffsetInHiddenColumns; + } + } else { + nextVisibleColumnField = + targetColVisibleIndex + 1 < visibleColumns.length + ? visibleColumns[targetColVisibleIndex + 1].field + : null; + while ( + indexWithOffset < allColumns.length - 1 && + allColumns[indexWithOffset].field !== nextVisibleColumnField && + forbiddenIndexes.current[indexWithOffset] + ) { + indexOffsetInHiddenColumns += 1; + indexWithOffset = targetColIndex + indexOffsetInHiddenColumns; + } + } + + if ( + forbiddenIndexes.current[indexWithOffset] || + allColumns[indexWithOffset].field === nextVisibleColumnField + ) { + // If we ended up on a visible column, or a forbidden one, we can not do the reorder + canBeReordered = false; + } } const canBeReorderedProcessed = apiRef.current.unstable_applyPipeProcessors( @@ -163,7 +265,10 @@ export const useGridColumnReorder = ( ); if (canBeReorderedProcessed) { - apiRef.current.setColumnIndex(dragColField, targetColIndex); + apiRef.current.setColumnIndex( + dragColField, + targetColIndex + indexOffsetInHiddenColumns, + ); } } diff --git a/packages/grid/x-data-grid-pro/src/hooks/features/columnResize/useGridColumnResize.tsx b/packages/grid/x-data-grid-pro/src/hooks/features/columnResize/useGridColumnResize.tsx index adb2fd329b9f2..ed2e4480bda88 100644 --- a/packages/grid/x-data-grid-pro/src/hooks/features/columnResize/useGridColumnResize.tsx +++ b/packages/grid/x-data-grid-pro/src/hooks/features/columnResize/useGridColumnResize.tsx @@ -22,6 +22,7 @@ import { findGridCellElementsFromCol, getFieldFromHeaderElem, findHeaderElementFromField, + findGroupHeaderElementsFromField, } from '../../../utils/domUtils'; import { GridApiPro } from '../../../models/gridApiPro'; import { DataGridProProcessedProps } from '../../../models/dataGridProProps'; @@ -132,6 +133,7 @@ export const useGridColumnResize = ( const colDefRef = React.useRef(); const colElementRef = React.useRef(); + const colGroupingElementRef = React.useRef(); const colCellElementsRef = React.useRef(); const theme = useTheme(); @@ -158,7 +160,7 @@ export const useGridColumnResize = ( colElementRef.current!.style.minWidth = `${newWidth}px`; colElementRef.current!.style.maxWidth = `${newWidth}px`; - colCellElementsRef.current!.forEach((element) => { + [...colCellElementsRef.current!, ...colGroupingElementRef.current!].forEach((element) => { const div = element as HTMLDivElement; let finalWidth: `${number}px`; @@ -252,6 +254,11 @@ export const useGridColumnResize = ( `[data-field="${colDef.field}"]`, )!; + colGroupingElementRef.current = findGroupHeaderElementsFromField( + apiRef.current.columnHeadersContainerElementRef?.current!, + colDef.field, + ); + colCellElementsRef.current = findGridCellElementsFromCol( colElementRef.current, apiRef.current, @@ -351,6 +358,10 @@ export const useGridColumnResize = ( const field = getFieldFromHeaderElem(colElementRef.current!); const colDef = apiRef.current.getColumn(field); + colGroupingElementRef.current = findGroupHeaderElementsFromField( + apiRef.current.columnHeadersContainerElementRef?.current!, + field, + ); logger.debug(`Start Resize on col ${colDef.field}`); apiRef.current.publishEvent('columnResizeStart', { field }, event); diff --git a/packages/grid/x-data-grid-pro/src/tests/columnReorder.DataGridPro.test.tsx b/packages/grid/x-data-grid-pro/src/tests/columnReorder.DataGridPro.test.tsx index 8e128217449ec..194c17c26dffc 100644 --- a/packages/grid/x-data-grid-pro/src/tests/columnReorder.DataGridPro.test.tsx +++ b/packages/grid/x-data-grid-pro/src/tests/columnReorder.DataGridPro.test.tsx @@ -424,4 +424,426 @@ describe(' - Columns reorder', () => { expect(handleDragOver.callCount).to.equal(0); expect(handleDragEnd.callCount).to.equal(0); }); + + describe('reorder with column grouping', () => { + it('should not allow to drag column outside of its group', () => { + const rows = [{ id: 0 }]; + const columns = [{ field: 'col1' }, { field: 'col2' }, { field: 'col3' }]; + + const columnGroupingModel = [ + { groupId: 'col12', children: [{ field: 'col1' }, { field: 'col2' }] }, + ]; + + const Test = () => { + return ( +
+ +
+ ); + }; + + render(); + expect(getColumnHeadersTextContent()).to.deep.equal(['col12', '', 'col1', 'col2', 'col3']); + const dragCol = getColumnHeaderCell(0, 1).firstChild!; + const targetCol = getColumnHeaderCell(2, 1).firstChild!; + + fireEvent.dragStart(dragCol); + fireEvent.dragEnter(targetCol); + const dragOverEvent2 = createDragOverEvent(targetCol); + fireEvent(targetCol, dragOverEvent2); + expect(getColumnHeadersTextContent()).to.deep.equal(['col12', '', 'col1', 'col2', 'col3']); + + const dragEndEvent = createDragEndEvent(dragCol); + fireEvent(dragCol, dragEndEvent); + expect(getColumnHeadersTextContent()).to.deep.equal(['col12', '', 'col1', 'col2', 'col3']); + }); + + describe('column - hidden', () => { + it('should use the correct start and end index', () => { + const rows = [{ id: 0 }]; + const columns = [ + { field: 'col1' }, + { field: 'col2' }, + { field: 'col3' }, + { field: 'col4' }, + ]; + + const columnGroupingModel = [ + { groupId: 'col23', children: [{ field: 'col2' }, { field: 'col3' }] }, + ]; + + const Test = () => { + return ( +
+ +
+ ); + }; + + render(); + expect(getColumnHeadersTextContent()).to.deep.equal(['col23', '', 'col2', 'col3', 'col4']); + const dragCol = getColumnHeaderCell(0, 1).firstChild!; + const col3 = getColumnHeaderCell(1, 1).firstChild!; + const col4 = getColumnHeaderCell(2, 1).firstChild!; + + // Do not allow to move col2 after col4 + fireEvent.dragStart(dragCol); + fireEvent.dragEnter(col3); + const dragOverEvent1 = createDragOverEvent(col3); + fireEvent(col3, dragOverEvent1); + expect(getColumnHeadersTextContent()).to.deep.equal(['col23', '', 'col3', 'col2', 'col4']); + + // Allow to move col2 after col3 + fireEvent.dragEnter(col4); + const dragOverEvent2 = createDragOverEvent(col4); + fireEvent(col4, dragOverEvent2); + expect(getColumnHeadersTextContent()).to.deep.equal(['col23', '', 'col3', 'col2', 'col4']); + }); + + it('should consider moving the column between hidden columns if it respect group constraint and visible behavior', () => { + const rows = [{ id: 0 }]; + const columns = [{ field: 'col1' }, { field: 'col2' }, { field: 'col3' }]; + + const columnGroupingModel = [ + { groupId: 'col23', children: [{ field: 'col2' }, { field: 'col3' }] }, + ]; + + const Test = (props: any) => { + return ( +
+ +
+ ); + }; + + const { setProps } = render(); + expect(getColumnHeadersTextContent()).to.deep.equal(['', 'col23', 'col1', 'col2']); + const dragCol = getColumnHeaderCell(0, 1).firstChild!; + const targetCol = getColumnHeaderCell(1, 1).firstChild!; + + // Move col 1 after col 3 to respect column grouping consistency even if col3 is hidden + fireEvent.dragStart(dragCol); + fireEvent.dragEnter(targetCol); + const dragOverEvent = createDragOverEvent(targetCol); + fireEvent(targetCol, dragOverEvent); + expect(getColumnHeadersTextContent()).to.deep.equal(['col23', '', 'col2', 'col1']); + + setProps({ columnVisibilityModel: {} }); + expect(getColumnHeadersTextContent()).to.deep.equal(['col23', '', 'col2', 'col3', 'col1']); + }); + }); + + it('should not allow to drag column inside a group', () => { + const rows = [{ id: 0 }]; + const columns = [{ field: 'col1' }, { field: 'col2' }, { field: 'col3' }]; + + const columnGroupingModel = [ + { groupId: 'col12', children: [{ field: 'col1' }, { field: 'col2' }] }, + ]; + + const Test = () => { + return ( +
+ +
+ ); + }; + + render(); + expect(getColumnHeadersTextContent()).to.deep.equal(['col12', '', 'col1', 'col2', 'col3']); + const dragCol = getColumnHeaderCell(2, 1).firstChild!; + const targetCol = getColumnHeaderCell(1, 1).firstChild!; + + fireEvent.dragStart(dragCol); + fireEvent.dragEnter(targetCol); + const dragOverEvent2 = createDragOverEvent(targetCol); + fireEvent(targetCol, dragOverEvent2); + expect(getColumnHeadersTextContent()).to.deep.equal(['col12', '', 'col1', 'col2', 'col3']); + + const dragEndEvent = createDragEndEvent(dragCol); + fireEvent(dragCol, dragEndEvent); + expect(getColumnHeadersTextContent()).to.deep.equal(['col12', '', 'col1', 'col2', 'col3']); + }); + + it('should allow to drag column outside of its group if it allows freeReordering', () => { + const rows = [{ id: 0 }]; + const columns = [{ field: 'col1' }, { field: 'col2' }, { field: 'col3' }]; + + const columnGroupingModel = [ + { + groupId: 'col12', + children: [{ field: 'col1' }, { field: 'col2' }], + freeReordering: true, + }, + ]; + + const Test = () => { + return ( +
+ +
+ ); + }; + + render(); + expect(getColumnHeadersTextContent()).to.deep.equal(['col12', '', 'col1', 'col2', 'col3']); + const dragCol = getColumnHeaderCell(0, 1).firstChild!; + const targetCol = getColumnHeaderCell(2, 1).firstChild!; + + fireEvent.dragStart(dragCol); + fireEvent.dragEnter(targetCol); + const dragOverEvent2 = createDragOverEvent(targetCol); + fireEvent(targetCol, dragOverEvent2); + expect(getColumnHeadersTextContent()).to.deep.equal([ + 'col12', + '', + 'col12', + 'col2', + 'col3', + 'col1', + ]); + + const dragEndEvent = createDragEndEvent(dragCol); + fireEvent(dragCol, dragEndEvent); + expect(getColumnHeadersTextContent()).to.deep.equal([ + 'col12', + '', + 'col12', + 'col2', + 'col3', + 'col1', + ]); + }); + + it('should allow to drag column inside a group if it allows freeReordering', () => { + // TODO: I observed columns are always moved from left to right + // The reason being that is: + // - when event.clientX does not change we consider that column is moving to the right + // - fireEvent.dragStart always set event.clientX = 1 (did not managed to modify this behavior) + const rows = [{ id: 0 }]; + const columns = [{ field: 'col1' }, { field: 'col2' }, { field: 'col3' }]; + + const columnGroupingModel = [ + { + groupId: 'col23', + children: [{ field: 'col2' }, { field: 'col3' }], + freeReordering: true, + }, + ]; + + const Test = () => { + return ( +
+ +
+ ); + }; + + render(); + expect(getColumnHeadersTextContent()).to.deep.equal(['', 'col23', 'col1', 'col2', 'col3']); + const dragCol = getColumnHeaderCell(0, 1).firstChild!; + const targetCol = getColumnHeaderCell(1, 1).firstChild!; + + fireEvent.dragStart(dragCol); + fireEvent.dragEnter(targetCol); + const dragOverEvent2 = createDragOverEvent(targetCol); + fireEvent(targetCol, dragOverEvent2); + expect(getColumnHeadersTextContent()).to.deep.equal([ + 'col23', + '', + 'col23', + 'col2', + 'col1', + 'col3', + ]); + + const dragEndEvent = createDragEndEvent(dragCol); + fireEvent(dragCol, dragEndEvent); + expect(getColumnHeadersTextContent()).to.deep.equal([ + 'col23', + '', + 'col23', + 'col2', + 'col1', + 'col3', + ]); + }); + + it('should allow to split a group with freeReordering in another group', () => { + const rows = [{ id: 0 }]; + const columns = [{ field: 'col1' }, { field: 'col2' }, { field: 'col3' }]; + + const columnGroupingModel = [ + { + groupId: 'col123', + children: [ + { field: 'col1' }, + { + groupId: 'col23', + children: [{ field: 'col2' }, { field: 'col3' }], + freeReordering: true, + }, + ], + }, + ]; + + const Test = () => { + return ( +
+ +
+ ); + }; + + render(); + expect(getColumnHeadersTextContent()).to.deep.equal([ + 'col123', + '', + 'col23', + 'col1', + 'col2', + 'col3', + ]); + const dragCol = getColumnHeaderCell(0, 2).firstChild!; + const targetCol = getColumnHeaderCell(1, 2).firstChild!; + + fireEvent.dragStart(dragCol); + fireEvent.dragEnter(targetCol); + const dragOverEvent2 = createDragOverEvent(targetCol); + fireEvent(targetCol, dragOverEvent2); + expect(getColumnHeadersTextContent()).to.deep.equal([ + 'col123', + 'col23', + '', + 'col23', + 'col2', + 'col1', + 'col3', + ]); + + const dragEndEvent = createDragEndEvent(dragCol); + fireEvent(dragCol, dragEndEvent); + expect(getColumnHeadersTextContent()).to.deep.equal([ + 'col123', + 'col23', + '', + 'col23', + 'col2', + 'col1', + 'col3', + ]); + }); + + it('should block dragging outside of a group even at deeper level', () => { + const rows = [{ id: 0 }]; + const columns = [{ field: 'col1' }, { field: 'col2' }, { field: 'col3' }]; + + const columnGroupingModel = [ + { + groupId: 'col12', + children: [ + { field: 'col1' }, + { + groupId: 'col2', + children: [{ field: 'col2' }], + freeReordering: true, + }, + ], + }, + ]; + + const Test = () => { + return ( +
+ +
+ ); + }; + + render(); + expect(getColumnHeadersTextContent()).to.deep.equal([ + 'col12', + '', + '', + 'col2', + '', + 'col1', + 'col2', + 'col3', + ]); + const dragCol = getColumnHeaderCell(0, 1).firstChild!; + const targetCol = getColumnHeaderCell(2, 1).firstChild!; + + fireEvent.dragStart(dragCol); + fireEvent.dragEnter(targetCol); + const dragOverEvent2 = createDragOverEvent(targetCol); + fireEvent(targetCol, dragOverEvent2); + expect(getColumnHeadersTextContent()).to.deep.equal([ + 'col12', + '', + '', + 'col2', + '', + 'col1', + 'col2', + 'col3', + ]); + + const dragEndEvent = createDragEndEvent(dragCol); + fireEvent(dragCol, dragEndEvent); + expect(getColumnHeadersTextContent()).to.deep.equal([ + 'col12', + '', + '', + 'col2', + '', + 'col1', + 'col2', + 'col3', + ]); + }); + }); }); diff --git a/packages/grid/x-data-grid-pro/src/utils/domUtils.ts b/packages/grid/x-data-grid-pro/src/utils/domUtils.ts index ef33671ad9dd6..7b03c91493eac 100644 --- a/packages/grid/x-data-grid-pro/src/utils/domUtils.ts +++ b/packages/grid/x-data-grid-pro/src/utils/domUtils.ts @@ -10,6 +10,10 @@ export function findHeaderElementFromField(elem: Element, field: string): Elemen return elem.querySelector(`[data-field="${field}"]`); } +export function findGroupHeaderElementsFromField(elem: Element, field: string): Element[] { + return Array.from(elem.querySelectorAll(`[data-fields*="|-${field}-|"]`) ?? []); +} + export function findGridCellElementsFromCol(col: HTMLElement, api: GridApiPro) { const root = findParentElementFromClassName(col, 'MuiDataGrid-root'); if (!root) { diff --git a/packages/grid/x-data-grid/src/DataGrid/DataGrid.tsx b/packages/grid/x-data-grid/src/DataGrid/DataGrid.tsx index bb76a2496ce7f..7058730b11b52 100644 --- a/packages/grid/x-data-grid/src/DataGrid/DataGrid.tsx +++ b/packages/grid/x-data-grid/src/DataGrid/DataGrid.tsx @@ -89,6 +89,7 @@ DataGridRaw.propTypes = { * @default 3 */ columnBuffer: PropTypes.number, + columnGroupingModel: PropTypes.arrayOf(PropTypes.object), /** * Set of columns of type [[GridColumns]]. */ @@ -191,6 +192,7 @@ DataGridRaw.propTypes = { * 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: PropTypes.shape({ + columnGrouping: PropTypes.bool, newEditingApi: PropTypes.bool, preventCommitWhileValidating: PropTypes.bool, warnIfFocusStateIsNotSynced: PropTypes.bool, diff --git a/packages/grid/x-data-grid/src/DataGrid/useDataGridComponent.tsx b/packages/grid/x-data-grid/src/DataGrid/useDataGridComponent.tsx index 491e46132f8fc..3eed1efbf06ea 100644 --- a/packages/grid/x-data-grid/src/DataGrid/useDataGridComponent.tsx +++ b/packages/grid/x-data-grid/src/DataGrid/useDataGridComponent.tsx @@ -45,6 +45,11 @@ import { useGridDimensions } from '../hooks/features/dimensions/useGridDimension import { rowsMetaStateInitializer, useGridRowsMeta } from '../hooks/features/rows/useGridRowsMeta'; import { useGridStatePersistence } from '../hooks/features/statePersistence/useGridStatePersistence'; import { useGridColumnSpanning } from '../hooks/features/columns/useGridColumnSpanning'; +import { + useGridColumnGrouping, + columnGroupsStateInitializer, +} from '../hooks/features/columnGrouping/useGridColumnGrouping'; +import { useGridColumnGroupingPreProcessors } from '../hooks/features/columnGrouping/useGridColumnGroupingPreProcessors'; export const useDataGridComponent = (props: DataGridProcessedProps) => { const apiRef = useGridInitialization(undefined, props); @@ -52,6 +57,7 @@ export const useDataGridComponent = (props: DataGridProcessedProps) => { /** * Register all pre-processors called during state initialization here. */ + useGridColumnGroupingPreProcessors(apiRef, props); useGridSelectionPreProcessors(apiRef, props); useGridRowsPreProcessors(apiRef); @@ -60,6 +66,7 @@ export const useDataGridComponent = (props: DataGridProcessedProps) => { */ useGridInitializeState(selectionStateInitializer, apiRef, props); useGridInitializeState(columnsStateInitializer, apiRef, props); + useGridInitializeState(columnGroupsStateInitializer, apiRef, props); useGridInitializeState(rowsStateInitializer, apiRef, props); useGridInitializeState( props.experimentalFeatures?.newEditingApi @@ -83,6 +90,7 @@ export const useDataGridComponent = (props: DataGridProcessedProps) => { useGridRows(apiRef, props); useGridParamsApi(apiRef); useGridColumnSpanning(apiRef); + useGridColumnGrouping(apiRef, props); const useGridEditing = props.experimentalFeatures?.newEditingApi ? useGridEditing_new diff --git a/packages/grid/x-data-grid/src/components/DataGridColumnHeaders.tsx b/packages/grid/x-data-grid/src/components/DataGridColumnHeaders.tsx index 5ceac2efb1ae2..0888cb7a3d165 100644 --- a/packages/grid/x-data-grid/src/components/DataGridColumnHeaders.tsx +++ b/packages/grid/x-data-grid/src/components/DataGridColumnHeaders.tsx @@ -12,15 +12,17 @@ export const DataGridColumnHeaders = React.forwardRef - {getColumns()} + {getColumnGroupHeaders()} + {getColumnHeaders()} diff --git a/packages/grid/x-data-grid/src/components/GridRow.tsx b/packages/grid/x-data-grid/src/components/GridRow.tsx index fc837b9568fe2..f570c14f170c7 100644 --- a/packages/grid/x-data-grid/src/components/GridRow.tsx +++ b/packages/grid/x-data-grid/src/components/GridRow.tsx @@ -29,6 +29,7 @@ import { GridRenderEditCellParams } from '../models/params/gridCellParams'; import { GRID_DETAIL_PANEL_TOGGLE_FIELD } from '../constants/gridDetailPanelToggleField'; import { gridSortModelSelector } from '../hooks/features/sorting/gridSortingSelector'; import { gridRowTreeDepthSelector } from '../hooks/features/rows/gridRowsSelector'; +import { gridDensityHeaderGroupingMaxDepthSelector } from '../hooks/features/density/densitySelector'; export interface GridRowProps { rowId: GridRowId; @@ -113,7 +114,6 @@ function GridRow(props: React.HTMLAttributes & GridRowProps) { onMouseLeave, ...other } = props; - const ariaRowIndex = index + 2; // 1 for the header row and 1 as it's 1-based const apiRef = useGridApiContext(); const ref = React.useRef(null); const rootProps = useGridRootProps(); @@ -121,6 +121,9 @@ function GridRow(props: React.HTMLAttributes & GridRowProps) { const columnsTotalWidth = useGridSelector(apiRef, gridColumnsTotalWidthSelector); const sortModel = useGridSelector(apiRef, gridSortModelSelector); const treeDepth = useGridSelector(apiRef, gridRowTreeDepthSelector); + const headerGroupingMaxDepth = useGridSelector(apiRef, gridDensityHeaderGroupingMaxDepthSelector); + + const ariaRowIndex = index + headerGroupingMaxDepth + 2; // 1 for the header row and 1 as it's 1-based const { hasScrollX, hasScrollY } = apiRef.current.getRootDimensions() ?? { hasScrollX: false, hasScrollY: false, diff --git a/packages/grid/x-data-grid/src/components/base/GridBody.tsx b/packages/grid/x-data-grid/src/components/base/GridBody.tsx index 7150071e596ed..83061a9b324f5 100644 --- a/packages/grid/x-data-grid/src/components/base/GridBody.tsx +++ b/packages/grid/x-data-grid/src/components/base/GridBody.tsx @@ -7,7 +7,7 @@ import { GridAutoSizer } from '../GridAutoSizer'; import { GridOverlays } from './GridOverlays'; import { useGridRootProps } from '../../hooks/utils/useGridRootProps'; import { useGridSelector } from '../../hooks/utils/useGridSelector'; -import { gridDensityHeaderHeightSelector } from '../../hooks/features/density/densitySelector'; +import { gridDensityTotalHeaderHeightSelector } from '../../hooks/features/density/densitySelector'; interface GridBodyProps { children?: React.ReactNode; @@ -30,7 +30,7 @@ function GridBody(props: GridBodyProps) { const { children, VirtualScrollerComponent, ColumnHeadersComponent } = props; const apiRef = useGridApiContext(); const rootProps = useGridRootProps(); - const headerHeight = useGridSelector(apiRef, gridDensityHeaderHeightSelector); + const totalHeaderHeight = useGridSelector(apiRef, gridDensityTotalHeaderHeightSelector); const [isVirtualizationDisabled, setIsVirtualizationDisabled] = React.useState( rootProps.disableVirtualization, ); @@ -83,8 +83,8 @@ function GridBody(props: GridBodyProps) { width: size.width, // If `autoHeight` is on, there will be no height value. // In this case, let the container to grow whatever it needs. - height: size.height ? size.height - headerHeight : 'auto', - marginTop: headerHeight, + height: size.height ? size.height - totalHeaderHeight : 'auto', + marginTop: totalHeaderHeight, } as React.CSSProperties; return ( 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 ce3a618e5796b..ea5f54c56dc3a 100644 --- a/packages/grid/x-data-grid/src/components/base/GridOverlays.tsx +++ b/packages/grid/x-data-grid/src/components/base/GridOverlays.tsx @@ -8,12 +8,12 @@ import { } from '../../hooks/features/rows/gridRowsSelector'; import { useGridApiContext } from '../../hooks/utils/useGridApiContext'; import { useGridRootProps } from '../../hooks/utils/useGridRootProps'; -import { gridDensityHeaderHeightSelector } from '../../hooks/features/density/densitySelector'; +import { gridDensityTotalHeaderHeightSelector } from '../../hooks/features/density/densitySelector'; function GridOverlayWrapper(props: React.PropsWithChildren<{}>) { const apiRef = useGridApiContext(); const rootProps = useGridRootProps(); - const headerHeight = useGridSelector(apiRef, gridDensityHeaderHeightSelector); + const totalHeaderHeight = useGridSelector(apiRef, gridDensityTotalHeaderHeightSelector); const [viewportInnerSize, setViewportInnerSize] = React.useState( () => apiRef.current.getRootDimensions()?.viewportInnerSize ?? null, @@ -42,7 +42,7 @@ function GridOverlayWrapper(props: React.PropsWithChildren<{}>) { height, width: viewportInnerSize?.width ?? 0, position: 'absolute', - top: headerHeight, + top: totalHeaderHeight, bottom: height === 'auto' ? 0 : undefined, }} {...props} diff --git a/packages/grid/x-data-grid/src/components/columnHeaders/GridColumnGroupHeader.tsx b/packages/grid/x-data-grid/src/components/columnHeaders/GridColumnGroupHeader.tsx new file mode 100644 index 0000000000000..2e9f74064462c --- /dev/null +++ b/packages/grid/x-data-grid/src/components/columnHeaders/GridColumnGroupHeader.tsx @@ -0,0 +1,157 @@ +import * as React from 'react'; +import { unstable_useId as useId } from '@mui/utils'; +import { unstable_composeClasses as composeClasses } from '@mui/material'; +import { GridAlignment } from '../../models/colDef/gridColDef'; +import { getDataGridUtilityClass } from '../../constants/gridClasses'; +import { useGridRootProps } from '../../hooks/utils/useGridRootProps'; +import { DataGridProcessedProps } from '../../models/props/DataGridProps'; +import { gridColumnGroupsLookupSelector } from '../../hooks/features/columnGrouping/gridColumnGroupsSelector'; +import { useGridApiContext } from '../../hooks/utils/useGridApiContext'; +import { useGridSelector } from '../../hooks/utils/useGridSelector'; +import { GridGenericColumnHeaderItem } from './GridGenericColumnHeaderItem'; +import { GridColumnGroup } from '../../models/gridColumnGrouping'; + +interface GridColumnGroupHeaderProps { + groupId: string | null; + width: number; + fields: string[]; + colIndex: number; // TODO: use this prop to get accessible column group + isLastColumn: boolean; + extendRowFullWidth: boolean; + depth: number; + maxDepth: number; + height: number; +} + +type OwnerState = { + groupId: GridColumnGroupHeaderProps['groupId']; + showRightBorder: boolean; + showColumnBorder: boolean; + isDragging: boolean; + headerAlign?: GridAlignment; + classes?: DataGridProcessedProps['classes']; +}; + +const useUtilityClasses = (ownerState: OwnerState) => { + const { classes, headerAlign, isDragging, showRightBorder, showColumnBorder, groupId } = + ownerState; + + const slots = { + root: [ + 'columnHeader', + headerAlign === 'left' && 'columnHeader--alignLeft', + headerAlign === 'center' && 'columnHeader--alignCenter', + headerAlign === 'right' && 'columnHeader--alignRight', + isDragging && 'columnHeader--moving', + showRightBorder && 'withBorder', + showColumnBorder && 'columnHeader--showColumnBorder', + groupId === null ? 'columnHeader--emptyGroup' : 'columnHeader--filledGroup', + ], + draggableContainer: ['columnHeaderDraggableContainer'], + titleContainer: ['columnHeaderTitleContainer'], + titleContainerContent: ['columnHeaderTitleContainerContent'], + }; + + return composeClasses(slots, getDataGridUtilityClass, classes); +}; + +function GridColumnGroupHeader(props: GridColumnGroupHeaderProps) { + const { + groupId, + width, + depth, + maxDepth, + fields, + height, + colIndex, + isLastColumn, + extendRowFullWidth, + } = props; + + const rootProps = useGridRootProps(); + + const apiRef = useGridApiContext(); + const columnGroupsLookup = useGridSelector(apiRef, gridColumnGroupsLookupSelector); + const { hasScrollX, hasScrollY } = apiRef.current.getRootDimensions() ?? { + hasScrollX: false, + hasScrollY: false, + }; + + const group: Partial = groupId ? columnGroupsLookup[groupId] : {}; + + const { headerName = groupId ?? '', description = '', headerAlign = undefined } = group; + + let headerComponent: React.ReactNode; + + const render = groupId && columnGroupsLookup[groupId]?.renderHeaderGroup; + const renderParams = { + groupId, + headerName, + description, + depth, + maxDepth, + fields, + colIndex, + isLastColumn, + }; + if (groupId && render) { + headerComponent = render(renderParams); + } + + const removeLastBorderRight = isLastColumn && hasScrollX && !hasScrollY; + const showRightBorder = !isLastColumn + ? rootProps.showColumnRightBorder + : !removeLastBorderRight && !extendRowFullWidth; + + const showColumnBorder = rootProps.showColumnRightBorder; + + const ownerState = { + ...props, + classes: rootProps.classes, + showRightBorder, + showColumnBorder, + headerAlign, + depth, + isDragging: false, + }; + + const label = headerName ?? groupId; + + const id = useId(); + const elementId = groupId === null ? `empty-group-cell-${id}` : groupId; + const classes = useUtilityClasses(ownerState); + + const headerClassName = + typeof group.headerClassName === 'function' + ? group.headerClassName(renderParams) + : group.headerClassName; + + return ( + + ); +} + +export { GridColumnGroupHeader }; diff --git a/packages/grid/x-data-grid/src/components/columnHeaders/GridColumnHeaderItem.tsx b/packages/grid/x-data-grid/src/components/columnHeaders/GridColumnHeaderItem.tsx index 83cf862460b9d..65b0e010d5884 100644 --- a/packages/grid/x-data-grid/src/components/columnHeaders/GridColumnHeaderItem.tsx +++ b/packages/grid/x-data-grid/src/components/columnHeaders/GridColumnHeaderItem.tsx @@ -1,23 +1,19 @@ import * as React from 'react'; import PropTypes from 'prop-types'; -import clsx from 'clsx'; import { unstable_composeClasses as composeClasses } from '@mui/material'; import { unstable_useId as useId } from '@mui/material/utils'; -import { GridColumnHeaderEventLookup } from '../../models/events'; import { GridStateColDef } from '../../models/colDef/gridColDef'; import { GridSortDirection } from '../../models/gridSortModel'; import { useGridApiContext } from '../../hooks/utils/useGridApiContext'; import { GridColumnHeaderSortIcon } from './GridColumnHeaderSortIcon'; -import { GridColumnHeaderTitle } from './GridColumnHeaderTitle'; -import { - GridColumnHeaderSeparator, - GridColumnHeaderSeparatorProps, -} from './GridColumnHeaderSeparator'; +import { GridColumnHeaderSeparatorProps } from './GridColumnHeaderSeparator'; import { ColumnHeaderMenuIcon } from './ColumnHeaderMenuIcon'; import { GridColumnHeaderMenu } from '../menu/columnMenu/GridColumnHeaderMenu'; import { getDataGridUtilityClass } from '../../constants/gridClasses'; import { useGridRootProps } from '../../hooks/utils/useGridRootProps'; import { DataGridProcessedProps } from '../../models/props/DataGridProps'; +import { GridGenericColumnHeaderItem } from './GridGenericColumnHeaderItem'; +import { GridColumnHeaderEventLookup } from '../../models/events'; interface GridColumnHeaderItemProps { colIndex: number; @@ -106,11 +102,24 @@ function GridColumnHeaderItem(props: GridColumnHeaderItemProps) { [rootProps.disableColumnReorder, disableReorder, column.disableReorder], ); - let headerComponent: React.ReactNode = null; + let headerComponent: React.ReactNode; if (column.renderHeader) { headerComponent = column.renderHeader(apiRef.current.getColumnHeaderParams(column.field)); } + const removeLastBorderRight = isLastColumn && hasScrollX && !hasScrollY; + const showRightBorder = !isLastColumn + ? rootProps.showColumnRightBorder + : !removeLastBorderRight && !extendRowFullWidth; + + const ownerState = { + ...props, + classes: rootProps.classes, + showRightBorder, + }; + + const classes = useUtilityClasses(ownerState); + const publish = React.useCallback( (eventName: keyof GridColumnHeaderEventLookup) => (event: React.SyntheticEvent) => { // Ignore portal @@ -127,46 +136,38 @@ function GridColumnHeaderItem(props: GridColumnHeaderItemProps) { [apiRef, column.field], ); - const mouseEventsHandlers = { - onClick: publish('columnHeaderClick'), - onDoubleClick: publish('columnHeaderDoubleClick'), - onMouseOver: publish('columnHeaderOver'), // TODO remove as it's not used - onMouseOut: publish('columnHeaderOut'), // TODO remove as it's not used - onMouseEnter: publish('columnHeaderEnter'), // TODO remove as it's not used - onMouseLeave: publish('columnHeaderLeave'), // TODO remove as it's not used - onKeyDown: publish('columnHeaderKeyDown'), - onFocus: publish('columnHeaderFocus'), - onBlur: publish('columnHeaderBlur'), - }; - - const draggableEventHandlers = isDraggable - ? { - onDragStart: publish('columnHeaderDragStart'), - onDragEnter: publish('columnHeaderDragEnter'), - onDragOver: publish('columnHeaderDragOver'), - onDragEnd: publish('columnHeaderDragEnd'), - } - : null; - - const removeLastBorderRight = isLastColumn && hasScrollX && !hasScrollY; - const showRightBorder = !isLastColumn - ? rootProps.showColumnRightBorder - : !removeLastBorderRight && !extendRowFullWidth; - - const ownerState = { - ...props, - classes: rootProps.classes, - showRightBorder, - }; - - const classes = useUtilityClasses(ownerState); + const mouseEventsHandlers = React.useMemo( + () => ({ + onClick: publish('columnHeaderClick'), + onDoubleClick: publish('columnHeaderDoubleClick'), + onMouseOver: publish('columnHeaderOver'), // TODO remove as it's not used + onMouseOut: publish('columnHeaderOut'), // TODO remove as it's not used + onMouseEnter: publish('columnHeaderEnter'), // TODO remove as it's not used + onMouseLeave: publish('columnHeaderLeave'), // TODO remove as it's not used + onKeyDown: publish('columnHeaderKeyDown'), + onFocus: publish('columnHeaderFocus'), + onBlur: publish('columnHeaderBlur'), + }), + [publish], + ); - const width = column.computedWidth; + const draggableEventHandlers = React.useMemo( + () => + isDraggable + ? { + onDragStart: publish('columnHeaderDragStart'), + onDragEnter: publish('columnHeaderDragEnter'), + onDragOver: publish('columnHeaderDragOver'), + onDragEnd: publish('columnHeaderDragEnd'), + } + : {}, + [isDraggable, publish], + ); - let ariaSort: 'ascending' | 'descending' | 'none' = 'none'; - if (sortDirection != null) { - ariaSort = sortDirection === 'asc' ? 'ascending' : 'descending'; - } + const columnHeaderSeparatorProps = React.useMemo( + () => ({ onMouseDown: publish('columnSeparatorMouseDown') }), + [publish], + ); React.useEffect(() => { if (!showColumnMenuIcon) { @@ -188,6 +189,19 @@ function GridColumnHeaderItem(props: GridColumnHeaderItemProps) { /> ); + const columnMenu = ( + + ); + const sortingOrder: GridSortDirection[] = column.sortingOrder ?? rootProps.sortingOrder; const columnTitleIconButtons = ( @@ -228,61 +242,33 @@ function GridColumnHeaderItem(props: GridColumnHeaderItemProps) { const label = column.headerName ?? column.field; return ( -
-
-
-
- {column.renderHeader ? ( - headerComponent - ) : ( - - )} -
- {columnTitleIconButtons} -
- {columnMenuIconButton} -
- - -
+ /> ); } diff --git a/packages/grid/x-data-grid/src/components/columnHeaders/GridColumnHeadersInner.tsx b/packages/grid/x-data-grid/src/components/columnHeaders/GridColumnHeadersInner.tsx index ff12b3c2b7d41..df6a1ec961d93 100644 --- a/packages/grid/x-data-grid/src/components/columnHeaders/GridColumnHeadersInner.tsx +++ b/packages/grid/x-data-grid/src/components/columnHeaders/GridColumnHeadersInner.tsx @@ -36,7 +36,8 @@ const GridColumnHeadersInnerRoot = styled('div', { ], })(() => ({ display: 'flex', - alignItems: 'center', + alignItems: 'flex-start', + flexDirection: 'column', [`&.${gridClasses.columnHeaderDropZone} .${gridClasses.columnHeaderDraggableContainer}`]: { cursor: 'move', }, diff --git a/packages/grid/x-data-grid/src/components/columnHeaders/GridGenericColumnHeaderItem.tsx b/packages/grid/x-data-grid/src/components/columnHeaders/GridGenericColumnHeaderItem.tsx new file mode 100644 index 0000000000000..54a51f5da338a --- /dev/null +++ b/packages/grid/x-data-grid/src/components/columnHeaders/GridGenericColumnHeaderItem.tsx @@ -0,0 +1,152 @@ +import * as React from 'react'; +import clsx from 'clsx'; +import { useForkRef } from '@mui/material/utils'; +import { GridStateColDef } from '../../models/colDef/gridColDef'; +import { GridSortDirection } from '../../models/gridSortModel'; +import { useGridApiContext } from '../../hooks/utils/useGridApiContext'; +import { GridColumnHeaderTitle } from './GridColumnHeaderTitle'; +import { + GridColumnHeaderSeparator, + GridColumnHeaderSeparatorProps, +} from './GridColumnHeaderSeparator'; +import { useGridRootProps } from '../../hooks/utils/useGridRootProps'; +import { GridColumnGroup } from '../../models/gridColumnGrouping'; + +interface GridGenericColumnHeaderItemProps + extends Pick { + classes: Record< + 'root' | 'draggableContainer' | 'titleContainer' | 'titleContainerContent', + string + >; + colIndex: number; + columnMenuOpen: boolean; + height: number; + isResizing: boolean; + sortDirection: GridSortDirection; + sortIndex?: number; + filterItemsCounter?: number; + hasFocus?: boolean; + tabIndex: 0 | -1; + disableReorder?: boolean; + separatorSide?: GridColumnHeaderSeparatorProps['side']; + headerComponent?: React.ReactNode; + elementId: GridStateColDef['field'] | GridColumnGroup['groupId']; + isDraggable: boolean; + width: number; + columnMenuIconButton?: React.ReactNode; + columnMenu?: React.ReactNode; + columnTitleIconButtons?: React.ReactNode; + label: string; + draggableContainerProps?: Partial>; + columnHeaderSeparatorProps?: Partial; + disableHeaderSeparator?: boolean; +} + +const GridGenericColumnHeaderItem = React.forwardRef(function GridGenericColumnHeaderItem( + props: GridGenericColumnHeaderItemProps, + ref, +) { + const { + classes, + columnMenuOpen, + colIndex, + height, + isResizing, + sortDirection, + hasFocus, + tabIndex, + separatorSide, + isDraggable, + headerComponent, + description, + elementId, + width, + columnMenuIconButton = null, + columnMenu = null, + columnTitleIconButtons = null, + headerClassName, + label, + resizable, + draggableContainerProps, + columnHeaderSeparatorProps, + disableHeaderSeparator, + ...other + } = props; + + const apiRef = useGridApiContext(); + const rootProps = useGridRootProps(); + const headerCellRef = React.useRef(null); + const [showColumnMenuIcon, setShowColumnMenuIcon] = React.useState(columnMenuOpen); + + const handleRef = useForkRef(headerCellRef, ref); + + let ariaSort: 'ascending' | 'descending' | 'none' = 'none'; + if (sortDirection != null) { + ariaSort = sortDirection === 'asc' ? 'ascending' : 'descending'; + } + + React.useEffect(() => { + if (!showColumnMenuIcon) { + setShowColumnMenuIcon(columnMenuOpen); + } + }, [showColumnMenuIcon, columnMenuOpen]); + + React.useLayoutEffect(() => { + const columnMenuState = apiRef.current.state.columnMenu; + if (hasFocus && !columnMenuState.open) { + const focusableElement = headerCellRef.current!.querySelector('[tabindex="0"]'); + const elementToFocus = focusableElement || headerCellRef.current; + elementToFocus?.focus(); + apiRef.current.columnHeadersContainerElementRef!.current!.scrollLeft = 0; + } + }, [apiRef, hasFocus]); + + return ( +
+
+
+
+ {headerComponent !== undefined ? ( + headerComponent + ) : ( + + )} +
+ {columnTitleIconButtons} +
+ {columnMenuIconButton} +
+ {!disableHeaderSeparator && ( + + )} + {columnMenu} +
+ ); +}); + +export { GridGenericColumnHeaderItem }; diff --git a/packages/grid/x-data-grid/src/components/containers/GridRoot.tsx b/packages/grid/x-data-grid/src/components/containers/GridRoot.tsx index 7b3971d05007d..9f5c85b859ea8 100644 --- a/packages/grid/x-data-grid/src/components/containers/GridRoot.tsx +++ b/packages/grid/x-data-grid/src/components/containers/GridRoot.tsx @@ -16,11 +16,14 @@ import { useGridSelector } from '../../hooks/utils/useGridSelector'; import { useGridApiContext } from '../../hooks/utils/useGridApiContext'; import { useGridRootProps } from '../../hooks/utils/useGridRootProps'; import { getDataGridUtilityClass } from '../../constants/gridClasses'; +import { + gridDensityHeaderGroupingMaxDepthSelector, + gridDensityValueSelector, +} from '../../hooks/features/density/densitySelector'; import { gridPinnedRowsCountSelector, gridRowCountSelector, } from '../../hooks/features/rows/gridRowsSelector'; -import { gridDensityValueSelector } from '../../hooks/features/density/densitySelector'; import { DataGridProcessedProps } from '../../models/props/DataGridProps'; import { GridDensity } from '../../models/gridDensity'; @@ -54,6 +57,7 @@ const GridRoot = React.forwardRef(function GridRo const visibleColumns = useGridSelector(apiRef, gridVisibleColumnDefinitionsSelector); const totalRowCount = useGridSelector(apiRef, gridRowCountSelector); const densityValue = useGridSelector(apiRef, gridDensityValueSelector); + const headerGroupingMaxDepth = useGridSelector(apiRef, gridDensityHeaderGroupingMaxDepthSelector); const rootContainerRef: GridRootContainerRef = React.useRef(null); const handleRef = useForkRef(rootContainerRef, ref); const pinnedRowsCount = useGridSelector(apiRef, gridPinnedRowsCountSelector); @@ -90,7 +94,7 @@ const GridRoot = React.forwardRef(function GridRo className={clsx(className, classes.root)} role="grid" aria-colcount={visibleColumns.length} - aria-rowcount={totalRowCount + pinnedRowsCount + 1} // +1 for the header row + aria-rowcount={headerGroupingMaxDepth + 1 + pinnedRowsCount + totalRowCount} aria-multiselectable={!rootProps.disableMultipleSelection} aria-label={rootProps['aria-label']} aria-labelledby={rootProps['aria-labelledby']} diff --git a/packages/grid/x-data-grid/src/components/containers/GridRootStyles.ts b/packages/grid/x-data-grid/src/components/containers/GridRootStyles.ts index 499f6fae91963..f1f9bd738b751 100644 --- a/packages/grid/x-data-grid/src/components/containers/GridRootStyles.ts +++ b/packages/grid/x-data-grid/src/components/containers/GridRootStyles.ts @@ -145,13 +145,26 @@ export const GridRootStyles = styled('div', { minWidth: 0, flex: 1, whiteSpace: 'nowrap', - overflowX: 'hidden', + overflow: 'hidden', }, [`& .${gridClasses.columnHeaderTitleContainerContent}`]: { overflow: 'hidden', display: 'flex', alignItems: 'center', }, + [`& .${gridClasses['columnHeader--filledGroup']} .${gridClasses.columnHeaderTitleContainer}`]: { + borderBottom: `solid ${borderColor} 1px`, + boxSizing: 'border-box', + }, + [`& .${gridClasses['columnHeader--filledGroup']}.${gridClasses['columnHeader--showColumnBorder']} .${gridClasses.columnHeaderTitleContainer}`]: + { + borderBottom: `none`, + }, + [`& .${gridClasses['columnHeader--filledGroup']}.${gridClasses['columnHeader--showColumnBorder']}`]: + { + borderBottom: `solid ${borderColor} 1px`, + boxSizing: 'border-box', + }, [`& .${gridClasses.sortIcon}, & .${gridClasses.filterIcon}`]: { fontSize: 'inherit', }, @@ -340,6 +353,7 @@ export const GridRootStyles = styled('div', { [`& .${gridClasses.columnHeaderDraggableContainer}`]: { display: 'flex', width: '100%', + height: '100%', }, [`& .${gridClasses.rowReorderCellPlaceholder}`]: { display: 'none', diff --git a/packages/grid/x-data-grid/src/constants/gridClasses.ts b/packages/grid/x-data-grid/src/constants/gridClasses.ts index 982b7eb2eb1d7..ecda4f4d810a3 100644 --- a/packages/grid/x-data-grid/src/constants/gridClasses.ts +++ b/packages/grid/x-data-grid/src/constants/gridClasses.ts @@ -113,6 +113,10 @@ export interface GridClasses { * Styles applied to the column header element. */ columnHeader: string; + /** + * Styles applied to the column group header element. + */ + columnGroupHeader: string; /** * Styles applied to the header checkbox cell element. */ @@ -141,6 +145,18 @@ export interface GridClasses { * Styles applied to the column header's title excepted buttons. */ columnHeaderTitleContainerContent: string; + /** + * Styles applied to the column group header cell if not empty. + */ + 'columnHeader--filledGroup': string; + /** + * Styles applied to the empty column group header cell. + */ + 'columnHeader--emptyGroup': string; + /** + * Styles applied to the column group header cell when show column border. + */ + 'columnHeader--showColumnBorder': string; /** * Styles applied to the column headers. */ @@ -504,6 +520,10 @@ export const gridClasses = generateUtilityClasses('MuiDataGrid', [ 'columnHeaderTitle', 'columnHeaderTitleContainer', 'columnHeaderTitleContainerContent', + 'columnGroupHeader', + 'columnHeader--filledGroup', + 'columnHeader--emptyGroup', + 'columnHeader--showColumnBorder', 'columnHeaders', 'columnHeadersInner', 'columnHeadersInner--scrollable', diff --git a/packages/grid/x-data-grid/src/hooks/features/columnGrouping/gridColumnGroupsInterfaces.ts b/packages/grid/x-data-grid/src/hooks/features/columnGrouping/gridColumnGroupsInterfaces.ts new file mode 100644 index 0000000000000..f34a26ca45ec6 --- /dev/null +++ b/packages/grid/x-data-grid/src/hooks/features/columnGrouping/gridColumnGroupsInterfaces.ts @@ -0,0 +1,9 @@ +import { GridColumnGroup } from '../../../models/gridColumnGrouping'; + +export type GridColumnGroupLookup = { + [field: string]: Omit; +}; + +export interface GridColumnsGroupingState { + lookup: GridColumnGroupLookup; +} diff --git a/packages/grid/x-data-grid/src/hooks/features/columnGrouping/gridColumnGroupsSelector.ts b/packages/grid/x-data-grid/src/hooks/features/columnGrouping/gridColumnGroupsSelector.ts new file mode 100644 index 0000000000000..d97a7a2274b6c --- /dev/null +++ b/packages/grid/x-data-grid/src/hooks/features/columnGrouping/gridColumnGroupsSelector.ts @@ -0,0 +1,13 @@ +import { createSelector } from '../../../utils/createSelector'; +import { GridStateCommunity } from '../../../models/gridStateCommunity'; + +/** + * @category ColumnGrouping + * @ignore - do not document. + */ +export const gridColumnGroupingSelector = (state: GridStateCommunity) => state.columnGrouping; + +export const gridColumnGroupsLookupSelector = createSelector( + gridColumnGroupingSelector, + (columnGrouping) => columnGrouping.lookup, +); diff --git a/packages/grid/x-data-grid/src/hooks/features/columnGrouping/index.ts b/packages/grid/x-data-grid/src/hooks/features/columnGrouping/index.ts new file mode 100644 index 0000000000000..dbdd20cca9b13 --- /dev/null +++ b/packages/grid/x-data-grid/src/hooks/features/columnGrouping/index.ts @@ -0,0 +1,2 @@ +export * from './gridColumnGroupsSelector'; +export type { GridColumnsGroupingState } from './gridColumnGroupsInterfaces'; diff --git a/packages/grid/x-data-grid/src/hooks/features/columnGrouping/useGridColumnGrouping.ts b/packages/grid/x-data-grid/src/hooks/features/columnGrouping/useGridColumnGrouping.ts new file mode 100644 index 0000000000000..5b2e107a8309c --- /dev/null +++ b/packages/grid/x-data-grid/src/hooks/features/columnGrouping/useGridColumnGrouping.ts @@ -0,0 +1,172 @@ +import * as React from 'react'; +import { GridApiCommunity } from '../../../models/api/gridApiCommunity'; +import { DataGridProcessedProps } from '../../../models/props/DataGridProps'; +import { GridStateInitializer } from '../../utils/useGridInitializeState'; +import { + GridColumnGroupingModel, + GridColumnNode, + GridColumnGroup, + isLeaf, +} from '../../../models/gridColumnGrouping'; +import { gridColumnGroupsLookupSelector } from './gridColumnGroupsSelector'; +import { gridColumnLookupSelector } from '../columns/gridColumnsSelector'; +import { GridColumnGroupLookup } from './gridColumnGroupsInterfaces'; +import { GridColumnGroupingApi } from '../../../models/api/gridColumnGroupingApi'; +import { useGridApiMethod } from '../../utils/useGridApiMethod'; +import { GridStateColDef, GridColDef } from '../../../models/colDef'; + +export function hasGroupPath( + lookupElement: GridColDef | GridStateColDef, +): lookupElement is GridStateColDef { + return (lookupElement).groupPath !== undefined; +} + +type UnwrappedGroupingModel = { [key: GridColDef['field']]: GridColumnGroup['groupId'][] }; + +// This is the recurrence function that help writing `unwrapGroupingColumnModel()` +const recurrentUnwrapGroupingColumnModel = ( + columnGroupNode: GridColumnNode, + parents: GridColumnGroup['groupId'][], + unwrappedGroupingModelToComplet: UnwrappedGroupingModel, +): void => { + if (isLeaf(columnGroupNode)) { + if (unwrappedGroupingModelToComplet[columnGroupNode.field] !== undefined) { + throw new Error( + [ + `MUI: columnGroupingModel contains duplicated field`, + `column field ${columnGroupNode.field} occurrs two times in the grouping model:`, + `- ${unwrappedGroupingModelToComplet[columnGroupNode.field].join(' > ')}`, + `- ${parents.join(' > ')}`, + ].join('\n'), + ); + } + unwrappedGroupingModelToComplet[columnGroupNode.field] = parents; + return; + } + + const { groupId, children } = columnGroupNode; + children.forEach((child) => { + recurrentUnwrapGroupingColumnModel( + child, + [...parents, groupId], + unwrappedGroupingModelToComplet, + ); + }); +}; + +/** + * This is a function that provide for each column the array of its parents. + * Parents are ordered from the root to the leaf. + * @param columnGroupingModel The model such as provided in DataGrid props + * @returns An object `{[field]: groupIds}` where `groupIds` is the parents of the column `field` + */ +export const unwrapGroupingColumnModel = ( + columnGroupingModel?: GridColumnGroupingModel, +): UnwrappedGroupingModel => { + if (!columnGroupingModel) { + return {}; + } + + const unwrappedSubTree: UnwrappedGroupingModel = {}; + columnGroupingModel.forEach((columnGroupNode) => { + recurrentUnwrapGroupingColumnModel(columnGroupNode, [], unwrappedSubTree); + }); + + return unwrappedSubTree; +}; + +const createGroupLookup = (columnGroupingModel: GridColumnNode[]): GridColumnGroupLookup => { + let groupLookup: GridColumnGroupLookup = {}; + + columnGroupingModel.forEach((node) => { + if (isLeaf(node)) { + return; + } + const { groupId, children, ...other } = node; + if (!groupId) { + throw new Error( + 'MUI: An element of the columnGroupingModel does not have either `field` or `groupId`.', + ); + } + if (!children) { + console.warn(`MUI: group groupId=${groupId} has no children.`); + } + const groupParam = { ...other, groupId }; + const subTreeLookup = createGroupLookup(children); + if (subTreeLookup[groupId] !== undefined || groupLookup[groupId] !== undefined) { + throw new Error( + `MUI: The groupId ${groupId} is used multiple times in the columnGroupingModel.`, + ); + } + groupLookup = { ...groupLookup, ...subTreeLookup, [groupId]: groupParam }; + }); + + return { ...groupLookup }; +}; +export const columnGroupsStateInitializer: GridStateInitializer< + Pick +> = (state, props) => { + const groupLookup = createGroupLookup(props.columnGroupingModel ?? []); + return { + ...state, + columnGrouping: { lookup: groupLookup, groupCollapsedModel: {} }, + }; +}; + +/** + * @requires useGridColumns (method, event) + * @requires useGridParamsApi (method) + */ +export const useGridColumnGrouping = ( + apiRef: React.MutableRefObject, + props: Pick, +) => { + /** + * API METHODS + */ + const getColumnGroupPath = React.useCallback< + GridColumnGroupingApi['unstable_getColumnGroupPath'] + >( + (field) => { + const columnLookup = gridColumnLookupSelector(apiRef); + + return columnLookup[field]?.groupPath ?? []; + }, + [apiRef], + ); + + const getAllGroupDetails = React.useCallback< + GridColumnGroupingApi['unstable_getAllGroupDetails'] + >(() => { + const columnGroupLookup = gridColumnGroupsLookupSelector(apiRef); + return columnGroupLookup; + }, [apiRef]); + + const columnGroupingApi: GridColumnGroupingApi = { + unstable_getColumnGroupPath: getColumnGroupPath, + unstable_getAllGroupDetails: getAllGroupDetails, + }; + + useGridApiMethod(apiRef, columnGroupingApi, 'GridColumnGroupingApi'); + + /** + * EFFECTS + */ + // The effect does not track any value defined synchronously during the 1st render by hooks called after `useGridColumns` + // As a consequence, the state generated by the 1st run of this useEffect will always be equal to the initialization one + const isFirstRender = React.useRef(true); + React.useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + if (!props.experimentalFeatures?.columnGrouping) { + return; + } + const groupLookup = createGroupLookup(props.columnGroupingModel ?? []); + apiRef.current.setState((state) => ({ + ...state, + columnGrouping: { ...state.columnGrouping, lookup: groupLookup }, + })); + }, [apiRef, props.columnGroupingModel, props.experimentalFeatures?.columnGrouping]); +}; diff --git a/packages/grid/x-data-grid/src/hooks/features/columnGrouping/useGridColumnGroupingPreProcessors.ts b/packages/grid/x-data-grid/src/hooks/features/columnGrouping/useGridColumnGroupingPreProcessors.ts new file mode 100644 index 0000000000000..efda8bc2f9ad7 --- /dev/null +++ b/packages/grid/x-data-grid/src/hooks/features/columnGrouping/useGridColumnGroupingPreProcessors.ts @@ -0,0 +1,38 @@ +import * as React from 'react'; +import { GridPipeProcessor, useGridRegisterPipeProcessor } from '../../core/pipeProcessing'; +import { DataGridProcessedProps } from '../../../models/props/DataGridProps'; +import { GridApiCommunity } from '../../../models/api/gridApiCommunity'; +import { isDeepEqual } from '../../../utils/utils'; +import { unwrapGroupingColumnModel, hasGroupPath } from './useGridColumnGrouping'; + +export const useGridColumnGroupingPreProcessors = ( + apiRef: React.MutableRefObject, + props: DataGridProcessedProps, +) => { + const addHeaderGroups = React.useCallback>( + (columnsState) => { + if (!props.experimentalFeatures?.columnGrouping) { + return columnsState; + } + const unwrappedGroupingModel = unwrapGroupingColumnModel(props.columnGroupingModel); + + columnsState.all.forEach((field) => { + const newGroupPath = unwrappedGroupingModel[field] ?? []; + + const lookupElement = columnsState.lookup[field]; + if (hasGroupPath(lookupElement) && isDeepEqual(newGroupPath, lookupElement?.groupPath)) { + // Avoid modifying the pointer to allow shadow comparison in https://github.com/mui/mui-x/blob/f90afbf10a1264ee8b453d7549dd7cdd6110a4ed/packages/grid/x-data-grid/src/hooks/features/columns/gridColumnsUtils.ts#L446:L453 + return; + } + columnsState.lookup[field] = { + ...columnsState.lookup[field], + groupPath: unwrappedGroupingModel[field] ?? [], + }; + }); + return columnsState; + }, + [props.columnGroupingModel, props.experimentalFeatures?.columnGrouping], + ); + + useGridRegisterPipeProcessor(apiRef, 'hydrateColumns', addHeaderGroups); +}; diff --git a/packages/grid/x-data-grid/src/hooks/features/columnHeaders/useGridColumnHeaders.tsx b/packages/grid/x-data-grid/src/hooks/features/columnHeaders/useGridColumnHeaders.tsx index a1944563c1bfc..e08ce80f4c087 100644 --- a/packages/grid/x-data-grid/src/hooks/features/columnHeaders/useGridColumnHeaders.tsx +++ b/packages/grid/x-data-grid/src/hooks/features/columnHeaders/useGridColumnHeaders.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import * as ReactDOM from 'react-dom'; import { useForkRef } from '@mui/material/utils'; +import { styled } from '@mui/material/styles'; import { defaultMemoize } from 'reselect'; import { useGridApiContext } from '../../utils/useGridApiContext'; import { useGridSelector } from '../../utils/useGridSelector'; @@ -13,7 +14,11 @@ import { gridTabIndexCellSelector, gridFocusColumnHeaderSelector, } from '../focus/gridFocusStateSelector'; -import { gridDensityHeaderHeightSelector } from '../density/densitySelector'; +import { + gridDensityHeaderHeightSelector, + gridDensityHeaderGroupingMaxDepthSelector, + gridDensityTotalHeaderHeightSelector, +} from '../density/densitySelector'; import { gridFilterActiveItemsLookupSelector } from '../filter/gridFilterSelector'; import { gridSortColumnLookupSelector } from '../sorting/gridSortingSelector'; import { gridColumnMenuSelector } from '../columnMenu/columnMenuSelector'; @@ -25,12 +30,41 @@ import { GridColumnHeaderItem } from '../../../components/columnHeaders/GridColu import { getFirstColumnIndexToRender } from '../columns/gridColumnsUtils'; import { useGridVisibleRows } from '../../utils/useGridVisibleRows'; import { getRenderableIndexes } from '../virtualization/useGridVirtualScroller'; +import { GridColumnGroupHeader } from '../../../components/columnHeaders/GridColumnGroupHeader'; +import { GridColumnGroup } from '../../../models/gridColumnGrouping'; +import { isDeepEqual } from '../../../utils/utils'; + +// TODO: add the possibility to switch this value if needed for customization +const MERGE_EMPTY_CELLS = true; + +const GridColumnHeaderRow = styled('div', { + name: 'MuiDataGrid', + slot: 'ColumnHeaderRow', + overridesResolver: (props, styles) => styles.columnHeaderRow, +})(() => ({ + display: 'flex', +})); + +interface HeaderInfo { + groupId: GridColumnGroup['groupId'] | null; + groupParents: GridColumnGroup['groupId'][]; + width: number; + fields: string[]; + colIndex: number; + description?: string; +} interface UseGridColumnHeadersProps { innerRef?: React.Ref; minColumnIndex?: number; } +interface GetHeadersParams { + renderContext: GridRenderContext | null; + minFirstColumn?: number; + maxLastColumn?: number; +} + function isUIEvent(event: any): event is React.UIEvent { return !!event.target; } @@ -48,6 +82,8 @@ export const useGridColumnHeaders = (props: UseGridColumnHeadersProps) => { const cellTabIndexState = useGridSelector(apiRef, gridTabIndexCellSelector); const columnHeaderFocus = useGridSelector(apiRef, gridFocusColumnHeaderSelector); const headerHeight = useGridSelector(apiRef, gridDensityHeaderHeightSelector); + const headerGroupingMaxDepth = useGridSelector(apiRef, gridDensityHeaderGroupingMaxDepthSelector); + const totalHeaderHeight = useGridSelector(apiRef, gridDensityTotalHeaderHeightSelector); const filterColumnLookup = useGridSelector(apiRef, gridFilterActiveItemsLookupSelector); const sortColumnLookup = useGridSelector(apiRef, gridSortColumnLookupSelector); const columnMenuState = useGridSelector(apiRef, gridColumnMenuSelector); @@ -187,14 +223,8 @@ export const useGridColumnHeaders = (props: UseGridColumnHeadersProps) => { useGridApiEventHandler(apiRef, 'rowsScroll', handleScroll); - const getColumns = ( - params?: { - renderContext: GridRenderContext | null; - minFirstColumn?: number; - maxLastColumn?: number; - }, - other = {}, - ) => { + // Helper for computation common between getColumnHeaders and getColumnGroupHeaders + const getColumnsToRender = (params?: GetHeadersParams) => { const { renderContext: nextRenderContext = renderContext, minFirstColumn = minColumnIndex, @@ -205,8 +235,6 @@ export const useGridColumnHeaders = (props: UseGridColumnHeadersProps) => { return null; } - const columns: JSX.Element[] = []; - const [firstRowToRender, lastRowToRender] = getRenderableIndexes({ firstIndex: nextRenderContext.firstRowIndex, lastIndex: nextRenderContext.lastRowIndex, @@ -232,6 +260,25 @@ export const useGridColumnHeaders = (props: UseGridColumnHeadersProps) => { const renderedColumns = visibleColumns.slice(firstColumnToRender, lastColumnToRender); + return { + renderedColumns, + firstColumnToRender, + lastColumnToRender, + minFirstColumn, + maxLastColumn, + }; + }; + + const getColumnHeaders = (params?: GetHeadersParams, other = {}) => { + const columnsToRender = getColumnsToRender(params); + + if (columnsToRender == null) { + return null; + } + + const { renderedColumns, firstColumnToRender } = columnsToRender; + + const columns: JSX.Element[] = []; for (let i = 0; i < renderedColumns.length; i += 1) { const column = renderedColumns[i]; @@ -268,20 +315,203 @@ export const useGridColumnHeaders = (props: UseGridColumnHeadersProps) => { ); } + return ( + + {columns} + + ); + }; + + const getParents = (path: string[] = [], depth: number) => path.slice(0, depth + 1); + + const getColumnGroupHeaders = (params?: GetHeadersParams) => { + if (headerGroupingMaxDepth === 0) { + return null; + } + const columnsToRender = getColumnsToRender(params); + + if (columnsToRender == null) { + return null; + } + + const { renderedColumns, firstColumnToRender, lastColumnToRender, maxLastColumn } = + columnsToRender; + + const columns: JSX.Element[] = []; + + const headerToRender: { + leftOverflow: number; + elements: HeaderInfo[]; + }[] = []; + + for (let depth = 0; depth < headerGroupingMaxDepth; depth += 1) { + // Initialize the header line with a grouping item containing all the columns on the left of the virtualization which are in the same group as the first group to render + const initialHeader: HeaderInfo[] = []; + let leftOverflow = 0; + + let columnIndex = firstColumnToRender - 1; + const firstColumnToRenderGroup = visibleColumns[firstColumnToRender]?.groupPath?.[depth]!; + + // The array of parent is used to manage empty grouping cell + // When two empty grouping cell are next to each other, we merge them if the belong to the same group. + const firstColumnToRenderGroupParents = getParents( + visibleColumns[firstColumnToRender]?.groupPath, + depth, + ); + while ( + firstColumnToRenderGroup !== null && + columnIndex >= minColumnIndex && + visibleColumns[columnIndex]?.groupPath && + isDeepEqual( + getParents(visibleColumns[columnIndex]?.groupPath, depth), + firstColumnToRenderGroupParents, + ) + ) { + const column = visibleColumns[columnIndex]; + + leftOverflow += column.computedWidth ?? 0; + + if (initialHeader.length === 0) { + initialHeader.push({ + width: column.computedWidth ?? 0, + fields: [column.field], + groupId: firstColumnToRenderGroup, + groupParents: firstColumnToRenderGroupParents, + colIndex: columnIndex, + }); + } else { + initialHeader[0].width += column.computedWidth ?? 0; + initialHeader[0].fields.push(column.field); + initialHeader[0].colIndex = columnIndex; + } + + columnIndex -= 1; + } + + const depthInfo = renderedColumns.reduce((aggregated, column, i) => { + const lastItem: HeaderInfo | undefined = aggregated[aggregated.length - 1]; + + if (column.groupPath && column.groupPath.length > depth) { + if (lastItem && lastItem.groupId === column.groupPath[depth]) { + // Merge with the previous columns + return [ + ...aggregated.slice(0, aggregated.length - 1), + { + ...lastItem, + width: lastItem.width + (column.computedWidth ?? 0), + fields: [...lastItem.fields, column.field], + }, + ]; + } + // Create a new grouping + return [ + ...aggregated, + { + groupId: column.groupPath[depth], + groupParents: getParents(column.groupPath, depth), + width: column.computedWidth ?? 0, + fields: [column.field], + colIndex: firstColumnToRender + i, + }, + ]; + } + + if ( + MERGE_EMPTY_CELLS && + lastItem && + lastItem.groupId === null && + isDeepEqual(getParents(column.groupPath, depth), lastItem.groupParents) + ) { + // We merge with previous column + return [ + ...aggregated.slice(0, aggregated.length - 1), + { + ...lastItem, + width: lastItem.width + (column.computedWidth ?? 0), + fields: [...lastItem.fields, column.field], + }, + ]; + } + // We create new empty cell + return [ + ...aggregated, + { + groupId: null, + groupParents: getParents(column.groupPath, depth), + width: column.computedWidth ?? 0, + fields: [column.field], + colIndex: firstColumnToRender + i, + }, + ]; + }, initialHeader); + + columnIndex = lastColumnToRender; + const lastColumnToRenderGroup = depthInfo[depthInfo.length - 1].groupId; + + while ( + lastColumnToRenderGroup !== null && + columnIndex < maxLastColumn && + visibleColumns[columnIndex]?.groupPath && + visibleColumns[columnIndex]?.groupPath?.[depth] === lastColumnToRenderGroup + ) { + const column = visibleColumns[columnIndex]; + + depthInfo[depthInfo.length - 1].width += column.computedWidth ?? 0; + depthInfo[depthInfo.length - 1].fields.push(column.field); + columnIndex += 1; + } + + headerToRender.push({ leftOverflow, elements: [...depthInfo] }); + } + + headerToRender.forEach((depthInfo, depthIndex) => { + columns.push( + + {depthInfo.elements.map(({ groupId, width, fields, colIndex }, groupIndex) => { + return ( + + ); + })} + , + ); + }); return columns; }; const rootStyle = { - minHeight: headerHeight, - maxHeight: headerHeight, + minHeight: totalHeaderHeight, + maxHeight: totalHeaderHeight, lineHeight: `${headerHeight}px`, }; return { renderContext, - getColumns, + getColumnHeaders, + getColumnGroupHeaders, isDragging: !!dragCol, getRootProps: (other = {}) => ({ style: rootStyle, ...other }), - getInnerProps: () => ({ ref: handleInnerRef, 'aria-rowindex': 1, role: 'row' }), + getInnerProps: () => ({ + ref: handleInnerRef, + role: 'rowgroup', + }), }; }; diff --git a/packages/grid/x-data-grid/src/hooks/features/density/densitySelector.ts b/packages/grid/x-data-grid/src/hooks/features/density/densitySelector.ts index 6e6cf2756939b..37e089b0aee22 100644 --- a/packages/grid/x-data-grid/src/hooks/features/density/densitySelector.ts +++ b/packages/grid/x-data-grid/src/hooks/features/density/densitySelector.ts @@ -18,7 +18,17 @@ export const gridDensityHeaderHeightSelector = createSelector( (density) => density.headerHeight, ); +export const gridDensityHeaderGroupingMaxDepthSelector = createSelector( + gridDensitySelector, + (density) => density.headerGroupingMaxDepth, +); + export const gridDensityFactorSelector = createSelector( gridDensitySelector, (density) => density.factor, ); + +export const gridDensityTotalHeaderHeightSelector = createSelector( + gridDensitySelector, + (density) => density.headerHeight * (1 + density.headerGroupingMaxDepth), +); diff --git a/packages/grid/x-data-grid/src/hooks/features/density/densityState.ts b/packages/grid/x-data-grid/src/hooks/features/density/densityState.ts index b924f6461377d..c79f750f471eb 100644 --- a/packages/grid/x-data-grid/src/hooks/features/density/densityState.ts +++ b/packages/grid/x-data-grid/src/hooks/features/density/densityState.ts @@ -4,5 +4,6 @@ export interface GridDensityState { value: GridDensity; rowHeight: number; headerHeight: number; + headerGroupingMaxDepth: number; factor: number; } diff --git a/packages/grid/x-data-grid/src/hooks/features/density/useGridDensity.tsx b/packages/grid/x-data-grid/src/hooks/features/density/useGridDensity.tsx index 6ea806b48e106..556d524acfb6c 100644 --- a/packages/grid/x-data-grid/src/hooks/features/density/useGridDensity.tsx +++ b/packages/grid/x-data-grid/src/hooks/features/density/useGridDensity.tsx @@ -9,6 +9,10 @@ import { DataGridProcessedProps } from '../../../models/props/DataGridProps'; import { gridDensitySelector } from './densitySelector'; import { isDeepEqual } from '../../../utils/utils'; import { GridStateInitializer } from '../../utils/useGridInitializeState'; +import { useGridSelector } from '../../utils/useGridSelector'; +import { gridVisibleColumnDefinitionsSelector } from '../columns'; +import { unwrapGroupingColumnModel } from '../columnGrouping/useGridColumnGrouping'; +import { GridStateCommunity } from '../../../models/gridStateCommunity'; export const COMPACT_DENSITY_FACTOR = 0.7; export const COMFORTABLE_DENSITY_FACTOR = 1.3; @@ -18,6 +22,7 @@ const getUpdatedDensityState = ( newDensity: GridDensity, newHeaderHeight: number, newRowHeight: number, + newMaxDepth: number, ): GridDensityState => { switch (newDensity) { case GridDensityTypes.Compact: @@ -25,6 +30,7 @@ const getUpdatedDensityState = ( value: newDensity, headerHeight: Math.floor(newHeaderHeight * COMPACT_DENSITY_FACTOR), rowHeight: Math.floor(newRowHeight * COMPACT_DENSITY_FACTOR), + headerGroupingMaxDepth: newMaxDepth, factor: COMPACT_DENSITY_FACTOR, }; case GridDensityTypes.Comfortable: @@ -32,6 +38,7 @@ const getUpdatedDensityState = ( value: newDensity, headerHeight: Math.floor(newHeaderHeight * COMFORTABLE_DENSITY_FACTOR), rowHeight: Math.floor(newRowHeight * COMFORTABLE_DENSITY_FACTOR), + headerGroupingMaxDepth: newMaxDepth, factor: COMFORTABLE_DENSITY_FACTOR, }; default: @@ -39,30 +46,71 @@ const getUpdatedDensityState = ( value: newDensity, headerHeight: newHeaderHeight, rowHeight: newRowHeight, + headerGroupingMaxDepth: newMaxDepth, factor: 1, }; } }; export const densityStateInitializer: GridStateInitializer< - Pick -> = (state, props) => ({ - ...state, - density: getUpdatedDensityState(props.density, props.headerHeight, props.rowHeight), -}); + Pick +> = (state, props) => { + // TODO: think about improving this initialization. Could it be done in the useColumn initializer? + // TODO: manage to remove ts-ignore + let maxDepth: number; + if (props.columnGroupingModel == null || Object.keys(props.columnGroupingModel).length === 0) { + maxDepth = 0; + } else { + const unwrappedGroupingColumnModel = unwrapGroupingColumnModel(props.columnGroupingModel); + + const columnsState = state.columns as GridStateCommunity['columns']; + const visibleColumns = columnsState.all.filter( + (field) => columnsState.columnVisibilityModel[field] !== false, + ); + + if (visibleColumns.length === 0) { + maxDepth = 0; + } else { + maxDepth = Math.max( + ...visibleColumns.map((field) => unwrappedGroupingColumnModel[field!]?.length ?? 0), + ); + } + } + return { + ...state, + density: getUpdatedDensityState(props.density, props.headerHeight, props.rowHeight, maxDepth), + }; +}; export const useGridDensity = ( apiRef: React.MutableRefObject, props: Pick, ): void => { + const visibleColumns = useGridSelector(apiRef, gridVisibleColumnDefinitionsSelector); + + const maxDepth = + visibleColumns.length > 0 + ? Math.max(...visibleColumns.map((column) => column.groupPath?.length ?? 0)) + : 0; + const logger = useGridLogger(apiRef, 'useDensity'); const setDensity = React.useCallback( - (newDensity, newHeaderHeight = props.headerHeight, newRowHeight = props.rowHeight): void => { + ( + newDensity, + newHeaderHeight = props.headerHeight, + newRowHeight = props.rowHeight, + newMaxDepth = maxDepth, + ): void => { logger.debug(`Set grid density to ${newDensity}`); apiRef.current.setState((state) => { const currentDensityState = gridDensitySelector(state); - const newDensityState = getUpdatedDensityState(newDensity, newHeaderHeight, newRowHeight); + const newDensityState = getUpdatedDensityState( + newDensity, + newHeaderHeight, + newRowHeight, + newMaxDepth, + ); if (isDeepEqual(currentDensityState, newDensityState)) { return state; @@ -75,12 +123,12 @@ export const useGridDensity = ( }); apiRef.current.forceUpdate(); }, - [logger, apiRef, props.headerHeight, props.rowHeight], + [logger, apiRef, props.headerHeight, props.rowHeight, maxDepth], ); React.useEffect(() => { - apiRef.current.setDensity(props.density, props.headerHeight, props.rowHeight); - }, [apiRef, props.density, props.rowHeight, props.headerHeight]); + apiRef.current.setDensity(props.density, props.headerHeight, props.rowHeight, maxDepth); + }, [apiRef, props.density, props.rowHeight, props.headerHeight, maxDepth]); const densityApi: GridDensityApi = { setDensity, 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 173b50c6c9a3b..0b2f13ae50488 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 @@ -16,7 +16,7 @@ import { useGridLogger } from '../../utils/useGridLogger'; import { DataGridProcessedProps } from '../../../models/props/DataGridProps'; import { GridDimensions, GridDimensionsApi } from './gridDimensionsApi'; import { gridColumnsTotalWidthSelector } from '../columns'; -import { gridDensityHeaderHeightSelector, gridDensityRowHeightSelector } from '../density'; +import { gridDensityTotalHeaderHeightSelector, gridDensityRowHeightSelector } from '../density'; import { useGridSelector } from '../../utils'; import { getVisibleRows } from '../../utils/useGridVisibleRows'; import { gridRowsMetaSelector } from '../rows/gridRowsMetaSelector'; @@ -63,7 +63,7 @@ export function useGridDimensions( const rootDimensionsRef = React.useRef(null); const fullDimensionsRef = React.useRef(null); const rowsMeta = useGridSelector(apiRef, gridRowsMetaSelector); - const headerHeight = useGridSelector(apiRef, gridDensityHeaderHeightSelector); + const totalHeaderHeight = useGridSelector(apiRef, gridDensityTotalHeaderHeightSelector); const updateGridDimensionsRef = React.useCallback(() => { const rootElement = apiRef.current.rootElementRef?.current; @@ -107,7 +107,7 @@ export function useGridDimensions( } else { viewportOuterSize = { width: rootDimensionsRef.current.width, - height: rootDimensionsRef.current.height - headerHeight, + height: rootDimensionsRef.current.height - totalHeaderHeight, }; const scrollInformation = hasScroll({ @@ -149,7 +149,7 @@ export function useGridDimensions( apiRef, props.scrollbarSize, props.autoHeight, - headerHeight, + totalHeaderHeight, rowsMeta.currentPageTotalHeight, ]); diff --git a/packages/grid/x-data-grid/src/hooks/features/export/useGridPrintExport.tsx b/packages/grid/x-data-grid/src/hooks/features/export/useGridPrintExport.tsx index 32032a57ce2a3..3ba78776ab55c 100644 --- a/packages/grid/x-data-grid/src/hooks/features/export/useGridPrintExport.tsx +++ b/packages/grid/x-data-grid/src/hooks/features/export/useGridPrintExport.tsx @@ -11,7 +11,7 @@ import { gridColumnDefinitionsSelector, gridColumnVisibilityModelSelector, } from '../columns/gridColumnsSelector'; -import { gridDensityHeaderHeightSelector } from '../density/densitySelector'; +import { gridDensityTotalHeaderHeightSelector } from '../density/densitySelector'; import { gridClasses } from '../../../constants/gridClasses'; import { useGridApiMethod } from '../../utils/useGridApiMethod'; import { gridRowsMetaSelector } from '../rows/gridRowsMetaSelector'; @@ -111,7 +111,7 @@ export const useGridPrintExport = ( return; } - const headerHeight = gridDensityHeaderHeightSelector(apiRef); + const totalHeaderHeight = gridDensityTotalHeaderHeightSelector(apiRef); const rowsMeta = gridRowsMetaSelector(apiRef.current.state); const gridRootElement = apiRef.current.rootElementRef!.current; @@ -153,7 +153,7 @@ export const useGridPrintExport = ( // Expand container height to accommodate all rows gridClone.style.height = `${ rowsMeta.currentPageTotalHeight + - headerHeight + + totalHeaderHeight + gridToolbarElementHeight + gridFooterElementHeight }px`; diff --git a/packages/grid/x-data-grid/src/hooks/features/index.ts b/packages/grid/x-data-grid/src/hooks/features/index.ts index 701c2018f4652..c98af58ad5f24 100644 --- a/packages/grid/x-data-grid/src/hooks/features/index.ts +++ b/packages/grid/x-data-grid/src/hooks/features/index.ts @@ -1,6 +1,7 @@ // Only export the variable and types that should be publicly exposed and re-exported from `@mui/x-data-grid-pro` export * from './columnMenu'; export * from './columns'; +export * from './columnGrouping'; export * from './density'; export * from './editRows'; export * from './filter'; diff --git a/packages/grid/x-data-grid/src/internals/index.ts b/packages/grid/x-data-grid/src/internals/index.ts index 7be746eb1a35e..40e0de4cf50ae 100644 --- a/packages/grid/x-data-grid/src/internals/index.ts +++ b/packages/grid/x-data-grid/src/internals/index.ts @@ -21,6 +21,11 @@ export { } from '../hooks/features/columnMenu/useGridColumnMenu'; export { useGridColumns, columnsStateInitializer } from '../hooks/features/columns/useGridColumns'; export { useGridColumnSpanning } from '../hooks/features/columns/useGridColumnSpanning'; +export { + useGridColumnGrouping, + columnGroupsStateInitializer, +} from '../hooks/features/columnGrouping/useGridColumnGrouping'; +export { useGridColumnGroupingPreProcessors } from '../hooks/features/columnGrouping/useGridColumnGroupingPreProcessors'; export type { GridColumnRawLookup, GridColumnsRawState, diff --git a/packages/grid/x-data-grid/src/models/api/gridApiCommon.ts b/packages/grid/x-data-grid/src/models/api/gridApiCommon.ts index 46d0ca6c19626..aa1cbe00fc919 100644 --- a/packages/grid/x-data-grid/src/models/api/gridApiCommon.ts +++ b/packages/grid/x-data-grid/src/models/api/gridApiCommon.ts @@ -26,6 +26,7 @@ import type { GridStrategyProcessingApi } from '../../hooks/core/strategyProcess import type { GridDimensionsApi } from '../../hooks/features/dimensions'; import type { GridPaginationApi } from '../../hooks/features/pagination'; import type { GridStatePersistenceApi } from '../../hooks/features/statePersistence'; +import { GridColumnGroupingApi } from './gridColumnGroupingApi'; type GridStateApiUntyped = { [key in keyof (GridStateApi & GridStatePersistenceApi)]: any; @@ -58,4 +59,5 @@ export interface GridApiCommon GridClipboardApi, GridScrollApi, GridColumnSpanningApi, - GridStateApiUntyped {} + GridStateApiUntyped, + GridColumnGroupingApi {} diff --git a/packages/grid/x-data-grid/src/models/api/gridColumnGroupingApi.ts b/packages/grid/x-data-grid/src/models/api/gridColumnGroupingApi.ts new file mode 100644 index 0000000000000..cb66d54d7b3e7 --- /dev/null +++ b/packages/grid/x-data-grid/src/models/api/gridColumnGroupingApi.ts @@ -0,0 +1,20 @@ +import { GridColumnGroupLookup } from '../../hooks/features/columnGrouping/gridColumnGroupsInterfaces'; +import { GridColumnGroup } from '../gridColumnGrouping'; + +/** + * The column grouping API interface that is available in the grid [[apiRef]]. + */ +export interface GridColumnGroupingApi { + /** + * Returns the id of the groups leading to the requested column. + * The array is ordered by increasing depth (the last element is the direct parent of the column). + * @param {string} field The field of of the column requested. + * @returns {string[]} The id of the groups leading to the requested column. + */ + unstable_getColumnGroupPath: (field: string) => GridColumnGroup['groupId'][]; + /** + * Returns the column group lookup. + * @returns {GridColumnGroupLookup} The column group lookup. + */ + unstable_getAllGroupDetails: () => GridColumnGroupLookup; +} diff --git a/packages/grid/x-data-grid/src/models/api/gridDensityApi.ts b/packages/grid/x-data-grid/src/models/api/gridDensityApi.ts index a405c913b0837..2a02f78e8b51d 100644 --- a/packages/grid/x-data-grid/src/models/api/gridDensityApi.ts +++ b/packages/grid/x-data-grid/src/models/api/gridDensityApi.ts @@ -7,6 +7,7 @@ export interface GridDensityOption { value: GridDensityTypes; } +// TODO v6: turns `setDensity` parameters in an object /** * The density API interface that is available in the grid `apiRef`. */ @@ -16,6 +17,12 @@ export interface GridDensityApi { * @param {string} density Can be: `"compact"`, `"standard"`, `"comfortable"`. * @param {number} headerHeight The new header height. * @param {number} rowHeight The new row height. + * @param {number} maxDepth The depth of maximal depth column header grouping tree. */ - setDensity: (density: GridDensity, headerHeight?: number, rowHeight?: number) => void; + setDensity: ( + density: GridDensity, + headerHeight?: number, + rowHeight?: number, + maxDepth?: number, + ) => void; } diff --git a/packages/grid/x-data-grid/src/models/colDef/gridColDef.ts b/packages/grid/x-data-grid/src/models/colDef/gridColDef.ts index e1843540dab2b..d46d1f3798757 100644 --- a/packages/grid/x-data-grid/src/models/colDef/gridColDef.ts +++ b/packages/grid/x-data-grid/src/models/colDef/gridColDef.ts @@ -20,7 +20,7 @@ import { GridActionsCellItemProps } from '../../components/cell/GridActionsCellI import { GridEditCellProps } from '../gridEditRowModel'; import type { GridValidRowModel } from '../gridRows'; import { GridApiCommunity } from '../api/gridApiCommunity'; - +import type { GridColumnGroup } from '../gridColumnGrouping'; /** * Alignment used in position elements in Cells. */ @@ -284,6 +284,13 @@ export type GridStateColDef = * If `true`, it means that at least one of the dimension's property of this column has been modified since the last time the column prop has changed. */ hasBeenResized?: boolean; + /** + * The id of the groups leading to the column. + * The array is ordered by increasing depth (the last element is the direct parent of the column). + * If not defined, the column is in no group (equivalent to a path equal to `[]`). + * This parameter is computed from the `columnGroupingModel` prop. + */ + groupPath?: GridColumnGroup['groupId'][]; }; /** diff --git a/packages/grid/x-data-grid/src/models/gridColumnGrouping.ts b/packages/grid/x-data-grid/src/models/gridColumnGrouping.ts new file mode 100644 index 0000000000000..e0add69ee507e --- /dev/null +++ b/packages/grid/x-data-grid/src/models/gridColumnGrouping.ts @@ -0,0 +1,78 @@ +import { GridColDef } from './colDef'; + +export interface GridLeafColumn { + field: GridColDef['field']; +} + +export type GridColumnNode = GridColumnGroup | GridLeafColumn; + +export function isLeaf(node: GridColumnNode): node is GridLeafColumn { + return (node).field !== undefined; +} + +/** + * A function used to process headerClassName params. + */ +export type GridColumnGroupHeaderClassFn = (params: GridColumnGroupHeaderParams) => string; + +/** + * The union type representing the [[GridColDef]] column header class type. + */ +export type GridColumnGroupHeaderClassNamePropType = string | GridColumnGroupHeaderClassFn; + +export interface GridColumnGroupHeaderParams + extends Pick { + /** + * A unique string identifying the group. + */ + groupId: GridColumnGroup['groupId'] | null; + /** + * The number parent the group have. + */ + depth: number; + /** + * The maximal depth among visible columns. + */ + maxDepth: number; + /** + * The column fields included in the group (including nested ones). + */ + fields: string[]; + /** + * The column index (0 based). + */ + colIndex: number; + /** + * Indicate if the group is the last one for the given depth. + */ + isLastColumn: boolean; +} + +export interface GridColumnGroup + extends Pick { + /** + * A unique string identifying the group. + */ + groupId: string; + /** + * The groups and columns included in this group. + */ + children: GridColumnNode[]; + /** + * If `true`, allows reordering columns outside of the group. + * @default false + */ + freeReordering?: boolean; + /** + * Allows to render a component in the column group header cell. + * @param {GridColumnGroupHeaderParams} params Object containing parameters for the renderer. + * @returns {React.ReactNode} The element to be rendered. + */ + renderHeaderGroup?: (params: GridColumnGroupHeaderParams) => React.ReactNode; + /** + * Class name that will be added in the column group header cell. + */ + headerClassName?: GridColumnGroupHeaderClassNamePropType; +} + +export type GridColumnGroupingModel = GridColumnGroup[]; diff --git a/packages/grid/x-data-grid/src/models/gridStateCommunity.ts b/packages/grid/x-data-grid/src/models/gridStateCommunity.ts index 5880d31a6f3ea..d4bff06c21cd1 100644 --- a/packages/grid/x-data-grid/src/models/gridStateCommunity.ts +++ b/packages/grid/x-data-grid/src/models/gridStateCommunity.ts @@ -2,6 +2,7 @@ import type { GridColumnMenuState, GridColumnsInitialState, GridColumnsState, + GridColumnsGroupingState, GridDensityState, GridFilterInitialState, GridFilterState, @@ -28,6 +29,7 @@ export interface GridStateCommunity { editRows: GridEditRowsModel; pagination: GridPaginationState; columns: GridColumnsState; + columnGrouping: GridColumnsGroupingState; columnMenu: GridColumnMenuState; sorting: GridSortingState; focus: GridFocusState; diff --git a/packages/grid/x-data-grid/src/models/index.ts b/packages/grid/x-data-grid/src/models/index.ts index debf8deff6c64..49e72e4a84a12 100644 --- a/packages/grid/x-data-grid/src/models/index.ts +++ b/packages/grid/x-data-grid/src/models/index.ts @@ -22,6 +22,7 @@ export * from './logger'; export * from './muiEvent'; export * from './events'; export * from './gridSortModel'; +export * from './gridColumnGrouping'; // Do not export GridExportFormat and GridExportExtension which are override in pro package export type { diff --git a/packages/grid/x-data-grid/src/models/props/DataGridProps.ts b/packages/grid/x-data-grid/src/models/props/DataGridProps.ts index 7e14aed27a076..a8549cacb212c 100644 --- a/packages/grid/x-data-grid/src/models/props/DataGridProps.ts +++ b/packages/grid/x-data-grid/src/models/props/DataGridProps.ts @@ -30,6 +30,7 @@ import { GridInitialStateCommunity } from '../gridStateCommunity'; import { GridSlotsComponentsProps } from '../gridSlotsComponentsProps'; import { GridColumnVisibilityModel } from '../../hooks/features/columns/gridColumnsInterfaces'; import { GridCellModesModel, GridRowModesModel } from '../api/gridEditingApi'; +import { GridColumnGroupingModel } from '../gridColumnGrouping'; export interface GridExperimentalFeatures { /** @@ -40,6 +41,10 @@ export interface GridExperimentalFeatures { * Enables the new API for cell editing and row editing. */ newEditingApi: boolean; + /** + * Enables the column grouping. + */ + columnGrouping: boolean; /** * Emits a warning if the cell receives focus without also syncing the focus state. * Only works if NODE_ENV=test. @@ -784,4 +789,5 @@ export interface DataGridPropsWithoutDefaultValue void; + columnGroupingModel?: GridColumnGroupingModel; } diff --git a/packages/grid/x-data-grid/src/tests/columnsGrouping.DataGrid.test.tsx b/packages/grid/x-data-grid/src/tests/columnsGrouping.DataGrid.test.tsx new file mode 100644 index 0000000000000..9c6e896e9fb2a --- /dev/null +++ b/packages/grid/x-data-grid/src/tests/columnsGrouping.DataGrid.test.tsx @@ -0,0 +1,366 @@ +import * as React from 'react'; +import { expect } from 'chai'; +// @ts-ignore Remove once the test utils are typed +import { createRenderer, ErrorBoundary, screen } from '@mui/monorepo/test/utils'; +import { DataGrid, DataGridProps, GridRowModel, GridColumns } from '@mui/x-data-grid'; + +const isJSDOM = /jsdom/.test(window.navigator.userAgent); + +const getDefaultProps = (nbColumns: number) => { + const columns: GridColumns = []; + const row: GridRowModel = {}; + + for (let i = 1; i <= nbColumns; i += 1) { + columns.push({ field: `col${i}` }); + row[`col${i}`] = i; + } + + return { + disableVirtualization: true, + columns, + rows: [{ id: 0, ...row }], + autoHeight: isJSDOM, + experimentalFeatures: { columnGrouping: true }, + }; +}; + +type TestDataGridProps = Omit & + Partial> & { nbColumns: number }; + +describe(' - Column grouping', () => { + const { render } = createRenderer(); + + const TestDataGrid = (props: TestDataGridProps) => { + const { nbColumns, ...other } = props; + return ( +
+ +
+ ); + }; + + describe('Header grouping columns', () => { + it('should add one header row when columns have a group', () => { + render( + , + ); + expect(screen.queryAllByRole('row')).to.have.length(3); + }); + + it('should add header rows to match max depth oc column groups', () => { + render( + , + ); + expect(screen.queryAllByRole('row')).to.have.length(4); + }); + + it('should add correct aria-colspan, aria-colindex on headers', () => { + render( + , + ); + + const row1Headers = document.querySelectorAll( + '[aria-rowindex="1"] [role="columnheader"]', + ); + const row2Headers = document.querySelectorAll( + '[aria-rowindex="2"] [role="columnheader"]', + ); + + expect( + Array.from(row1Headers).map((header) => header.getAttribute('aria-colspan')), + ).to.deep.equal(['3']); + expect( + Array.from(row1Headers).map((header) => header.getAttribute('aria-colindex')), + ).to.deep.equal(['1']); + + expect( + Array.from(row2Headers).map((header) => header.getAttribute('aria-colspan')), + ).to.deep.equal(['2', '1']); + expect( + Array.from(row2Headers).map((header) => header.getAttribute('aria-colindex')), + ).to.deep.equal(['1', '3']); + }); + + it('should support non connexe groups', () => { + render( + , + ); + + const row1Headers = document.querySelectorAll( + '[aria-rowindex="1"] [role="columnheader"]', + ); + const row2Headers = document.querySelectorAll( + '[aria-rowindex="2"] [role="columnheader"]', + ); + + expect( + Array.from(row1Headers).map((header) => header.getAttribute('aria-colspan')), + ).to.deep.equal(['1', '1', '2']); + expect( + Array.from(row1Headers).map((header) => header.getAttribute('aria-colindex')), + ).to.deep.equal(['1', '2', '3']); + + expect( + Array.from(row2Headers).map((header) => header.getAttribute('aria-colspan')), + ).to.deep.equal(['1', '1', '1', '1']); + expect( + Array.from(row2Headers).map((header) => header.getAttribute('aria-colindex')), + ).to.deep.equal(['1', '2', '3', '4']); + }); + + it('should only consider visible columns non connexe groups', () => { + const { setProps } = render( + , + ); + + // 2 header groups, 1 header, 1 row + expect(screen.queryAllByRole('row')).to.have.length(4); + + // hide the column with 2 nested groups + setProps({ columnVisibilityModel: { col2: false } }); + expect(screen.queryAllByRole('row')).to.have.length(3); + + // hide the last column with a group + setProps({ columnVisibilityModel: { col1: false, col2: false } }); + expect(screen.queryAllByRole('row')).to.have.length(2); + }); + + it('should update headers when `columnGroupingModel` is modified', () => { + const { setProps } = render( + , + ); + + // 2 header groups, 1 header, 1 row + expect(screen.queryAllByRole('row')).to.have.length(4); + + // remove the top group + setProps({ columnGroupingModel: [{ groupId: 'col2', children: [{ field: 'col2' }] }] }); + expect(screen.queryAllByRole('row')).to.have.length(3); + }); + + it('should split empty group cell if they are children of different group', () => { + render( + , + ); + + const row2Headers = document.querySelectorAll( + '[aria-rowindex="2"] [role="columnheader"]', + ); + + expect( + Array.from(row2Headers).map((header) => header.getAttribute('aria-colspan')), + ).to.deep.equal(['1', '1', '1']); + expect( + Array.from(row2Headers).map((header) => header.getAttribute('aria-colindex')), + ).to.deep.equal(['1', '2', '3']); + }); + + it('should merge empty group cell if they are children of the group', () => { + render( + , + ); + + const row2Headers = document.querySelectorAll( + '[aria-rowindex="2"] [role="columnheader"]', + ); + + expect( + Array.from(row2Headers).map((header) => header.getAttribute('aria-colspan')), + ).to.deep.equal(['2', '1']); + expect( + Array.from(row2Headers).map((header) => header.getAttribute('aria-colindex')), + ).to.deep.equal(['1', '3']); + }); + }); + + // TODO: remove the skip. I failed to test if an error is thrown + // eslint-disable-next-line mocha/no-skipped-tests + describe.skip('error messages', () => { + const TestWithError = (props: TestDataGridProps) => ( + + + + ); + + it('should log an error if two groups have the same id', () => { + expect(() => { + render( + , + ); + }).toErrorDev(); + }); + + it('should log an error if a columns is referenced in two groups', () => { + expect(() => { + render( + , + ); + }).toErrorDev(); + }); + + it('should log an error if a group have no id', () => { + expect(() => { + try { + render( + , + ); + } catch (error) { + console.error(error); + } + }).toErrorDev( + 'MUI-DataGrid: an element of the columnGroupingModel does not have either `field` or `groupId`', + ); + }); + + it('should log a warning if a group has no children', () => { + expect(() => { + render( + , + ); + }).toWarnDev('MUI-DataGrid: group groupId=col12 has no children.'); + expect(() => { + render( + , + ); + }).toWarnDev('MUI-DataGrid: group groupId=col12 has no children.'); + }); + }); +}); diff --git a/scripts/x-data-grid-premium.exports.json b/scripts/x-data-grid-premium.exports.json index 5b1f0589fd148..e800c6440221a 100644 --- a/scripts/x-data-grid-premium.exports.json +++ b/scripts/x-data-grid-premium.exports.json @@ -126,6 +126,13 @@ { "name": "GridColumnApi", "kind": "Interface" }, { "name": "gridColumnDefinitionsSelector", "kind": "Variable" }, { "name": "gridColumnFieldsSelector", "kind": "Variable" }, + { "name": "GridColumnGroup", "kind": "Interface" }, + { "name": "GridColumnGroupHeaderClassFn", "kind": "TypeAlias" }, + { "name": "GridColumnGroupHeaderClassNamePropType", "kind": "TypeAlias" }, + { "name": "GridColumnGroupHeaderParams", "kind": "Interface" }, + { "name": "GridColumnGroupingModel", "kind": "TypeAlias" }, + { "name": "gridColumnGroupingSelector", "kind": "Variable" }, + { "name": "gridColumnGroupsLookupSelector", "kind": "Variable" }, { "name": "GridColumnHeaderClassFn", "kind": "TypeAlias" }, { "name": "GridColumnHeaderClassNamePropType", "kind": "TypeAlias" }, { "name": "GridColumnHeaderEventLookup", "kind": "Interface" }, @@ -152,6 +159,7 @@ { "name": "GridColumnMenuProps", "kind": "Interface" }, { "name": "gridColumnMenuSelector", "kind": "Variable" }, { "name": "GridColumnMenuState", "kind": "Interface" }, + { "name": "GridColumnNode", "kind": "TypeAlias" }, { "name": "GridColumnOrderChangeParams", "kind": "Interface" }, { "name": "GridColumnPinningApi", "kind": "Interface" }, { "name": "GridColumnPinningInternalCache", "kind": "Interface" }, @@ -165,6 +173,7 @@ { "name": "gridColumnResizeSelector", "kind": "Variable" }, { "name": "GridColumnResizeState", "kind": "Interface" }, { "name": "GridColumns", "kind": "TypeAlias" }, + { "name": "GridColumnsGroupingState", "kind": "Interface" }, { "name": "GridColumnsInitialState", "kind": "Interface" }, { "name": "GridColumnsMenuItem", "kind": "Variable" }, { "name": "GridColumnsMeta", "kind": "Interface" }, @@ -195,11 +204,13 @@ { "name": "GridDensity", "kind": "TypeAlias" }, { "name": "GridDensityApi", "kind": "Interface" }, { "name": "gridDensityFactorSelector", "kind": "Variable" }, + { "name": "gridDensityHeaderGroupingMaxDepthSelector", "kind": "Variable" }, { "name": "gridDensityHeaderHeightSelector", "kind": "Variable" }, { "name": "GridDensityOption", "kind": "Interface" }, { "name": "gridDensityRowHeightSelector", "kind": "Variable" }, { "name": "gridDensitySelector", "kind": "Variable" }, { "name": "GridDensityState", "kind": "Interface" }, + { "name": "gridDensityTotalHeaderHeightSelector", "kind": "Variable" }, { "name": "GridDensityTypes", "kind": "Enum" }, { "name": "gridDensityValueSelector", "kind": "Variable" }, { "name": "GridDetailPanelApi", "kind": "Interface" }, @@ -312,6 +323,7 @@ { "name": "GridInputSelectionModel", "kind": "TypeAlias" }, { "name": "GridKeyboardArrowRight", "kind": "Variable" }, { "name": "GridKeyValue", "kind": "TypeAlias" }, + { "name": "GridLeafColumn", "kind": "Interface" }, { "name": "GridLinkOperator", "kind": "Enum" }, { "name": "GridLoadIcon", "kind": "Variable" }, { "name": "GridLoadingOverlay", "kind": "Variable" }, @@ -515,6 +527,7 @@ { "name": "heIL", "kind": "Variable" }, { "name": "HideGridColMenuItem", "kind": "Variable" }, { "name": "huHU", "kind": "Variable" }, + { "name": "isLeaf", "kind": "Function" }, { "name": "itIT", "kind": "Variable" }, { "name": "jaJP", "kind": "Variable" }, { "name": "koKR", "kind": "Variable" }, diff --git a/scripts/x-data-grid-pro.exports.json b/scripts/x-data-grid-pro.exports.json index 87839fbdd6469..d995dfb8337f9 100644 --- a/scripts/x-data-grid-pro.exports.json +++ b/scripts/x-data-grid-pro.exports.json @@ -105,6 +105,13 @@ { "name": "GridColumnApi", "kind": "Interface" }, { "name": "gridColumnDefinitionsSelector", "kind": "Variable" }, { "name": "gridColumnFieldsSelector", "kind": "Variable" }, + { "name": "GridColumnGroup", "kind": "Interface" }, + { "name": "GridColumnGroupHeaderClassFn", "kind": "TypeAlias" }, + { "name": "GridColumnGroupHeaderClassNamePropType", "kind": "TypeAlias" }, + { "name": "GridColumnGroupHeaderParams", "kind": "Interface" }, + { "name": "GridColumnGroupingModel", "kind": "TypeAlias" }, + { "name": "gridColumnGroupingSelector", "kind": "Variable" }, + { "name": "gridColumnGroupsLookupSelector", "kind": "Variable" }, { "name": "GridColumnHeaderClassFn", "kind": "TypeAlias" }, { "name": "GridColumnHeaderClassNamePropType", "kind": "TypeAlias" }, { "name": "GridColumnHeaderEventLookup", "kind": "Interface" }, @@ -131,6 +138,7 @@ { "name": "GridColumnMenuProps", "kind": "Interface" }, { "name": "gridColumnMenuSelector", "kind": "Variable" }, { "name": "GridColumnMenuState", "kind": "Interface" }, + { "name": "GridColumnNode", "kind": "TypeAlias" }, { "name": "GridColumnOrderChangeParams", "kind": "Interface" }, { "name": "GridColumnPinningApi", "kind": "Interface" }, { "name": "GridColumnPinningInternalCache", "kind": "Interface" }, @@ -144,6 +152,7 @@ { "name": "gridColumnResizeSelector", "kind": "Variable" }, { "name": "GridColumnResizeState", "kind": "Interface" }, { "name": "GridColumns", "kind": "TypeAlias" }, + { "name": "GridColumnsGroupingState", "kind": "Interface" }, { "name": "GridColumnsInitialState", "kind": "Interface" }, { "name": "GridColumnsMenuItem", "kind": "Variable" }, { "name": "GridColumnsMeta", "kind": "Interface" }, @@ -174,11 +183,13 @@ { "name": "GridDensity", "kind": "TypeAlias" }, { "name": "GridDensityApi", "kind": "Interface" }, { "name": "gridDensityFactorSelector", "kind": "Variable" }, + { "name": "gridDensityHeaderGroupingMaxDepthSelector", "kind": "Variable" }, { "name": "gridDensityHeaderHeightSelector", "kind": "Variable" }, { "name": "GridDensityOption", "kind": "Interface" }, { "name": "gridDensityRowHeightSelector", "kind": "Variable" }, { "name": "gridDensitySelector", "kind": "Variable" }, { "name": "GridDensityState", "kind": "Interface" }, + { "name": "gridDensityTotalHeaderHeightSelector", "kind": "Variable" }, { "name": "GridDensityTypes", "kind": "Enum" }, { "name": "gridDensityValueSelector", "kind": "Variable" }, { "name": "GridDetailPanelApi", "kind": "Interface" }, @@ -285,6 +296,7 @@ { "name": "GridInputSelectionModel", "kind": "TypeAlias" }, { "name": "GridKeyboardArrowRight", "kind": "Variable" }, { "name": "GridKeyValue", "kind": "TypeAlias" }, + { "name": "GridLeafColumn", "kind": "Interface" }, { "name": "GridLinkOperator", "kind": "Enum" }, { "name": "GridLoadIcon", "kind": "Variable" }, { "name": "GridLoadingOverlay", "kind": "Variable" }, @@ -480,6 +492,7 @@ { "name": "heIL", "kind": "Variable" }, { "name": "HideGridColMenuItem", "kind": "Variable" }, { "name": "huHU", "kind": "Variable" }, + { "name": "isLeaf", "kind": "Function" }, { "name": "itIT", "kind": "Variable" }, { "name": "jaJP", "kind": "Variable" }, { "name": "koKR", "kind": "Variable" }, diff --git a/scripts/x-data-grid.exports.json b/scripts/x-data-grid.exports.json index 36fd3258ba005..494758be7b848 100644 --- a/scripts/x-data-grid.exports.json +++ b/scripts/x-data-grid.exports.json @@ -100,6 +100,13 @@ { "name": "GridColumnApi", "kind": "Interface" }, { "name": "gridColumnDefinitionsSelector", "kind": "Variable" }, { "name": "gridColumnFieldsSelector", "kind": "Variable" }, + { "name": "GridColumnGroup", "kind": "Interface" }, + { "name": "GridColumnGroupHeaderClassFn", "kind": "TypeAlias" }, + { "name": "GridColumnGroupHeaderClassNamePropType", "kind": "TypeAlias" }, + { "name": "GridColumnGroupHeaderParams", "kind": "Interface" }, + { "name": "GridColumnGroupingModel", "kind": "TypeAlias" }, + { "name": "gridColumnGroupingSelector", "kind": "Variable" }, + { "name": "gridColumnGroupsLookupSelector", "kind": "Variable" }, { "name": "GridColumnHeaderClassFn", "kind": "TypeAlias" }, { "name": "GridColumnHeaderClassNamePropType", "kind": "TypeAlias" }, { "name": "GridColumnHeaderEventLookup", "kind": "Interface" }, @@ -126,10 +133,12 @@ { "name": "GridColumnMenuProps", "kind": "Interface" }, { "name": "gridColumnMenuSelector", "kind": "Variable" }, { "name": "GridColumnMenuState", "kind": "Interface" }, + { "name": "GridColumnNode", "kind": "TypeAlias" }, { "name": "GridColumnOrderChangeParams", "kind": "Interface" }, { "name": "gridColumnPositionsSelector", "kind": "Variable" }, { "name": "GridColumnResizeParams", "kind": "Interface" }, { "name": "GridColumns", "kind": "TypeAlias" }, + { "name": "GridColumnsGroupingState", "kind": "Interface" }, { "name": "GridColumnsInitialState", "kind": "Interface" }, { "name": "GridColumnsMenuItem", "kind": "Variable" }, { "name": "GridColumnsMeta", "kind": "Interface" }, @@ -160,11 +169,13 @@ { "name": "GridDensity", "kind": "TypeAlias" }, { "name": "GridDensityApi", "kind": "Interface" }, { "name": "gridDensityFactorSelector", "kind": "Variable" }, + { "name": "gridDensityHeaderGroupingMaxDepthSelector", "kind": "Variable" }, { "name": "gridDensityHeaderHeightSelector", "kind": "Variable" }, { "name": "GridDensityOption", "kind": "Interface" }, { "name": "gridDensityRowHeightSelector", "kind": "Variable" }, { "name": "gridDensitySelector", "kind": "Variable" }, { "name": "GridDensityState", "kind": "Interface" }, + { "name": "gridDensityTotalHeaderHeightSelector", "kind": "Variable" }, { "name": "GridDensityTypes", "kind": "Enum" }, { "name": "gridDensityValueSelector", "kind": "Variable" }, { "name": "GridDimensions", "kind": "Interface" }, @@ -262,6 +273,7 @@ { "name": "GridInputSelectionModel", "kind": "TypeAlias" }, { "name": "GridKeyboardArrowRight", "kind": "Variable" }, { "name": "GridKeyValue", "kind": "TypeAlias" }, + { "name": "GridLeafColumn", "kind": "Interface" }, { "name": "GridLinkOperator", "kind": "Enum" }, { "name": "GridLoadIcon", "kind": "Variable" }, { "name": "GridLoadingOverlay", "kind": "Variable" }, @@ -447,6 +459,7 @@ { "name": "heIL", "kind": "Variable" }, { "name": "HideGridColMenuItem", "kind": "Variable" }, { "name": "huHU", "kind": "Variable" }, + { "name": "isLeaf", "kind": "Function" }, { "name": "itIT", "kind": "Variable" }, { "name": "jaJP", "kind": "Variable" }, { "name": "koKR", "kind": "Variable" }, diff --git a/test/utils/helperFn.ts b/test/utils/helperFn.ts index c24e9ab00d0bd..c5b06dea84f25 100644 --- a/test/utils/helperFn.ts +++ b/test/utils/helperFn.ts @@ -64,9 +64,12 @@ export function getColumnValues(colIndex: number) { ); } -export function getColumnHeaderCell(colIndex: number): HTMLElement { +export function getColumnHeaderCell(colIndex: number, rowIndex?: number): HTMLElement { + const headerRowSelector = + rowIndex === undefined ? '' : `[role="row"][aria-rowindex="${rowIndex + 1}"] `; + const headerCellSelector = `[role="columnheader"][aria-colindex="${colIndex + 1}"]`; const columnHeader = document.querySelector( - `[role="columnheader"][aria-colindex="${colIndex + 1}"]`, + `${headerRowSelector}${headerCellSelector}`, ); if (columnHeader == null) { throw new Error(`columnheader ${colIndex} not found`);