mui / mui-x

MUI X: Build complex and data-rich applications using a growing list of advanced React components, like the Data Grid, Date and Time Pickers, Charts, and more!
https://mui.com/x/
3.92k stars 1.19k forks source link

[data grid] Smart children selection when toggling a group #4248

Open elyesbenabdelkader opened 2 years ago

elyesbenabdelkader commented 2 years ago

Summary 💡

When you group rows and then select the group header row, I would expect:

  1. The selection of all of the rows of the group.
  2. onSelectionModelChange to returns an array of the selected group rows ids instead of a single row with a generated id.

Examples 🌈

Motivation 🔦

Having to manually look for a group rows on selection is (in my own opinion) an unnecessarily difficult task in the current state. Add to that if you deselect group rows (while selecting other rows), the task becomes even harder.

Order ID 💳 (optional)

No response

Other user requests

flaviendelangle commented 2 years ago

Hi,

Thank's for your contribution I listed this behavior on #2994, for me depending on the use case you might want, or not the selection of a group to select its children. This could probably be handled by a prop.

For your 2nd point, I don't think we should change the return format of onSelectionModelChange, that would mean changing the format of the model which is massively breaking. What exactly are you trying to achieve that requires this callback to return only the groups ?

elyesbenabdelkader commented 2 years ago

Suppose we have these rows [ { "id": 1, "street": "A" }, { "id": 2, "street": "B" }, { "id": 3, "street": "A" } ] If I select the group header row "A", I expect onSelectionModelChange to return: [1, 3]. Here nothing breaks we still return a list of the selected rows ids.

flaviendelangle commented 2 years ago

It breaks because if you are controlling the model, the selected rows are the one you give back to the grid. So if we pass [1, 3], to onSelectionModelChange you will pass [1, 3] to props.selectionModel. To be able to keep the group visually selected without passing the groups themselves in the model, we would have not just to say "when you select a group, select its children" but also "if all the children are selected, then select also the parents".

This as several impacts

  1. How do we handle selection with lazy loaded children if there is no trace of the group selection inside the model ?
  2. I consider it to be a breaking change, even if the format does not change. Users could rely on the presence of the group inside the selectionModel to trigger some custom behavior

In v6, we will have to rework the selection to do #208 We could also introduce a notion of "group" in the model that would automatically select its children even when lazy loader later. This needs to be investigated.

In v5, you can easily filter out the IDs of the group before using your model for something custom.

// Remove all the rows generated by the grid
// With the Tree Data it would remove the auto generated parents but not the one based on real rows
// With the Aggregation (not released yet), it would remove the aggregation rows
const modelWithoutGroups = selectionModel.filter(id => !apiRef.current.getRowNode(id)?.isAutoGenerated)

// Remove all the rows that are not leaves (ie all the grouping rows)
// With the Tree Data it would remove all the rows that are not of maximal depth (not very usable I think)
// With the Aggregation (not released yet), it would not remove the aggregation rows
const depth = gridRowTreeDepthSelector(apiRef)
const modelWithoutGroups = selectionModel.filter(id => apiRef.current.getRowNode(id)?.depth === depth - 1)
elyesbenabdelkader commented 2 years ago

Fair enough, but could we at least have a method in the api that fetches the rows ids of a given group id? Maybe something like: apiRef.current.getGroupRowsIds("A") that should return [1, 3].

flaviendelangle commented 2 years ago

Here is a method that retrieves all the leaves (filtered or not) of a group: https://codesandbox.io/s/datagridpro-v5-quick-start-forked-ltkt4g?file=/src/App.tsx It should work even when grouping according to several criterias.

We could probably add a built-in version of this method at some point.

flaviendelangle commented 2 years ago

See https://github.com/mui/mui-x/issues/4270 for the method returning the rows of a group.

m4theushw commented 2 years ago

To be able to keep the group visually selected without passing the groups themselves in the model, we would have not just to say "when you select a group, select its children" but also "if all the children are selected, then select also the parents".

Yes, internally the group row would never be selected, but its selection state would be based on its children. Or we even keep it selectable, being present in selectionModel, but we add some sort of method or selector to filter out auto-generated rows from the model. We'll need to sanitize the model, because if we chose to add auto-generated rows to selectionModel, these rows may not be there during the first render, so we'll need to add them.

How do we handle selection with lazy loaded children if there is no trace of the group selection inside the model ?

We have this same problem with the "Select All" checkbox. selectionModel might not be empty but if the IDs are not also in rows, the checkbox is displayed as unchecked. I would reproduce the same behavior here, initially at least.

