diff --git a/docs/data/data-grid/row-grouping/RowGroupingPropagateSelection.js b/docs/data/data-grid/row-grouping/RowGroupingPropagateSelection.js new file mode 100644 index 000000000000..5baa5839b6f8 --- /dev/null +++ b/docs/data/data-grid/row-grouping/RowGroupingPropagateSelection.js @@ -0,0 +1,72 @@ +import * as React from 'react'; +import { + DataGridPremium, + useGridApiRef, + useKeepGroupedColumnsHidden, +} from '@mui/x-data-grid-premium'; +import { useMovieData } from '@mui/x-data-grid-generator'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Checkbox from '@mui/material/Checkbox'; +import Stack from '@mui/material/Stack'; + +export default function RowGroupingPropagateSelection() { + const data = useMovieData(); + const apiRef = useGridApiRef(); + const [rowSelectionPropagation, setRowSelectionPropagation] = React.useState({ + parents: true, + descendants: true, + }); + + const initialState = useKeepGroupedColumnsHidden({ + apiRef, + initialState: { + rowGrouping: { + model: ['company', 'director'], + }, + }, + }); + + return ( +
+ + + setRowSelectionPropagation((prev) => ({ + ...prev, + descendants: event.target.checked, + })) + } + /> + } + label="Auto select descendants" + /> + + setRowSelectionPropagation((prev) => ({ + ...prev, + parents: event.target.checked, + })) + } + /> + } + label="Auto select parents" + /> + +
+ +
+
+ ); +} diff --git a/docs/data/data-grid/row-grouping/RowGroupingPropagateSelection.tsx b/docs/data/data-grid/row-grouping/RowGroupingPropagateSelection.tsx new file mode 100644 index 000000000000..9229e086666a --- /dev/null +++ b/docs/data/data-grid/row-grouping/RowGroupingPropagateSelection.tsx @@ -0,0 +1,74 @@ +import * as React from 'react'; +import { + DataGridPremium, + useGridApiRef, + useKeepGroupedColumnsHidden, + GridRowSelectionPropagation, +} from '@mui/x-data-grid-premium'; +import { useMovieData } from '@mui/x-data-grid-generator'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Checkbox from '@mui/material/Checkbox'; +import Stack from '@mui/material/Stack'; + +export default function RowGroupingPropagateSelection() { + const data = useMovieData(); + const apiRef = useGridApiRef(); + const [rowSelectionPropagation, setRowSelectionPropagation] = + React.useState({ + parents: true, + descendants: true, + }); + + const initialState = useKeepGroupedColumnsHidden({ + apiRef, + initialState: { + rowGrouping: { + model: ['company', 'director'], + }, + }, + }); + + return ( +
+ + + setRowSelectionPropagation((prev) => ({ + ...prev, + descendants: event.target.checked, + })) + } + /> + } + label="Auto select descendants" + /> + + setRowSelectionPropagation((prev) => ({ + ...prev, + parents: event.target.checked, + })) + } + /> + } + label="Auto select parents" + /> + +
+ +
+
+ ); +} diff --git a/docs/data/data-grid/row-grouping/row-grouping.md b/docs/data/data-grid/row-grouping/row-grouping.md index 8b387aa8d42b..7e234f5c3632 100644 --- a/docs/data/data-grid/row-grouping/row-grouping.md +++ b/docs/data/data-grid/row-grouping/row-grouping.md @@ -305,6 +305,50 @@ In the example below: If you are dynamically switching the `leafField` or `mainGroupingCriteria`, the sorting and filtering models will not be cleaned up automatically, and the sorting/filtering will not be re-applied. ::: +## Automatic parents and children selection + +By default, selecting a parent row does not select its children. +You can override this behavior by using the `rowSelectionPropagation` prop. + +Here's how it's structured: + +```ts +type GridRowSelectionPropagation = { + descendants?: boolean; // default: false + parents?: boolean; // default: false +}; +``` + +When `rowSelectionPropagation.descendants` is set to `true`. + +- Selecting a parent would auto-select all its filtered descendants. +- Deselecting a parent row would auto-deselect all its filtered descendants. + +When `rowSelectionPropagation.parents` is set to `true`. + +- Selecting all the filtered descendants of a parent would auto-select the parent. +- Deselecting a descendant of a selected parent would auto-deselect the parent. + +The example below demonstrates the usage of the `rowSelectionPropagation` prop. + +{{"demo": "RowGroupingPropagateSelection.js", "bg": "inline", "defaultCodeOpen": false}} + +:::info +The row selection propagation also affects the "Select all" checkbox like any other group checkbox. +::: + +:::info +The selected rows that do not pass the filtering criteria are automatically deselected when the filter is applied. Row selection propagation is not applied to the unfiltered rows. +::: + +:::warning +If `props.disableMultipleRowSelection` is set to `true`, the row selection propagation doesn't apply. +::: + +:::warning +Row selection propagation is a client-side feature and is not supported with the [server-side data source](/x/react-data-grid/server-side-data/). +::: + ## Get the rows in a group You can use the `apiRef.current.getRowGroupChildren` method to get the id of all rows contained in a group. diff --git a/docs/data/data-grid/tree-data/tree-data.md b/docs/data/data-grid/tree-data/tree-data.md index 3af238f688a3..b6c67e6528cb 100644 --- a/docs/data/data-grid/tree-data/tree-data.md +++ b/docs/data/data-grid/tree-data/tree-data.md @@ -87,6 +87,10 @@ If you want to access the grouping column field, for instance, to use it with co Same behavior as for the [Row grouping](/x/react-data-grid/row-grouping/#group-expansion). +## Automatic parents and children selection + +Same behavior as for the [Row grouping](/x/react-data-grid/row-grouping/#automatic-parents-and-children-selection). + ## Gaps in the tree If some entries are missing to build the full tree, the `DataGridPro` will automatically create rows to fill those gaps. 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 5e824d9e015d..832c3d4eec29 100644 --- a/docs/pages/x/api/data-grid/data-grid-premium.json +++ b/docs/pages/x/api/data-grid/data-grid-premium.json @@ -594,6 +594,10 @@ "description": "Array<number
| string>
| number
| string" } }, + "rowSelectionPropagation": { + "type": { "name": "shape", "description": "{ descendants?: bool, parents?: bool }" }, + "default": "{ parents: false, descendants: false }" + }, "rowsLoadingMode": { "type": { "name": "enum", "description": "'client'
| 'server'" } }, 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 fa8a22c24d5e..ee04a078942e 100644 --- a/docs/pages/x/api/data-grid/data-grid-pro.json +++ b/docs/pages/x/api/data-grid/data-grid-pro.json @@ -529,6 +529,10 @@ "description": "Array<number
| string>
| number
| string" } }, + "rowSelectionPropagation": { + "type": { "name": "shape", "description": "{ descendants?: bool, parents?: bool }" }, + "default": "{ parents: false, descendants: false }" + }, "rowsLoadingMode": { "type": { "name": "enum", "description": "'client'
| 'server'" } }, diff --git a/docs/translations/api-docs/data-grid/data-grid-premium/data-grid-premium.json b/docs/translations/api-docs/data-grid/data-grid-premium/data-grid-premium.json index 6ad9d6baa7aa..a5f876df50d1 100644 --- a/docs/translations/api-docs/data-grid/data-grid-premium/data-grid-premium.json +++ b/docs/translations/api-docs/data-grid/data-grid-premium/data-grid-premium.json @@ -187,7 +187,7 @@ "groupingColDef": { "description": "The grouping column used by the tree data." }, "headerFilterHeight": { "description": "Override the height of the header filters." }, "headerFilters": { - "description": "If true, enables the data grid filtering on header feature." + "description": "If true, the header filters feature is enabled." }, "hideFooter": { "description": "If true, the footer component is hidden." }, "hideFooterPagination": { @@ -610,6 +610,9 @@ "rows": { "description": "Set of rows of type GridRowsProp." }, "rowSelection": { "description": "If false, the row selection mode is disabled." }, "rowSelectionModel": { "description": "Sets the row selection model of the Data Grid." }, + "rowSelectionPropagation": { + "description": "When rowSelectionPropagation.descendants is set to true. - Selecting a parent will auto-select all its filtered descendants. - Deselecting a parent will auto-deselect all its filtered descendants.
When rowSelectionPropagation.parents=true - Selecting all descendants of a parent would auto-select it. - Deselecting a descendant of a selected parent would deselect the parent.
Works with tree data and row grouping on the client-side only." + }, "rowsLoadingMode": { "description": "Loading rows can be processed on the server or client-side. Set it to 'client' if you would like enable infnite loading. Set it to 'server' if you would like to enable lazy loading. * @default "client"" }, diff --git a/docs/translations/api-docs/data-grid/data-grid-pro/data-grid-pro.json b/docs/translations/api-docs/data-grid/data-grid-pro/data-grid-pro.json index dd8849adea65..3e24702b10cd 100644 --- a/docs/translations/api-docs/data-grid/data-grid-pro/data-grid-pro.json +++ b/docs/translations/api-docs/data-grid/data-grid-pro/data-grid-pro.json @@ -168,7 +168,7 @@ "groupingColDef": { "description": "The grouping column used by the tree data." }, "headerFilterHeight": { "description": "Override the height of the header filters." }, "headerFilters": { - "description": "If true, enables the data grid filtering on header feature." + "description": "If true, the header filters feature is enabled." }, "hideFooter": { "description": "If true, the footer component is hidden." }, "hideFooterPagination": { @@ -552,6 +552,9 @@ "rows": { "description": "Set of rows of type GridRowsProp." }, "rowSelection": { "description": "If false, the row selection mode is disabled." }, "rowSelectionModel": { "description": "Sets the row selection model of the Data Grid." }, + "rowSelectionPropagation": { + "description": "When rowSelectionPropagation.descendants is set to true. - Selecting a parent will auto-select all its filtered descendants. - Deselecting a parent will auto-deselect all its filtered descendants.
When rowSelectionPropagation.parents=true - Selecting all descendants of a parent would auto-select it. - Deselecting a descendant of a selected parent would deselect the parent.
Works with tree data and row grouping on the client-side only." + }, "rowsLoadingMode": { "description": "Loading rows can be processed on the server or client-side. Set it to 'client' if you would like enable infnite loading. Set it to 'server' if you would like to enable lazy loading. * @default "client"" }, diff --git a/packages/x-data-grid-premium/src/DataGridPremium/DataGridPremium.tsx b/packages/x-data-grid-premium/src/DataGridPremium/DataGridPremium.tsx index 46e1fcd78fbb..4df3406d73a3 100644 --- a/packages/x-data-grid-premium/src/DataGridPremium/DataGridPremium.tsx +++ b/packages/x-data-grid-premium/src/DataGridPremium/DataGridPremium.tsx @@ -439,7 +439,7 @@ DataGridPremiumRaw.propTypes = { */ headerFilterHeight: PropTypes.number, /** - * If `true`, enables the data grid filtering on header feature. + * If `true`, the header filters feature is enabled. * @default false */ headerFilters: PropTypes.bool, @@ -985,6 +985,22 @@ DataGridPremiumRaw.propTypes = { PropTypes.number, PropTypes.string, ]), + /** + * When `rowSelectionPropagation.descendants` is set to `true`. + * - Selecting a parent will auto-select all its filtered descendants. + * - Deselecting a parent will auto-deselect all its filtered descendants. + * + * When `rowSelectionPropagation.parents=true` + * - Selecting all descendants of a parent would auto-select it. + * - Deselecting a descendant of a selected parent would deselect the parent. + * + * Works with tree data and row grouping on the client-side only. + * @default { parents: false, descendants: false } + */ + rowSelectionPropagation: PropTypes.shape({ + descendants: PropTypes.bool, + parents: PropTypes.bool, + }), /** * Loading rows can be processed on the server or client-side. * Set it to 'client' if you would like enable infnite loading. diff --git a/packages/x-data-grid-premium/src/tests/rowSelection.DataGridPremium.test.tsx b/packages/x-data-grid-premium/src/tests/rowSelection.DataGridPremium.test.tsx new file mode 100644 index 000000000000..d330b84ebd8c --- /dev/null +++ b/packages/x-data-grid-premium/src/tests/rowSelection.DataGridPremium.test.tsx @@ -0,0 +1,164 @@ +import * as React from 'react'; +import { createRenderer, fireEvent } from '@mui/internal-test-utils'; +import { getCell } from 'test/utils/helperFn'; +import { expect } from 'chai'; +import { + DataGridPremium, + DataGridPremiumProps, + GridApi, + GridRowsProp, + useGridApiRef, +} from '@mui/x-data-grid-premium'; + +const isJSDOM = /jsdom/.test(window.navigator.userAgent); + +interface BaselineProps extends DataGridPremiumProps { + rows: GridRowsProp; +} + +const rows: GridRowsProp = [ + { id: 0, category1: 'Cat A', category2: 'Cat 1' }, + { id: 1, category1: 'Cat A', category2: 'Cat 2' }, + { id: 2, category1: 'Cat A', category2: 'Cat 2' }, + { id: 3, category1: 'Cat B', category2: 'Cat 2' }, + { id: 4, category1: 'Cat B', category2: 'Cat 1' }, +]; + +const baselineProps: BaselineProps = { + autoHeight: isJSDOM, + disableVirtualization: true, + rows, + columns: [ + { + field: 'id', + type: 'number', + }, + { + field: 'category1', + }, + { + field: 'category2', + }, + ], +}; + +describe(' - Row selection', () => { + const { render } = createRenderer(); + + describe('props: rowSelectionPropagation = { descendants: true, parents: true }', () => { + let apiRef: React.MutableRefObject; + + function Test(props: Partial) { + apiRef = useGridApiRef(); + + return ( +
+ +
+ ); + } + + it('should select all the children when selecting a parent', () => { + render(); + + fireEvent.click(getCell(1, 0).querySelector('input')!); + expect(apiRef.current.getSelectedRows()).to.have.keys([ + 'auto-generated-row-category1/Cat B', + 3, + 4, + ]); + }); + + it('should deselect all the children when deselecting a parent', () => { + render(); + + fireEvent.click(getCell(1, 0).querySelector('input')!); + expect(apiRef.current.getSelectedRows()).to.have.keys([ + 'auto-generated-row-category1/Cat B', + 3, + 4, + ]); + fireEvent.click(getCell(1, 0).querySelector('input')!); + expect(apiRef.current.getSelectedRows().size).to.equal(0); + }); + + it('should auto select the parent if all the children are selected', () => { + render(); + + fireEvent.click(getCell(1, 0).querySelector('input')!); + fireEvent.click(getCell(2, 0).querySelector('input')!); + fireEvent.click(getCell(3, 0).querySelector('input')!); + expect(apiRef.current.getSelectedRows()).to.have.keys([ + 0, + 1, + 2, + 'auto-generated-row-category1/Cat A', + ]); + }); + + it('should deselect auto selected parent if one of the children is deselected', () => { + render(); + + fireEvent.click(getCell(1, 0).querySelector('input')!); + fireEvent.click(getCell(2, 0).querySelector('input')!); + fireEvent.click(getCell(3, 0).querySelector('input')!); + expect(apiRef.current.getSelectedRows()).to.have.keys([ + 0, + 1, + 2, + 'auto-generated-row-category1/Cat A', + ]); + fireEvent.click(getCell(2, 0).querySelector('input')!); + expect(apiRef.current.getSelectedRows()).to.have.keys([0, 2]); + }); + + describe("prop: indeterminateCheckboxAction = 'select'", () => { + it('should select all the children when selecting an indeterminate parent', () => { + render( + , + ); + + fireEvent.click(getCell(2, 0).querySelector('input')!); + expect(getCell(0, 0).querySelector('input')!).to.have.attr('data-indeterminate', 'true'); + fireEvent.click(getCell(0, 0).querySelector('input')!); + expect(apiRef.current.getSelectedRows()).to.have.keys([ + 0, + 1, + 2, + 'auto-generated-row-category1/Cat A', + ]); + }); + }); + + describe("prop: indeterminateCheckboxAction = 'deselect'", () => { + it('should deselect all the children when selecting an indeterminate parent', () => { + render( + , + ); + + fireEvent.click(getCell(2, 0).querySelector('input')!); + expect(getCell(0, 0).querySelector('input')!).to.have.attr('data-indeterminate', 'true'); + fireEvent.click(getCell(0, 0).querySelector('input')!); + expect(apiRef.current.getSelectedRows().size).to.equal(0); + }); + }); + }); +}); diff --git a/packages/x-data-grid-pro/src/DataGridPro/DataGridPro.tsx b/packages/x-data-grid-pro/src/DataGridPro/DataGridPro.tsx index 022003ef5d6d..cc4801eda0c9 100644 --- a/packages/x-data-grid-pro/src/DataGridPro/DataGridPro.tsx +++ b/packages/x-data-grid-pro/src/DataGridPro/DataGridPro.tsx @@ -394,7 +394,7 @@ DataGridProRaw.propTypes = { */ headerFilterHeight: PropTypes.number, /** - * If `true`, enables the data grid filtering on header feature. + * If `true`, the header filters feature is enabled. * @default false */ headerFilters: PropTypes.bool, @@ -891,6 +891,22 @@ DataGridProRaw.propTypes = { PropTypes.number, PropTypes.string, ]), + /** + * When `rowSelectionPropagation.descendants` is set to `true`. + * - Selecting a parent will auto-select all its filtered descendants. + * - Deselecting a parent will auto-deselect all its filtered descendants. + * + * When `rowSelectionPropagation.parents=true` + * - Selecting all descendants of a parent would auto-select it. + * - Deselecting a descendant of a selected parent would deselect the parent. + * + * Works with tree data and row grouping on the client-side only. + * @default { parents: false, descendants: false } + */ + rowSelectionPropagation: PropTypes.shape({ + descendants: PropTypes.bool, + parents: PropTypes.bool, + }), /** * Loading rows can be processed on the server or client-side. * Set it to 'client' if you would like enable infnite loading. diff --git a/packages/x-data-grid-pro/src/DataGridPro/useDataGridProProps.ts b/packages/x-data-grid-pro/src/DataGridPro/useDataGridProProps.ts index 0149f1a482f3..a54285727759 100644 --- a/packages/x-data-grid-pro/src/DataGridPro/useDataGridProProps.ts +++ b/packages/x-data-grid-pro/src/DataGridPro/useDataGridProProps.ts @@ -5,7 +5,11 @@ import { DATA_GRID_PROPS_DEFAULT_VALUES, GridValidRowModel, } from '@mui/x-data-grid'; -import { computeSlots, useProps } from '@mui/x-data-grid/internals'; +import { + computeSlots, + useProps, + ROW_SELECTION_PROPAGATION_DEFAULT, +} from '@mui/x-data-grid/internals'; import { DataGridProProps, DataGridProProcessedProps, @@ -46,6 +50,7 @@ export const DATA_GRID_PRO_PROPS_DEFAULT_VALUES: DataGridProPropsWithDefaultValu getDetailPanelHeight: () => 500, headerFilters: false, keepColumnPositionIfDraggedOutside: false, + rowSelectionPropagation: ROW_SELECTION_PROPAGATION_DEFAULT, rowReordering: false, rowsLoadingMode: 'client', scrollEndThreshold: 80, diff --git a/packages/x-data-grid-pro/src/tests/rowSelection.DataGridPro.test.tsx b/packages/x-data-grid-pro/src/tests/rowSelection.DataGridPro.test.tsx index f878b572a3c5..23994a6684d0 100644 --- a/packages/x-data-grid-pro/src/tests/rowSelection.DataGridPro.test.tsx +++ b/packages/x-data-grid-pro/src/tests/rowSelection.DataGridPro.test.tsx @@ -9,6 +9,8 @@ import { DataGridPro, DataGridProProps, GridRowSelectionModel, + GridRowsProp, + GridColDef, } from '@mui/x-data-grid-pro'; import { getBasicGridData } from '@mui/x-data-grid-generator'; @@ -44,6 +46,161 @@ describe(' - Row selection', () => { ); } + const rows: GridRowsProp = [ + { + hierarchy: ['Sarah'], + jobTitle: 'Head of Human Resources', + recruitmentDate: new Date(2020, 8, 12), + id: 0, + }, + { + hierarchy: ['Thomas'], + jobTitle: 'Head of Sales', + recruitmentDate: new Date(2017, 3, 4), + id: 1, + }, + { + hierarchy: ['Thomas', 'Robert'], + jobTitle: 'Sales Person', + recruitmentDate: new Date(2020, 11, 20), + id: 2, + }, + { + hierarchy: ['Thomas', 'Karen'], + jobTitle: 'Sales Person', + recruitmentDate: new Date(2020, 10, 14), + id: 3, + }, + { + hierarchy: ['Thomas', 'Nancy'], + jobTitle: 'Sales Person', + recruitmentDate: new Date(2017, 10, 29), + id: 4, + }, + { + hierarchy: ['Thomas', 'Daniel'], + jobTitle: 'Sales Person', + recruitmentDate: new Date(2020, 7, 21), + id: 5, + }, + { + hierarchy: ['Thomas', 'Christopher'], + jobTitle: 'Sales Person', + recruitmentDate: new Date(2020, 7, 20), + id: 6, + }, + { + hierarchy: ['Thomas', 'Donald'], + jobTitle: 'Sales Person', + recruitmentDate: new Date(2019, 6, 28), + id: 7, + }, + { + hierarchy: ['Mary'], + jobTitle: 'Head of Engineering', + recruitmentDate: new Date(2016, 3, 14), + id: 8, + }, + { + hierarchy: ['Mary', 'Jennifer'], + jobTitle: 'Tech lead front', + recruitmentDate: new Date(2016, 5, 17), + id: 9, + }, + { + hierarchy: ['Mary', 'Jennifer', 'Anna'], + jobTitle: 'Front-end developer', + recruitmentDate: new Date(2019, 11, 7), + id: 10, + }, + { + hierarchy: ['Mary', 'Michael'], + jobTitle: 'Tech lead devops', + recruitmentDate: new Date(2021, 7, 1), + id: 11, + }, + { + hierarchy: ['Mary', 'Linda'], + jobTitle: 'Tech lead back', + recruitmentDate: new Date(2017, 0, 12), + id: 12, + }, + { + hierarchy: ['Mary', 'Linda', 'Elizabeth'], + jobTitle: 'Back-end developer', + recruitmentDate: new Date(2019, 2, 22), + id: 13, + }, + { + hierarchy: ['Mary', 'Linda', 'William'], + jobTitle: 'Back-end developer', + recruitmentDate: new Date(2018, 4, 19), + id: 14, + }, + ]; + + const columns: GridColDef[] = [ + { field: 'jobTitle', headerName: 'Job Title', width: 200 }, + { + field: 'recruitmentDate', + headerName: 'Recruitment Date', + type: 'date', + width: 150, + }, + ]; + + const getTreeDataPath: DataGridProProps['getTreeDataPath'] = (row) => row.hierarchy; + + function TreeDataGrid(props: Partial) { + apiRef = useGridApiRef(); + return ( +
+ +
+ ); + } + + it('should keep the previously selected tree data parent selected if it becomes leaf after filtering', () => { + render(); + + fireEvent.click( + screen.getByRole('checkbox', { + name: /select all rows/i, + }), + ); + + expect(apiRef.current.getSelectedRows()).to.have.length(15); + + act(() => { + apiRef.current.setFilterModel({ + items: [ + { + field: 'jobTitle', + value: 'Head of Sales', + operator: 'equals', + }, + ], + }); + }); + + expect(apiRef.current.getSelectedRows()).to.have.keys([1]); + }); + + it('should put the parent into indeterminate if some but not all the children are selected', () => { + render(); + + fireEvent.click(getCell(2, 0).querySelector('input')!); + expect(getCell(1, 0).querySelector('input')!).to.have.attr('data-indeterminate', 'true'); + }); + describe('prop: checkboxSelectionVisibleOnly = false', () => { it('should select all rows of all pages if no row is selected', () => { render( @@ -221,16 +378,14 @@ describe(' - Row selection', () => { const data = React.useMemo(() => getBasicGridData(rowLength, 2), [rowLength]); - const rows = data.rows.slice( - paginationModel.pageSize * paginationModel.page, - paginationModel.pageSize * (paginationModel.page + 1), - ); - return (
- Row selection', () => { }); }); + describe('prop: rowSelectionPropagation = { descendants: false, parents: false }', () => { + function SelectionPropagationGrid(props: Partial) { + return ( + + ); + } + + it('should select the parent only when selecting it', () => { + render(); + + fireEvent.click(getCell(1, 0).querySelector('input')!); + expect(apiRef.current.getSelectedRows()).to.have.keys([1]); + }); + + it('should deselect the parent only when deselecting it', () => { + render(); + + fireEvent.click(getCell(1, 0).querySelector('input')!); + fireEvent.click(getCell(2, 0).querySelector('input')!); + expect(apiRef.current.getSelectedRows()).to.have.keys([1, 2]); + fireEvent.click(getCell(1, 0).querySelector('input')!); + expect(apiRef.current.getSelectedRows()).to.have.keys([2]); + }); + + it('should not auto select the parent if all the children are selected', () => { + render(); + + fireEvent.click(getCell(2, 0).querySelector('input')!); + fireEvent.click(getCell(3, 0).querySelector('input')!); + fireEvent.click(getCell(4, 0).querySelector('input')!); + fireEvent.click(getCell(5, 0).querySelector('input')!); + fireEvent.click(getCell(6, 0).querySelector('input')!); + fireEvent.click(getCell(7, 0).querySelector('input')!); + // The parent row (Thomas, id: 1) should not be among the selected rows + expect(apiRef.current.getSelectedRows()).to.have.keys([2, 3, 4, 5, 6, 7]); + }); + + it('should not deselect selected parent if one of the children is deselected', () => { + render(); + + fireEvent.click(getCell(1, 0).querySelector('input')!); + fireEvent.click(getCell(2, 0).querySelector('input')!); + fireEvent.click(getCell(3, 0).querySelector('input')!); + fireEvent.click(getCell(4, 0).querySelector('input')!); + fireEvent.click(getCell(5, 0).querySelector('input')!); + fireEvent.click(getCell(6, 0).querySelector('input')!); + fireEvent.click(getCell(7, 0).querySelector('input')!); + expect(apiRef.current.getSelectedRows()).to.have.keys([1, 2, 3, 4, 5, 6, 7]); + fireEvent.click(getCell(2, 0).querySelector('input')!); + // The parent row (Thomas, id: 1) should still be among the selected rows + expect(apiRef.current.getSelectedRows()).to.have.keys([1, 3, 4, 5, 6, 7]); + }); + + it('should select only the unwrapped rows when clicking "Select All" checkbox', () => { + render(); + + fireEvent.click(screen.getByRole('checkbox', { name: /select all rows/i })); + expect(apiRef.current.getSelectedRows()).to.have.keys([0, 1, 8]); + }); + + it('should deselect only the unwrapped rows when clicking "Select All" checkbox', () => { + render(); + + fireEvent.click(screen.getByRole('checkbox', { name: /select all rows/i })); + expect(apiRef.current.getSelectedRows()).to.have.keys([0, 1, 8]); + fireEvent.click(screen.getByRole('checkbox', { name: /select all rows/i })); + expect(apiRef.current.getSelectedRows().size).to.equal(0); + }); + }); + + describe('prop: rowSelectionPropagation = { descendants: true, parents: false }', () => { + function SelectionPropagationGrid(props: Partial) { + return ( + + ); + } + + it('should select all the children when selecting a parent', () => { + render(); + + fireEvent.click(getCell(1, 0).querySelector('input')!); + expect(apiRef.current.getSelectedRows()).to.have.keys([1, 2, 3, 4, 5, 6, 7]); + }); + + it('should deselect all the children when deselecting a parent', () => { + render(); + + fireEvent.click(getCell(1, 0).querySelector('input')!); + expect(apiRef.current.getSelectedRows()).to.have.keys([1, 2, 3, 4, 5, 6, 7]); + fireEvent.click(getCell(1, 0).querySelector('input')!); + expect(apiRef.current.getSelectedRows().size).to.equal(0); + }); + + it('should not auto select the parent if all the children are selected', () => { + render(); + + fireEvent.click(getCell(2, 0).querySelector('input')!); + fireEvent.click(getCell(3, 0).querySelector('input')!); + fireEvent.click(getCell(4, 0).querySelector('input')!); + fireEvent.click(getCell(5, 0).querySelector('input')!); + fireEvent.click(getCell(6, 0).querySelector('input')!); + fireEvent.click(getCell(7, 0).querySelector('input')!); + // The parent row (Thomas, id: 1) should not be among the selected rows + expect(apiRef.current.getSelectedRows()).to.have.keys([2, 3, 4, 5, 6, 7]); + }); + + it('should not deselect selected parent if one of the children is deselected', () => { + render(); + + fireEvent.click(getCell(1, 0).querySelector('input')!); + expect(apiRef.current.getSelectedRows()).to.have.keys([1, 2, 3, 4, 5, 6, 7]); + fireEvent.click(getCell(2, 0).querySelector('input')!); + // The parent row (Thomas, id: 1) should still be among the selected rows + expect(apiRef.current.getSelectedRows()).to.have.keys([1, 3, 4, 5, 6, 7]); + }); + + it('should select all the nested rows when clicking "Select All" checkbox', () => { + render(); + + fireEvent.click(screen.getByRole('checkbox', { name: /select all rows/i })); + expect(apiRef.current.getSelectedRows().size).to.equal(15); + }); + + it('should deselect all the nested rows when clicking "Select All" checkbox', () => { + render(); + + fireEvent.click(screen.getByRole('checkbox', { name: /select all rows/i })); + expect(apiRef.current.getSelectedRows().size).to.equal(15); + fireEvent.click(screen.getByRole('checkbox', { name: /select all rows/i })); + expect(apiRef.current.getSelectedRows().size).to.equal(0); + }); + + describe('prop: isRowSelectable', () => { + it("should not select a parent or it's descendants if not allowed", () => { + render( + params.id !== 1} + />, + ); + + fireEvent.click(getCell(1, 0).querySelector('input')!); + expect(apiRef.current.getSelectedRows().size).to.equal(0); + }); + + it('should not auto-select a descendant if not allowed', () => { + render( + params.id !== 2} + />, + ); + + fireEvent.click(getCell(1, 0).querySelector('input')!); + expect(apiRef.current.getSelectedRows()).to.have.keys([1, 3, 4, 5, 6, 7]); + }); + }); + }); + + describe('prop: rowSelectionPropagation = { descendants: false, parents: true }', () => { + function SelectionPropagationGrid(props: Partial) { + return ( + + ); + } + + it('should select the parent only when selecting it', () => { + render(); + + fireEvent.click(getCell(1, 0).querySelector('input')!); + expect(apiRef.current.getSelectedRows()).to.have.keys([1]); + }); + + it('should deselect the parent only when deselecting it', () => { + render(); + + fireEvent.click(getCell(1, 0).querySelector('input')!); + fireEvent.click(getCell(2, 0).querySelector('input')!); + expect(apiRef.current.getSelectedRows()).to.have.keys([1, 2]); + fireEvent.click(getCell(1, 0).querySelector('input')!); + expect(apiRef.current.getSelectedRows()).to.have.keys([2]); + }); + + it('should auto select the parent if all the children are selected', () => { + render(); + + fireEvent.click(getCell(2, 0).querySelector('input')!); + fireEvent.click(getCell(3, 0).querySelector('input')!); + fireEvent.click(getCell(4, 0).querySelector('input')!); + fireEvent.click(getCell(5, 0).querySelector('input')!); + fireEvent.click(getCell(6, 0).querySelector('input')!); + fireEvent.click(getCell(7, 0).querySelector('input')!); + // The parent row (Thomas, id: 1) should be among the selected rows + expect(apiRef.current.getSelectedRows()).to.have.keys([2, 3, 4, 5, 6, 7, 1]); + }); + + it('should deselect selected parent if one of the children is deselected', () => { + render(); + + fireEvent.click(getCell(2, 0).querySelector('input')!); + fireEvent.click(getCell(3, 0).querySelector('input')!); + fireEvent.click(getCell(4, 0).querySelector('input')!); + fireEvent.click(getCell(5, 0).querySelector('input')!); + fireEvent.click(getCell(6, 0).querySelector('input')!); + fireEvent.click(getCell(7, 0).querySelector('input')!); + expect(apiRef.current.getSelectedRows()).to.have.keys([2, 3, 4, 5, 6, 7, 1]); + fireEvent.click(getCell(2, 0).querySelector('input')!); + // The parent row (Thomas, id: 1) should not be among the selected rows + expect(apiRef.current.getSelectedRows()).to.have.keys([3, 4, 5, 6, 7]); + }); + + describe('prop: isRowSelectable', () => { + it('should not auto select a parent if not allowed', () => { + render( + params.id !== 1} + />, + ); + + fireEvent.click(getCell(2, 0).querySelector('input')!); + fireEvent.click(getCell(3, 0).querySelector('input')!); + fireEvent.click(getCell(4, 0).querySelector('input')!); + fireEvent.click(getCell(5, 0).querySelector('input')!); + fireEvent.click(getCell(6, 0).querySelector('input')!); + fireEvent.click(getCell(7, 0).querySelector('input')!); + // The parent row (Thomas, id: 1) should still not be among the selected rows + expect(apiRef.current.getSelectedRows()).to.have.keys([2, 3, 4, 5, 6, 7]); + }); + }); + }); + + describe('prop: rowSelectionPropagation = { descendants: true, parents: true }', () => { + function SelectionPropagationGrid(props: Partial) { + return ( + + ); + } + + it('should select all the children when selecting a parent', () => { + render(); + + fireEvent.click(getCell(1, 0).querySelector('input')!); + expect(apiRef.current.getSelectedRows()).to.have.keys([1, 2, 3, 4, 5, 6, 7]); + }); + + it('should deselect all the children when deselecting a parent', () => { + render(); + + fireEvent.click(getCell(1, 0).querySelector('input')!); + expect(apiRef.current.getSelectedRows()).to.have.keys([1, 2, 3, 4, 5, 6, 7]); + fireEvent.click(getCell(1, 0).querySelector('input')!); + expect(apiRef.current.getSelectedRows().size).to.equal(0); + }); + + it('should auto select the parent if all the children are selected', () => { + render(); + + fireEvent.click(getCell(9, 0).querySelector('input')!); + fireEvent.click(getCell(11, 0).querySelector('input')!); + fireEvent.click(getCell(12, 0).querySelector('input')!); + + // The parent row (Mary, id: 8) should be among the selected rows + expect(apiRef.current.getSelectedRows()).to.have.keys([9, 10, 11, 12, 8, 13, 14]); + }); + + it('should deselect auto selected parent if one of the children is deselected', () => { + render(); + + fireEvent.click(getCell(9, 0).querySelector('input')!); + fireEvent.click(getCell(11, 0).querySelector('input')!); + fireEvent.click(getCell(12, 0).querySelector('input')!); + expect(apiRef.current.getSelectedRows()).to.have.keys([9, 10, 11, 12, 8, 13, 14]); + fireEvent.click(getCell(9, 0).querySelector('input')!); + expect(apiRef.current.getSelectedRows()).to.have.keys([11, 12, 13, 14]); + }); + + describe("prop: indeterminateCheckboxAction = 'select'", () => { + it('should select all the children when selecting an indeterminate parent', () => { + render( + , + ); + + fireEvent.click(getCell(2, 0).querySelector('input')!); + expect(getCell(1, 0).querySelector('input')!).to.have.attr('data-indeterminate', 'true'); + fireEvent.click(getCell(1, 0).querySelector('input')!); + expect(apiRef.current.getSelectedRows()).to.have.keys([1, 2, 3, 4, 5, 6, 7]); + }); + }); + + describe("prop: indeterminateCheckboxAction = 'deselect'", () => { + it('should deselect all the children when selecting an indeterminate parent', () => { + render( + , + ); + + fireEvent.click(getCell(2, 0).querySelector('input')!); + expect(getCell(1, 0).querySelector('input')!).to.have.attr('data-indeterminate', 'true'); + fireEvent.click(getCell(1, 0).querySelector('input')!); + expect(apiRef.current.getSelectedRows().size).to.equal(0); + }); + }); + + describe('prop: keepNonExistentRowsSelected = true', () => { + it('should keep non-existent rows selected on filtering', () => { + render(); + + fireEvent.click(getCell(1, 0).querySelector('input')!); + expect(apiRef.current.getSelectedRows()).to.have.keys([1, 2, 3, 4, 5, 6, 7]); + + act(() => { + apiRef.current.setFilterModel({ + items: [ + { + field: 'jobTitle', + value: 'Head of Human Resources', + operator: 'equals', + }, + ], + }); + }); + + fireEvent.click(getCell(0, 0).querySelector('input')!); + + expect(apiRef.current.getSelectedRows()).to.have.keys([0, 1, 2, 3, 4, 5, 6, 7]); + }); + }); + }); + describe('apiRef: getSelectedRows', () => { it('should handle the event internally before triggering onRowSelectionModelChange', () => { render( diff --git a/packages/x-data-grid/src/components/columnSelection/GridCellCheckboxRenderer.tsx b/packages/x-data-grid/src/components/columnSelection/GridCellCheckboxRenderer.tsx index 377713f3bb3b..5bfadd9b96f7 100644 --- a/packages/x-data-grid/src/components/columnSelection/GridCellCheckboxRenderer.tsx +++ b/packages/x-data-grid/src/components/columnSelection/GridCellCheckboxRenderer.tsx @@ -4,12 +4,14 @@ import { unstable_composeClasses as composeClasses, unstable_useForkRef as useForkRef, } from '@mui/utils'; -import type { GridRenderCellParams } from '../../models/params/gridCellParams'; import { useGridApiContext } from '../../hooks/utils/useGridApiContext'; import { useGridRootProps } from '../../hooks/utils/useGridRootProps'; import { getDataGridUtilityClass } from '../../constants/gridClasses'; +import { useGridSelector } from '../../hooks/utils/useGridSelector'; +import { getCheckboxPropsSelector } from '../../hooks/features/rowSelection/utils'; import type { DataGridProcessedProps } from '../../models/props/DataGridProps'; import type { GridRowSelectionCheckboxParams } from '../../models/params/gridRowSelectionCheckboxParams'; +import type { GridRenderCellParams } from '../../models/params/gridCellParams'; type OwnerState = { classes: DataGridProcessedProps['classes'] }; @@ -32,7 +34,6 @@ const GridCellCheckboxForwardRef = React.forwardRef, ): void => { const logger = useGridLogger(apiRef, 'useGridSelection'); - const runIfRowSelectionIsEnabled = + const runIfRowSelectionIsEnabled = React.useCallback( (callback: (...args: Args) => void) => - (...args: Args) => { - if (props.rowSelection) { - callback(...args); - } - }; + (...args: Args) => { + if (props.rowSelection) { + callback(...args); + } + }, + [props.rowSelection], + ); + + const applyAutoSelection = + props.signature !== GridSignature.DataGrid && + (props.rowSelectionPropagation?.parents || props.rowSelectionPropagation?.descendants); const propRowSelectionModel = React.useMemo(() => { return getSelectionModelPropValue( @@ -120,6 +129,8 @@ export const useGridRowSelection = ( const canHaveMultipleSelection = isMultipleRowSelectionEnabled(props); const visibleRows = useGridVisibleRows(apiRef, props); + const tree = useGridSelector(apiRef, gridRowTreeSelector); + const isNestedData = useGridSelector(apiRef, gridRowMaximumTreeDepthSelector) > 1; const expandMouseRowRangeSelection = React.useCallback( (id: GridRowId) => { @@ -147,7 +158,7 @@ export const useGridRowSelection = ( [apiRef], ); - /** + /* * API METHODS */ const setRowSelectionModel = React.useCallback( @@ -219,24 +230,77 @@ export const useGridRowSelection = ( if (resetSelection) { logger.debug(`Setting selection for row ${id}`); - apiRef.current.setRowSelectionModel(isSelected ? [id] : []); + const newSelection: GridRowId[] = []; + const addRow = (rowId: GridRowId) => { + newSelection.push(rowId); + }; + if (isSelected) { + addRow(id); + if (applyAutoSelection) { + findRowsToSelect( + apiRef, + tree, + id, + props.rowSelectionPropagation?.descendants ?? false, + props.rowSelectionPropagation?.parents ?? false, + addRow, + ); + } + } + + apiRef.current.setRowSelectionModel(newSelection); } else { logger.debug(`Toggling selection for row ${id}`); const selection = gridRowSelectionStateSelector(apiRef.current.state); - const newSelection: GridRowId[] = selection.filter((el) => el !== id); + const newSelection: Set = new Set(selection); + newSelection.delete(id); + + const addRow = (rowId: GridRowId) => { + newSelection.add(rowId); + }; + const removeRow = (rowId: GridRowId) => { + newSelection.delete(rowId); + }; if (isSelected) { - newSelection.push(id); + addRow(id); + if (applyAutoSelection) { + findRowsToSelect( + apiRef, + tree, + id, + props.rowSelectionPropagation?.descendants ?? false, + props.rowSelectionPropagation?.parents ?? false, + addRow, + ); + } + } else if (applyAutoSelection) { + findRowsToDeselect( + apiRef, + tree, + id, + props.rowSelectionPropagation?.descendants ?? false, + props.rowSelectionPropagation?.parents ?? false, + removeRow, + ); } - const isSelectionValid = newSelection.length < 2 || canHaveMultipleSelection; + const isSelectionValid = newSelection.size < 2 || canHaveMultipleSelection; if (isSelectionValid) { - apiRef.current.setRowSelectionModel(newSelection); + apiRef.current.setRowSelectionModel(Array.from(newSelection)); } } }, - [apiRef, logger, canHaveMultipleSelection], + [ + apiRef, + logger, + applyAutoSelection, + tree, + props.rowSelectionPropagation?.descendants, + props.rowSelectionPropagation?.parents, + canHaveMultipleSelection, + ], ); const selectRows = React.useCallback( @@ -247,18 +311,63 @@ export const useGridRowSelection = ( let newSelection: GridRowId[]; if (resetSelection) { - newSelection = isSelected ? selectableIds : []; + if (isSelected) { + newSelection = selectableIds; + if (applyAutoSelection) { + const addRow = (rowId: GridRowId) => { + newSelection.push(rowId); + }; + selectableIds.forEach((id) => { + findRowsToSelect( + apiRef, + tree, + id, + props.rowSelectionPropagation?.descendants ?? false, + props.rowSelectionPropagation?.parents ?? false, + addRow, + ); + }); + } + } else { + newSelection = []; + } } else { // We clone the existing object to avoid mutating the same object returned by the selector to others part of the project const selectionLookup = { ...selectedIdsLookupSelector(apiRef), }; + const addRow = (rowId: GridRowId) => { + selectionLookup[rowId] = rowId; + }; + const removeRow = (rowId: GridRowId) => { + delete selectionLookup[rowId]; + }; selectableIds.forEach((id) => { if (isSelected) { selectionLookup[id] = id; + if (applyAutoSelection) { + findRowsToSelect( + apiRef, + tree, + id, + props.rowSelectionPropagation?.descendants ?? false, + props.rowSelectionPropagation?.parents ?? false, + addRow, + ); + } } else { - delete selectionLookup[id]; + removeRow(id); + if (applyAutoSelection) { + findRowsToDeselect( + apiRef, + tree, + id, + props.rowSelectionPropagation?.descendants ?? false, + props.rowSelectionPropagation?.parents ?? false, + removeRow, + ); + } } }); @@ -270,7 +379,15 @@ export const useGridRowSelection = ( apiRef.current.setRowSelectionModel(newSelection); } }, - [apiRef, logger, canHaveMultipleSelection], + [ + logger, + applyAutoSelection, + canHaveMultipleSelection, + apiRef, + tree, + props.rowSelectionPropagation?.descendants, + props.rowSelectionPropagation?.parents, + ], ); const selectRowRange = React.useCallback( @@ -322,31 +439,63 @@ export const useGridRowSelection = ( props.signature === GridSignature.DataGrid ? 'private' : 'public', ); - /** + /* * EVENTS */ - const removeOutdatedSelection = React.useCallback(() => { - if (props.keepNonExistentRowsSelected) { - return; - } - const currentSelection = gridRowSelectionStateSelector(apiRef.current.state); - const filteredRowsLookup = gridFilteredRowsLookupSelector(apiRef); - - // We clone the existing object to avoid mutating the same object returned by the selector to others part of the project - const selectionLookup = { ...selectedIdsLookupSelector(apiRef) }; + const removeOutdatedSelection = React.useCallback( + (sortModelUpdated = false) => { + const currentSelection = gridRowSelectionStateSelector(apiRef.current.state); + const filteredRowsLookup = gridFilteredRowsLookupSelector(apiRef); + + // We clone the existing object to avoid mutating the same object returned by the selector to others part of the project + const selectionLookup = { ...selectedIdsLookupSelector(apiRef) }; + + let hasChanged = false; + currentSelection.forEach((id: GridRowId) => { + if (filteredRowsLookup[id] === false) { + if (props.keepNonExistentRowsSelected) { + return; + } + delete selectionLookup[id]; + hasChanged = true; + return; + } + if (!props.rowSelectionPropagation?.parents) { + return; + } + const node = tree[id]; + if (node.type === 'group') { + const isAutoGenerated = (node as GridGroupNode).isAutoGenerated; + if (isAutoGenerated) { + delete selectionLookup[id]; + hasChanged = true; + return; + } + // Keep previously selected tree data parents selected if all their children are filtered out + if (!node.children.every((childId) => filteredRowsLookup[childId] === false)) { + delete selectionLookup[id]; + hasChanged = true; + } + } + }); - let hasChanged = false; - currentSelection.forEach((id: GridRowId) => { - if (!filteredRowsLookup[id]) { - delete selectionLookup[id]; - hasChanged = true; + if (hasChanged || (isNestedData && !sortModelUpdated)) { + const newSelection = Object.values(selectionLookup); + if (isNestedData) { + apiRef.current.selectRows(newSelection, true, true); + } else { + apiRef.current.setRowSelectionModel(newSelection); + } } - }); - - if (hasChanged) { - apiRef.current.setRowSelectionModel(Object.values(selectionLookup)); - } - }, [apiRef, props.keepNonExistentRowsSelected]); + }, + [ + apiRef, + isNestedData, + props.rowSelectionPropagation?.parents, + props.keepNonExistentRowsSelected, + tree, + ], + ); const handleSingleRowSelection = React.useCallback( (id: GridRowId, event: React.MouseEvent | React.KeyboardEvent) => { @@ -535,7 +684,7 @@ export const useGridRowSelection = ( useGridApiEventHandler( apiRef, 'sortedRowsSet', - runIfRowSelectionIsEnabled(removeOutdatedSelection), + runIfRowSelectionIsEnabled(() => removeOutdatedSelection(true)), ); useGridApiEventHandler( apiRef, @@ -560,7 +709,7 @@ export const useGridRowSelection = ( ); useGridApiEventHandler(apiRef, 'cellKeyDown', runIfRowSelectionIsEnabled(handleCellKeyDown)); - /** + /* * EFFECTS */ React.useEffect(() => { @@ -604,4 +753,8 @@ export const useGridRowSelection = ( apiRef.current.setRowSelectionModel([]); } }, [apiRef, canHaveMultipleSelection, checkboxSelection, isStateControlled, props.rowSelection]); + + React.useEffect(() => { + runIfRowSelectionIsEnabled(removeOutdatedSelection); + }, [removeOutdatedSelection, runIfRowSelectionIsEnabled]); }; diff --git a/packages/x-data-grid/src/hooks/features/rowSelection/utils.ts b/packages/x-data-grid/src/hooks/features/rowSelection/utils.ts index 908dc4260d1e..ba1ff85f77be 100644 --- a/packages/x-data-grid/src/hooks/features/rowSelection/utils.ts +++ b/packages/x-data-grid/src/hooks/features/rowSelection/utils.ts @@ -1,5 +1,102 @@ -import type { DataGridProcessedProps } from '../../../models/props/DataGridProps'; import { GridSignature } from '../../utils/useGridApiEventHandler'; +import { GRID_ROOT_GROUP_ID } from '../rows/gridRowsUtils'; +import { gridFilteredRowsLookupSelector } from '../filter/gridFilterSelector'; +import { gridSortedRowIdsSelector } from '../sorting/gridSortingSelector'; +import { selectedIdsLookupSelector } from './gridRowSelectionSelector'; +import { gridRowTreeSelector } from '../rows/gridRowsSelector'; +import { createSelector } from '../../../utils/createSelector'; +import type { GridGroupNode, GridRowId, GridRowTreeConfig } from '../../../models/gridRows'; +import type { DataGridProcessedProps } from '../../../models/props/DataGridProps'; +import type { + GridPrivateApiCommunity, + GridApiCommunity, +} from '../../../models/api/gridApiCommunity'; +import type { GridRowSelectionPropagation } from '../../../models/gridRowSelectionModel'; + +export const ROW_SELECTION_PROPAGATION_DEFAULT: GridRowSelectionPropagation = { + parents: false, + descendants: false, +}; + +function getGridRowGroupSelectableDescendants( + apiRef: React.MutableRefObject, + groupId: GridRowId, +) { + const rowTree = gridRowTreeSelector(apiRef); + const sortedRowIds = gridSortedRowIdsSelector(apiRef); + const filteredRowsLookup = gridFilteredRowsLookupSelector(apiRef); + const groupNode = rowTree[groupId]; + if (!groupNode || groupNode.type !== 'group') { + return []; + } + + const descendants: GridRowId[] = []; + + const startIndex = sortedRowIds.findIndex((id) => id === groupId) + 1; + for ( + let index = startIndex; + index < sortedRowIds.length && rowTree[sortedRowIds[index]]?.depth > groupNode.depth; + index += 1 + ) { + const id = sortedRowIds[index]; + if (filteredRowsLookup[id] !== false && apiRef.current.isRowSelectable(id)) { + descendants.push(id); + } + } + return descendants; +} + +// TODO v8: Use `createSelectorV8` +export function getCheckboxPropsSelector(groupId: GridRowId, autoSelectParents: boolean) { + return createSelector( + gridRowTreeSelector, + gridSortedRowIdsSelector, + gridFilteredRowsLookupSelector, + selectedIdsLookupSelector, + (rowTree, sortedRowIds, filteredRowsLookup, rowSelectionLookup) => { + const groupNode = rowTree[groupId]; + if (!groupNode || groupNode.type !== 'group') { + return { + isIndeterminate: false, + isChecked: rowSelectionLookup[groupId] === groupId, + }; + } + + if (rowSelectionLookup[groupId] === groupId) { + return { + isIndeterminate: false, + isChecked: true, + }; + } + + let selectableDescendentsCount = 0; + let selectedDescendentsCount = 0; + const startIndex = sortedRowIds.findIndex((id) => id === groupId) + 1; + for ( + let index = startIndex; + index < sortedRowIds.length && rowTree[sortedRowIds[index]]?.depth > groupNode.depth; + index += 1 + ) { + const id = sortedRowIds[index]; + if (filteredRowsLookup[id] !== false) { + selectableDescendentsCount += 1; + if (rowSelectionLookup[id] !== undefined) { + selectedDescendentsCount += 1; + } + } + } + return { + isIndeterminate: + (selectedDescendentsCount > 0 && selectedDescendentsCount < selectableDescendentsCount) || + (selectedDescendentsCount === selectableDescendentsCount && + rowSelectionLookup[groupId] === undefined), + isChecked: autoSelectParents + ? selectedDescendentsCount > 0 + : rowSelectionLookup[groupId] === groupId, + }; + }, + ); +} export function isMultipleRowSelectionEnabled( props: Pick< @@ -13,3 +110,134 @@ export function isMultipleRowSelectionEnabled( } return !props.disableMultipleRowSelection; } + +const getRowNodeParents = (tree: GridRowTreeConfig, id: GridRowId) => { + const parents: GridRowId[] = []; + + let parent: GridRowId | null = id; + + while (parent != null && parent !== GRID_ROOT_GROUP_ID) { + const node = tree[parent] as GridGroupNode; + if (!node) { + return parents; + } + parents.push(parent); + parent = node.parent; + } + return parents; +}; + +const getFilteredRowNodeSiblings = ( + tree: GridRowTreeConfig, + filteredRows: Record, + id: GridRowId, +) => { + const node = tree[id]; + if (!node) { + return []; + } + + const parent = node.parent; + if (parent == null) { + return []; + } + + const parentNode = tree[parent] as GridGroupNode; + + return parentNode.children.filter((childId) => childId !== id && filteredRows[childId]); +}; + +export const findRowsToSelect = ( + apiRef: React.MutableRefObject, + tree: GridRowTreeConfig, + selectedRow: GridRowId, + autoSelectDescendants: boolean, + autoSelectParents: boolean, + addRow: (rowId: GridRowId) => void, +) => { + const filteredRows = gridFilteredRowsLookupSelector(apiRef); + const selectedIdsLookup = selectedIdsLookupSelector(apiRef); + const selectedDescendants: Set = new Set([]); + + if (!autoSelectDescendants && !autoSelectParents) { + return; + } + + if (autoSelectDescendants) { + const rowNode = tree[selectedRow]; + + if (rowNode?.type === 'group') { + const descendants = getGridRowGroupSelectableDescendants(apiRef, selectedRow); + descendants.forEach((rowId) => { + addRow(rowId); + selectedDescendants.add(rowId); + }); + } + } + + if (autoSelectParents) { + const checkAllDescendantsSelected = (rowId: GridRowId): boolean => { + if (selectedIdsLookup[rowId] !== rowId && !selectedDescendants.has(rowId)) { + return false; + } + const node = tree[rowId]; + if (node?.type !== 'group') { + return true; + } + return node.children.every(checkAllDescendantsSelected); + }; + + const traverseParents = (rowId: GridRowId) => { + const siblings: GridRowId[] = getFilteredRowNodeSiblings(tree, filteredRows, rowId); + if (siblings.length === 0 || siblings.every(checkAllDescendantsSelected)) { + const rowNode = tree[rowId] as GridGroupNode; + const parent = rowNode.parent; + if ( + parent != null && + parent !== GRID_ROOT_GROUP_ID && + apiRef.current.isRowSelectable(parent) + ) { + addRow(parent); + selectedDescendants.add(parent); + traverseParents(parent); + } + } + }; + traverseParents(selectedRow); + } +}; + +export const findRowsToDeselect = ( + apiRef: React.MutableRefObject, + tree: GridRowTreeConfig, + deselectedRow: GridRowId, + autoSelectDescendants: boolean, + autoSelectParents: boolean, + removeRow: (rowId: GridRowId) => void, +) => { + const selectedIdsLookup = selectedIdsLookupSelector(apiRef); + + if (!autoSelectParents && !autoSelectDescendants) { + return; + } + + if (autoSelectParents) { + const allParents = getRowNodeParents(tree, deselectedRow); + allParents.forEach((parent) => { + const isSelected = selectedIdsLookup[parent] === parent; + if (isSelected) { + removeRow(parent); + } + }); + } + + if (autoSelectDescendants) { + const rowNode = tree[deselectedRow]; + if (rowNode?.type === 'group') { + const descendants = getGridRowGroupSelectableDescendants(apiRef, deselectedRow); + descendants.forEach((descendant) => { + removeRow(descendant); + }); + } + } +}; diff --git a/packages/x-data-grid/src/internals/index.ts b/packages/x-data-grid/src/internals/index.ts index 6d0b8e5b91b6..7463a879c2f6 100644 --- a/packages/x-data-grid/src/internals/index.ts +++ b/packages/x-data-grid/src/internals/index.ts @@ -133,6 +133,7 @@ export { useGridColumnResize, columnResizeStateInitializer, } from '../hooks/features/columnResize/useGridColumnResize'; +export { ROW_SELECTION_PROPAGATION_DEFAULT } from '../hooks/features/rowSelection/utils'; export { useTimeout } from '../hooks/utils/useTimeout'; export { useGridVisibleRows, getVisibleRows } from '../hooks/utils/useGridVisibleRows'; diff --git a/packages/x-data-grid/src/models/gridRowSelectionModel.ts b/packages/x-data-grid/src/models/gridRowSelectionModel.ts index 599d21df8b8f..92b98a2b8dca 100644 --- a/packages/x-data-grid/src/models/gridRowSelectionModel.ts +++ b/packages/x-data-grid/src/models/gridRowSelectionModel.ts @@ -1,5 +1,10 @@ import { GridRowId } from './gridRows'; +export type GridRowSelectionPropagation = { + descendants?: boolean; + parents?: boolean; +}; + export type GridInputRowSelectionModel = readonly GridRowId[] | GridRowId; export type GridRowSelectionModel = readonly GridRowId[]; diff --git a/packages/x-data-grid/src/models/props/DataGridProps.ts b/packages/x-data-grid/src/models/props/DataGridProps.ts index 449f56046587..8d4495333596 100644 --- a/packages/x-data-grid/src/models/props/DataGridProps.ts +++ b/packages/x-data-grid/src/models/props/DataGridProps.ts @@ -33,6 +33,7 @@ import { GridColumnGroupingModel } from '../gridColumnGrouping'; import { GridPaginationMeta, GridPaginationModel } from '../gridPaginationProps'; import type { GridAutosizeOptions } from '../../hooks/features/columnResize'; import type { GridDataSource } from '../gridDataSource'; +import type { GridRowSelectionPropagation } from '../gridRowSelectionModel'; export interface GridExperimentalFeatures { /** @@ -827,10 +828,23 @@ export interface DataGridPropsWithoutDefaultValue