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/
4.55k stars 1.33k forks source link

[data grid] Problem getting latest state of row in edit mode: Have there been changes to how the `gridEditRowsStateSelector()` works? #14967

Closed snarky-barnacle closed 1 month ago

snarky-barnacle commented 1 month ago

The problem in depth

We recently updated our version of the DataGridPro to 7.20.0 from v5. In doing so, we have found that one of the selectors we use to do custom validation and control of the row mode no longer works as expected. We think we may have found a bug in the DataGridPro component, but wondering if there's another way to accomplish what we're trying to do?

In our application we use row editing, and have edit/save buttons in an actions column to control the row mode. We only allow saving row edits if all cell validation criteria have been met. To do this, we use gridEditRowsStateSelector() as part of the getActions function in the columns definition. We get the latest state of the edited row, then use a validation utility on that state that returns true/false, allowing us to enable/disable the save button, like so:

...
getActions: (params: GridRowParams<Article>) => {
        const isInEditMode =
          rowModesModel[params.id]?.mode === GridRowModes.Edit;

        const rowHasEmptyFields = (editRow: GridEditRowProps) =>
          !editRow?.author.value ||
          !editRow?.articleTitle.value ||
          !editRow?.rating.value;

        console.log(gridEditRowsStateSelector(apiRef.current.state)[params.id]);
        if (isInEditMode) {
          return [
            <GridActionsCellItem
              key="Save"
              icon={<CheckIcon />}
              label="Save"
              onClick={handleSaveClick(params.id)}
              disabled={rowHasEmptyFields(
                gridEditRowsStateSelector(apiRef.current.state)[params.id]
              )}
            />,
          ];
        }
        return [
          <GridActionsCellItem
            icon={<EditIcon />}
            label="Edit"
            key="Edit"
            onClick={handleEditClick(params.id)}
          />,
        ];
      },
...

The problem: we found that when filling in a row, even though all cell criteria are met, the save button would not enable. Upon closer look, we discovered that the DataGrid state wasn't updating after each cell edit--this was done by logging the output gridEditRowsStateSelector() and using React DevTools to inspect apiRef.current.state directly)

I've made a very pared down version DataGridPro demo that has the basic row control functionality we need. It exhibits the same behavior described above: https://stackblitz.com/edit/react-8xnt9e?file=Demo.tsx

Specific steps to reproduce the issue

  1. Click the edit button on the last row
  2. fill in the two empty cells with any text.
  3. Without tabbing out of the last cell you edited, note that the save button has not enabled
  4. Now click on the cell with the save button. Save button should now enable

Due to the complexity of the row editing we have in our application (not evident in this simple demo), it's extremely important that the row state be responsive so the proper feedback can be given to our users.

Your environment

`npx @mui/envinfo` ``` System: OS: macOS 14.6.1 Binaries: Node: 22.4.1 - ~/.nvm/versions/node/v22.4.1/bin/node npm: 10.8.1 - ~/.nvm/versions/node/v22.4.1/bin/npm pnpm: Not Found Browsers: Chrome: 129.0.6668.100 Edge: 129.0.2792.89 Safari: 17.6 npmPackages: @emotion/react: ^11.9.3 => 11.10.4 @emotion/styled: ^11.9.3 => 11.10.4 @mui/core-downloads-tracker: 5.16.7 @mui/icons-material: ^5.16.7 => 5.16.7 @mui/material: ^5.16.7 => 5.16.7 @mui/private-theming: 5.16.6 @mui/styled-engine: 5.16.6 @mui/system: 5.16.7 @mui/types: 7.2.16 @mui/utils: 5.16.6 @mui/x-data-grid: 7.20.0 @mui/x-data-grid-pro: ^7.17.0 => 7.20.0 @mui/x-date-pickers: ^7.17.0 => 7.17.0 @mui/x-internals: 7.17.0 @mui/x-license: ^7.16.0 => 7.20.0 @types/react: ^18.2.65 => 18.2.65 react: ^18.3.1 => 18.3.1 react-dom: ^18.3.1 => 18.3.1 typescript: ^5.5.4 => 5.5.4 ``` Browsers used: - Microsoft Edge Version 129.0.2792.89 - Firefox 131.0.2 (aarch64)