inflrscns commented 2 years ago

I ran into this issue in development and wrote a gist that may be helpful for adding this feature, or anyone who needs it working: https://gist.github.com/inflrscns/fa5f3d4551947cbf4735ac8373d9bbef

note: I built this for single column row grouping, probably doesn't work for multiple

HansBrende commented 1 year ago

@inflrscns I've built on your code example to display indeterminate-state checkboxes in the parent rows whenever the children are partially selected, by adding the following column definition (my tree is only of depth 2, but this could be extended to support higher depths):

import {
    GRID_CHECKBOX_SELECTION_COL_DEF, GridCellCheckboxRenderer, GridColDef, 
    GridApiPro, selectedIdsLookupSelector
} from "@mui/x-data-grid-pro";

const checkboxColumn: GridColDef = {
    ...GRID_CHECKBOX_SELECTION_COL_DEF,
    renderCell: (params: GridRenderCellParams) => {
        const rowNode = params.rowNode;
        const selectionLookup = selectedIdsLookupSelector(params.api.state, params.api.instanceId);
        const indeterminate = rowNode.children != null && rowNode.parent == null &&
            rowNode.children.some(child => selectionLookup[child] === undefined) &&
            rowNode.children.some(child => selectionLookup[child] !== undefined);
        return <GridCellCheckboxRenderer
            {...params}
            // @ts-ignore
            indeterminate={indeterminate}
            size={rowNode.parent == null ? undefined : 'small'}
        />
    }
};
cstephens-cni commented 1 year ago

Further building off @HansBrende if you want the selection logic in the cell

2 things to note:

Otherwise this is working surprising well for me.

import {
    GRID_CHECKBOX_SELECTION_COL_DEF,
    GridCellCheckboxRenderer,
    GridColDef,
    GridRenderCellParams,
    selectedIdsLookupSelector
} from "@mui/x-data-grid-premium";
import { GridRenderCellParamsPremium } from "@mui/x-data-grid-premium/typeOverloads";

export const MUICustomCheckboxColumn: GridColDef = {
    ...GRID_CHECKBOX_SELECTION_COL_DEF,
    renderCell: (params: GridRenderCellParams & GridRenderCellParamsPremium) => {
        const rowNode = params.rowNode;
        const selectionLookup = selectedIdsLookupSelector(params.api.state, params.api.instanceId);
        let indeterminate: boolean | undefined = undefined;
        let checked: boolean | undefined = undefined;
        const extraData ={};
        if (rowNode.type === "group") {
            const isBottomGroup = rowNode.children != null && (rowNode.parent == null || rowNode.parent === "auto-generated-group-node-root");
            indeterminate = isBottomGroup &&
                rowNode.children.some(child => selectionLookup[child] === undefined) &&
                rowNode.children.some(child => selectionLookup[child] !== undefined);
            checked = isBottomGroup && rowNode.children.every(child => selectionLookup[child] !== undefined);
            if(indeterminate){
                extraData["indeterminate"]=indeterminate;
            }
            if(checked){
                extraData["checked"]=checked;
            }
            extraData["onClick"] = (e) => {
                if (rowNode.type === "group") {
                    if (rowNode.children) {
                        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
                        // @ts-ignore
                        params.api.selectRows(rowNode.children, indeterminate || !checked);
                    }
                    e.preventDefault();
                }
            }
        }
        return <GridCellCheckboxRenderer
            {...params}
            {...extraData}
        />;
    }
};
cstephens-cni commented 1 year ago

Further building off @HansBrende if you want the selection logic in the cell

2 things to note:

  • still have the ts error comment (i thought GridRenderCellParams & GridRenderCellParamsPremium) should do it but it didn't fix it.
  • when you use the top header selection it will set the internal state of the group as selected, which is not ideal...so it will count it in the footer as well as any method that get selected, I will work this out later.

Otherwise this is working surprising well for me.

import {
    GRID_CHECKBOX_SELECTION_COL_DEF,
    GridCellCheckboxRenderer,
    GridColDef,
    GridRenderCellParams,
    selectedIdsLookupSelector
} from "@mui/x-data-grid-premium";
import { GridRenderCellParamsPremium } from "@mui/x-data-grid-premium/typeOverloads";

export const MUICustomCheckboxColumn: GridColDef = {
    ...GRID_CHECKBOX_SELECTION_COL_DEF,
    renderCell: (params: GridRenderCellParams & GridRenderCellParamsPremium) => {
        const rowNode = params.rowNode;
        const selectionLookup = selectedIdsLookupSelector(params.api.state, params.api.instanceId);
        let indeterminate: boolean | undefined = undefined;
        let checked: boolean | undefined = undefined;
        const extraData ={};
        if (rowNode.type === "group") {
            const isBottomGroup = rowNode.children != null && (rowNode.parent == null || rowNode.parent === "auto-generated-group-node-root");
            indeterminate = isBottomGroup &&
                rowNode.children.some(child => selectionLookup[child] === undefined) &&
                rowNode.children.some(child => selectionLookup[child] !== undefined);
            checked = isBottomGroup && rowNode.children.every(child => selectionLookup[child] !== undefined);
            if(indeterminate){
                extraData["indeterminate"]=indeterminate;
            }
            if(checked){
                extraData["checked"]=checked;
            }
            extraData["onClick"] = (e) => {
                if (rowNode.type === "group") {
                    if (rowNode.children) {
                        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
                        // @ts-ignore
                        params.api.selectRows(rowNode.children, indeterminate || !checked);
                    }
                    e.preventDefault();
                }
            }
        }
        return <GridCellCheckboxRenderer
            {...params}
            {...extraData}
        />;
    }
};

Wanted to make a quick note, it's not as clean, so I'm treating it separately, rather then editing what I did above.

If you do what I'm doing, an issue I discovered was that the selected row count is counting the groups as selected if you use the select all header button.

To stop that you can add

        isRowSelectable: (params)=>{
                return params.id.indexOf('auto-generated-row-') === -1 
            },