Search keywords: row editing, state selector, DataGridPro

arminmeh commented 1 month ago

@snarky-barnacle

to ensure reactivity, you need to retrieve state updates with useGridSelector hook

If you update getActions to

      getActions: (params: GridRowParams<Article>) => {
        const isInEditMode =
          rowModesModel[params.id]?.mode === GridRowModes.Edit;
        const rowState = useGridSelector(apiRef, gridEditRowsStateSelector)[
          params.id
        ];

        const rowHasEmptyFields = (editRow: GridEditRowProps) =>
          !editRow?.author.value ||
          !editRow?.articleTitle.value ||
          !editRow?.rating.value;

        if (isInEditMode) {
          return [
            <GridActionsCellItem
              key="Save"
              icon={<CheckIcon />}
              label="Save"
              onClick={handleSaveClick(params.id)}
              disabled={rowHasEmptyFields(rowState)}
            />,
          ];
        }
        return [
          <GridActionsCellItem
            icon={<EditIcon />}
            label="Edit"
            key="Edit"
            onClick={handleEditClick(params.id)}
          />,
        ];
      },

your action button should behave as expected.

Hope that this helps

snarky-barnacle commented 1 month ago

Good morning @arminmeh , thanks so much for the reply. Your solution with useGridSelector does indeed fix the behavior, which is great. However, this is triggering our ESLint rules that do not allow the use of react hooks any place else other than functional components (which getActions is not). We get this error:

React Hook "useGridSelector" is called in function "getActions" that is neither a React function component nor a custom React Hook function. React component names must start with an uppercase letter. React Hook names must start with the word "use".

I'm working through a solution, but wondering if you have any quick pointers for how to resolve this?

arminmeh commented 1 month ago

you can try extracting getActions logic into a component and pass apiRef and params as props

the other possibility is to use renderCell and renderEditCell to return pieces (as components) that you have at the moment in getActions

snarky-barnacle commented 1 month ago

Thanks for the tip--I'm working on extracting the getActions logic into a component, which has led me to extracting const rowState = useGridSelector(apiRef, gridEditRowsStateSelector) into the top level of our functional component. That resolves the hook-use ESLint error, but now I'm getting a type error, which seems to be because apiRef is undefined:

TypeError: Cannot read properties of undefined (reading 'editRows')

we have const apiRef = useGridApiRef(); at the top level of our functional component, just like in the linked demo. Probably something basic I'm forgetting?

snarky-barnacle commented 1 month ago

UPDATE: Just found the place in the docs saying useGridSelector can only be used inside the context of the Data Grid, such as within custom components--which is not what I was doing. Working on another solution....

snarky-barnacle commented 1 month ago

@arminmeh I believe I'm in the clear now. I was able to implement a custom component for the action buttons and pass the row model, edit/save handlers, and GridRowParams to it. The StackBlitz demo is fighting me right now, so for anyone else that is interested in the solution, the code snippet is below. This code snippet has the proper behavior

I appreciate your quick response to this question--I may return in a little as I have yet to implement in this approach in our actual application, and may run into other issues. I'll update to confirm that all is well.

import { Box } from '@mui/material';
import {
  DataGridPro,
  GridActionsCellItem,
  GridColDef,
  GridEditRowProps,
  gridEditRowsStateSelector,
  GridRowId,
  GridRowModes,
  GridRowModesModel,
  useGridApiContext,
  useGridApiRef,
  useGridSelector,
} from '@mui/x-data-grid-pro';
import CheckIcon from '@mui/icons-material/Check';
import EditIcon from '@mui/icons-material/Edit';
import { useState } from 'react';
import React from 'react';
import { GridRenderCellParams } from '@mui/x-data-grid';

type Article = {
  articleId: string;
  articleTitle: string | null;
  author: string | null;
  rating: 'Excellent' | 'Good' | 'Fair' | 'Poor' | null;
};

function ActionButtons(props: {
  params: GridRenderCellParams<Article>;
  rowModesModel: GridRowModesModel;
  onSaveClick: (id: GridRowId) => void;
  onEditClick: (id: GridRowId) => void;
}) {
  const { params, rowModesModel, onEditClick, onSaveClick } = props;
  const apiRef = useGridApiContext();

  const isInEditMode = rowModesModel[params.id]?.mode === GridRowModes.Edit;

  const rowState = useGridSelector(apiRef, gridEditRowsStateSelector)[
    params.id
  ];

  const rowHasEmptyFields = (editRow: GridEditRowProps) =>
    !editRow?.author.value ||
    !editRow?.articleTitle.value ||
    !editRow?.rating.value;

  if (isInEditMode) {
    return [
      <GridActionsCellItem
        key="Save"
        icon={<CheckIcon />}
        label="Save"
        onClick={() => onSaveClick(params.id)}
        disabled={rowHasEmptyFields(rowState)}
      />,
    ];
  }
  return [
    <GridActionsCellItem
      icon={<EditIcon />}
      label="Edit"
      key="Edit"
      onClick={() => onEditClick(params.id)}
    />,
  ];
}

export default function ExampleDataGrid() {
  const apiRef = useGridApiRef();
  const [rowModesModel, setRowModesModel] = useState<GridRowModesModel>({});

  const handleEditClick = (id: GridRowId) => {
    console.log('edting');
    setRowModesModel({ ...rowModesModel, [id]: { mode: GridRowModes.Edit } });
  };

  const handleSaveClick = (id: GridRowId) => {
    console.log('saving');
    setRowModesModel({ ...rowModesModel, [id]: { mode: GridRowModes.View } });
  };

  // const rowState = useGridSelector(apiRef, gridEditRowsStateSelector);

  const rows: Article[] = [
    {
      articleTitle: 'How to refry beans',
      author: 'John Smith',
      rating: 'Excellent',
      articleId: 'a1',
    },
    {
      articleTitle: 'How to roast asparagus',
      author: 'Angela Myers',
      rating: 'Good',
      articleId: 'b2',
    },
    {
      articleTitle: 'Five times you were wrong about me',
      author: null,
      rating: null,
      articleId: 'c3',
    },
  ];

  const columns: GridColDef[] = [
    {
      field: 'articleTitle',
      headerName: 'Article Title',
      flex: 1,
      editable: true,
    },
    {
      field: 'author',
      headerName: 'Author',
      flex: 1,
      editable: true,
    },
    {
      field: 'rating',
      headerName: 'Rating',
      editable: true,
    },
    {
      field: 'actions',
      type: 'actions',
      renderCell: (params: GridRenderCellParams<Article>) => (
        <ActionButtons
          params={params}
          rowModesModel={rowModesModel}
          onSaveClick={() => handleSaveClick(params.id)}
          onEditClick={() => handleEditClick(params.id)}
        />
      ),
    },
  ];
  return (
    <Box>
      <DataGridPro
        columns={columns}
        rows={rows}
        getRowId={(row: Article) => row.articleId}
        rowModesModel={rowModesModel}
        editMode="row"
        apiRef={apiRef}
        disableRowSelectionOnClick
      />
    </Box>
  );
}
arminmeh commented 1 month ago

pass apiRef

this was a mistake

you can just get it inside the component that you have made (like you did already)

since you already switched to renderCell, you can also use renderEditCell so you don't have to inspect manually inside your action if the row is in edit mode.

Also, splitting the component simplifies the props you need to render them and you can memoize them to prevent unnecessary re-rendering

Here is the re-worked version of your last code

import { Box } from '@mui/material';
import {
  DataGridPro,
  GridActionsCellItem,
  GridColDef,
  gridEditRowsStateSelector,
  GridRowId,
  GridRowModes,
  GridRowModesModel,
  useGridApiContext,
  useGridApiRef,
  useGridSelector,
} from '@mui/x-data-grid-pro';
import CheckIcon from '@mui/icons-material/Check';
import EditIcon from '@mui/icons-material/Edit';
import { useState } from 'react';
import React from 'react';
import { GridRenderCellParams } from '@mui/x-data-grid';