to your grid...this will disable selecting it. Which also mean it marks it as disabled in the code that I gave above (you can no longer click it). to fix that you can add a single line to the example above.

       if (rowNode.type === "group") {
       ...
            extraData["disabled"] = false;
       ...

This will overwrite the disabled attribute on the grouping checkbox which will no longer count as selected in the footer etc.

nickcarnival commented 10 months ago

@cstephens-cni your examples have been of tremendous help to me so here's a slightly cleaned up version with the removal of // ts-ignore and eslint-disable this version does require you to use the api object

const checkboxColumn = {
  ...GRID_CHECKBOX_SELECTION_COL_DEF,
  renderCell: (
    params: GridRenderCellParams & GridRenderCellParamsPremium
  ) => {
    const { rowNode } = params;

    if (rowNode.groupingField == null)
      return <GridCellCheckboxRenderer {...params} />;

    const selectionLookup = selectedIdsLookupSelector(
      apiRef.current.state,
      apiRef.current.instanceId
    );

    const isBottomGroup =
      rowNode.children != null &&
      (rowNode.parent == null ||
        rowNode.parent === 'auto-generated-group-node-root');

    const indeterminate =
      isBottomGroup &&
      rowNode?.children?.some(
        (child) => selectionLookup[child] === undefined
      ) &&
      rowNode.children.some((child) => selectionLookup[child] !== undefined);

    const checked =
      isBottomGroup &&
      rowNode?.children?.every(
        (child) => selectionLookup[child] !== undefined
      );

    const extraData: GridRenderCellParams &
      GridRenderCellParamsPremium & {
        indeterminate?: boolean;
        checked?: boolean;
        disabled?: boolean;
        onClick?: (e: MouseEvent) => void;
      } = {
      ...params,
      disabled: false,
      onClick: (e) => {
        if (rowNode.groupingField != null) {
          if (rowNode.children) {
            apiRef.current.selectRows(
              rowNode.children,
              indeterminate || !checked
            );
          }
          e.preventDefault();
        }
      },
      indeterminate,
      checked,
    };

    return <GridCellCheckboxRenderer {...extraData} />;
  },
};
jaballogian commented 10 months ago

Before the row grouping on the MUI DataGridPremium component arrived, I had already used the tree data feature on the MUI DataGridPro component. I could migrate from the MUI DataGridPro component to the MUI DataGridPremium component but it would consume a lot of effort since the project has become huge. Based on the user perspective, both have the same display. The code for both features is similar.

So here is the solution for anyone who is still using the tree data feature on the MUI DataGridPro component based on @HansBrende @cstephens-cni and @nickcarnival solutions.

  1. Replace the @mui/x-data-grid-premium into the @mui/x-data-grid-pro dependency
  2. There is no rowNode.groupingField and rowNode.type values on the tree data feature from the MUI DataGridPro component. We could use the rowNode.children value as the substitution to check whether the rowNode is a parent/generated node or a children node.

I tried to implement those two steps but apparently, nothing happens after the parent/group checkbox is clicked. So, the best way to implement this smart children selection feature on the tree data feature is to migrate from the MUI DataGridPro component to the MUI DataGridPremium component 😂.

cclews1 commented 9 months ago

DataGrid Checkbox Nested Group Intermediate Select Functionality + Select All Functionality

@nickcarnival @cstephens-cni

I found your snippets extremely useful, but I noticed it broke in cases where nested groups were used. It also didn't allow the "select all" header to work properly.

Code below:


const checkboxColumn = (
  apiRef: MutableRefObject<GridApiPremium>
): GridColDef => {
  return {
    ...GRID_CHECKBOX_SELECTION_COL_DEF,
    renderHeader: (params) => {
      const children = gridFilteredSortedRowIdsSelector(
        apiRef.current.state,
        apiRef.current.instanceId
      ).filter((id) => !id.toString().includes("auto-generated"));

      const selectionLookup = selectedIdsLookupSelector(
        apiRef.current.state,
        apiRef.current.instanceId
      );

      const indeterminate =
        children?.some((child) => selectionLookup[child] === undefined) &&
        children?.some((child) => selectionLookup[child] !== undefined);

      const checked = children?.every(
        (child) => selectionLookup[child] !== undefined
      );
      const data: GridColumnHeaderParams & {
        indeterminate?: boolean;
        checked?: boolean;
        disabled?: boolean;
        onClick?: (e: MouseEvent) => void;
      } = {
        ...params,
        onClick: (e) => {
          apiRef.current.selectRows(children, indeterminate || !checked);
          e.preventDefault();
        },
        indeterminate,
        checked,
      };
      return (
        <>
          <GridHeaderCheckbox {...data} />
        </>
      );
    },
    renderCell: (params) => {
      const { rowNode } = params;

      if (rowNode.type !== "group")
        return <GridCellCheckboxRenderer {...params} />;

      const selectionLookup = selectedIdsLookupSelector(
        apiRef.current.state,
        apiRef.current.instanceId
      );
      const children = apiRef.current.getRowGroupChildren({
        groupId: rowNode.id,
        applyFiltering: true,
        applySorting: true,
      });

      const indeterminate =
        children?.some((child) => selectionLookup[child] === undefined) &&
        children?.some((child) => selectionLookup[child] !== undefined);

      const checked = children?.every(
        (child) => selectionLookup[child] !== undefined
      );

      const extraData: GridRenderCellParams &
        GridRenderCellParamsPremium & {
          indeterminate?: boolean;
          checked?: boolean;
          disabled?: boolean;
          onClick?: (e: MouseEvent) => void;
        } = {
        ...params,
        disabled: false,
        onClick: (e) => {
          if (rowNode.groupingField != null) {
            if (children) {
              apiRef.current.selectRows(children, indeterminate || !checked);
            }
            e.preventDefault();
          }
        },
        indeterminate,
        checked,
      };

      return <GridCellCheckboxRenderer {...extraData} />;
    },
  };
};
export default checkboxColumn;

It is making use of the GridApi for fetching group children (and subgroup's children) with the apiRef.current.getRowGroupChildren, which includes grandchildren by default. This method also automatically filters out autogenerated group row IDs. It accepts the applyFiltering: true, applySorting: true props which ensure the row selection applies only to the rows that haven't been filtered, in their sort-order.

The code added to the renderHeader prop allows the "Select All" checkbox to basically function the same as the group checkboxes, only it cannot make use of the apiRef.current.getRowGroupChildren method because that method takes a group row's id as a parameter. Unfortunately, this means we have to again manually filter out the group row IDs.

mtsmith commented 2 months ago

Now what it's missing is the ability to update after a filter is applied.

joserodolfofreitas commented 2 months ago

TreeView is also aiming to support this feature soon, so it'd be good for us to keep aligned on the DevEx.

genepaul commented 1 month ago

It looks like possibly v7 may have broken this workaround. It seems this workaround is relying on every checkbox cell re-rendering any time there are changes to the selection state, but in v7 it seems that it's only re-rendering the checkbox cells that changed selection state - so any cell that needs to show as indeterminate isn't updated because it didn't change selection state according to the grid. I have a reproduction at the following link, where if you click a sub-group it will correctly select its children but not render its parent as indeterminate until an update on that row occurs (such as expanding or collapsing that row).

https://react-kckdew.stackblitz.io

Is there something I'm missing here?