type Article = {
  articleId: string;
  articleTitle: string | null;
  author: string | null;
  rating: 'Excellent' | 'Good' | 'Fair' | 'Poor' | null;
};

function ActionEditButtonRaw(props: { rowId: GridRowId; onClick: (id: GridRowId) => void }) {
  const { rowId, onClick } = props;

  const apiRef = useGridApiContext();
  const rowState = useGridSelector(apiRef, gridEditRowsStateSelector)[rowId];

  const rowHasEmptyFields =
    !rowState?.author.value || !rowState?.articleTitle.value || !rowState?.rating.value;

  return (
    <GridActionsCellItem
      icon={<CheckIcon />}
      label="Save"
      onClick={() => onClick(rowId)}
      disabled={rowHasEmptyFields}
    />
  );
}

const ActionEditButton = React.memo(ActionEditButtonRaw);

function ActionButtonRaw(props: { rowId: GridRowId; onClick: (id: GridRowId) => void }) {
  const { rowId, onClick } = props;

  return <GridActionsCellItem icon={<EditIcon />} label="Edit" onClick={() => onClick(rowId)} />;
}

const ActionButton = React.memo(ActionButtonRaw);

export default function ExampleDataGrid() {
  const apiRef = useGridApiRef();
  const [rowModesModel, setRowModesModel] = useState<GridRowModesModel>({});

  const handleEditClick = (id: GridRowId) => {
    console.log('edting');
    setRowModesModel({ ...rowModesModel, [id]: { mode: GridRowModes.Edit } });
  };

  const handleSaveClick = (id: GridRowId) => {
    console.log('saving');
    setRowModesModel({ ...rowModesModel, [id]: { mode: GridRowModes.View } });
  };

  const rows: Article[] = [
    {
      articleTitle: 'How to refry beans',
      author: 'John Smith',
      rating: 'Excellent',
      articleId: 'a1',
    },
    {
      articleTitle: 'How to roast asparagus',
      author: 'Angela Myers',
      rating: 'Good',
      articleId: 'b2',
    },
    {
      articleTitle: 'Five times you were wrong about me',
      author: null,
      rating: null,
      articleId: 'c3',
    },
  ];

  const columns: GridColDef[] = [
    {
      field: 'articleTitle',
      headerName: 'Article Title',
      flex: 1,
      editable: true,
    },
    {
      field: 'author',
      headerName: 'Author',
      flex: 1,
      editable: true,
    },
    {
      field: 'rating',
      headerName: 'Rating',
      editable: true,
    },
    {
      field: 'actions',
      type: 'actions',
      editable: true,
      renderCell: (params: GridRenderCellParams<Article>) => (
        <ActionButton rowId={params.id} onClick={handleEditClick} />
      ),
      renderEditCell: (params: GridRenderCellParams<Article>) => (
        <ActionEditButton rowId={params.id} onClick={handleSaveClick} />
      ),
    },
  ];
  return (
    <Box>
      <DataGridPro
        columns={columns}
        rows={rows}
        getRowId={(row: Article) => row.articleId}
        rowModesModel={rowModesModel}
        editMode="row"
        apiRef={apiRef}
        disableRowSelectionOnClick
      />
    </Box>
  );
}
snarky-barnacle commented 1 month ago

Nice, appreciate the improvement on the example solution. We are able to move forward now, I'll close this issue. Thank you again for the prompt help.

github-actions[bot] commented 1 month ago

This issue has been closed. If you have a similar problem but not exactly the same, please open a new issue. Now, if you have additional information related to this issue or things that could help future readers, feel free to leave a comment.

[!NOTE] @snarky-barnacle How did we do? Your experience with our support team matters to us. If you have a moment, please share your thoughts in this short Support Satisfaction survey.