Search code examples
javascriptreactjsag-gridag-grid-angularag-grid-react

How to Group Empty ag-Grid Rows Under Default Categories in React?


I'm building a React application that utilizes the ag-Grid library (https://www.ag-grid.com/react-data-grid/row-dragging-to-grid/) for displaying tabular data. I have two ag-Grid tables: one on the left and the other on the right. My goal is to allow users to drag and drop rows from the left grid to the right grid.

To achieve this, I have implemented the drag-and-drop functionality, and it works perfectly when there is data available in the left grid. However, I want to enhance the user experience by showing default categories ("Male" and "Female") in the right grid, even when there are no rows present in either grid.

Currently, I face two main challenges:

  • How can I display the "Male" and "Female" categories in the right grid by default, even when there is no data available in either grid?

  • When dragging rows from the left grid to the right grid, how can I ensure that the dropped rows are automatically placed under the appropriate default category (e.g., "Male" category for male athletes) in the right grid?

To summarize my requirements:

  • Show default "Male" and "Female" categories in the right grid, even when there is no data present in either grid.

  • Allow users to drag and drop rows from the left grid to the right grid.

  • Automatically place the dropped rows under the appropriate default category (e.g., "Male" category for male athletes) in the right grid.

This is the code I am using.

import 'ag-grid-community/styles/ag-grid.css';
import 'ag-grid-community/styles/ag-theme-alpine.css';
import React, { useCallback, useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { AgGridReact } from 'ag-grid-react';

const SportRenderer = (props) => {
  return (
    <i
      className="far fa-trash-alt"
      style={{ cursor: 'pointer' }}
      onClick={() => props.api.applyTransaction({ remove: [props.node.data] })}
    ></i>
  );
};

const leftColumns = [
  {
    rowDrag: true,
    maxWidth: 50,
    suppressMenu: true,
    rowDragText: (params, dragItemCount) => {
      if (dragItemCount > 1) {
        return dragItemCount + ' athletes';
      }
      return params.rowNode.data.athlete;
    },
  },
  {
    colId: 'checkbox',
    maxWidth: 50,
    checkboxSelection: true,
    suppressMenu: true,
    headerCheckboxSelection: true,
  },
  { field: 'athlete' },
  { field: 'sport' },
];

const rightColumns = [
  {
    rowDrag: true,
    maxWidth: 50,
    suppressMenu: true,
    rowDragText: (params, dragItemCount) => {
      if (dragItemCount > 1) {
        return dragItemCount + ' athletes';
      }
      return params.rowNode.data.athlete;
    },
  },
  { field: 'athlete' },
  { field: 'sport' },
  {
    suppressMenu: true,
    maxWidth: 50,
    cellRenderer: SportRenderer,
  },
];

const defaultColDef = {
  flex: 1,
  minWidth: 100,
  sortable: true,
  filter: true,
  resizable: true,
};

const GridExample = () => {
  const [leftApi, setLeftApi] = useState(null);
  const [leftColumnApi, setLeftColumnApi] = useState(null);
  const [rightApi, setRightApi] = useState(null);
  const [rawData, setRawData] = useState([]);
  const [leftRowData, setLeftRowData] = useState(null);
  const [rightRowData, setRightRowData] = useState([]);
  const [radioChecked, setRadioChecked] = useState(0);
  const [checkBoxSelected, setCheckBoxSelected] = useState(true);

  useEffect(() => {
    if (!rawData.length) {
      fetch('https://www.ag-grid.com/example-assets/olympic-winners.json')
        .then((resp) => resp.json())
        .then((data) => {
          const athletes = [];
          let i = 0;

          while (athletes.length < 20 && i < data.length) {
            var pos = i++;
            if (athletes.some((rec) => rec.athlete === data[pos].athlete)) {
              continue;
            }
            athletes.push(data[pos]);
          }
          setRawData(athletes);
        });
    }
  }, [rawData]);

  const loadGrids = useCallback(() => {
    setLeftRowData([...rawData]);
    setRightRowData([]);
    leftApi.deselectAll();
  }, [leftApi, rawData]);

  useEffect(() => {
    if (rawData.length) {
      loadGrids();
    }
  }, [rawData, loadGrids]);

  useEffect(() => {
    if (leftColumnApi && leftApi) {
      leftColumnApi.setColumnVisible('checkbox', checkBoxSelected);
      leftApi.setSuppressRowClickSelection(checkBoxSelected);
    }
  }, [leftColumnApi, leftApi, checkBoxSelected]);

  const reset = () => {
    setRadioChecked(0);
    setCheckBoxSelected(true);
    loadGrids();
  };

  const onRadioChange = (e) => {
    setRadioChecked(parseInt(e.target.value, 10));
  };

  const onCheckboxChange = (e) => {
    const checked = e.target.checked;
    setCheckBoxSelected(checked);
  };

  const getRowId = (params) => params.data.athlete;

  const onDragStop = useCallback(
    (params) => {
      var nodes = params.nodes;

      if (radioChecked === 0) {
        leftApi.applyTransaction({
          remove: nodes.map(function (node) {
            return node.data;
          }),
        });
      } else if (radioChecked === 1) {
        leftApi.setNodesSelected({ nodes, newValue: false });
      }
    },
    [leftApi, radioChecked]
  );

  useEffect(() => {
    if (!leftApi || !rightApi) {
      return;
    }
    const dropZoneParams = rightApi.getRowDropZoneParams({ onDragStop });

    leftApi.removeRowDropZone(dropZoneParams);
    leftApi.addRowDropZone(dropZoneParams);
  }, [leftApi, rightApi, onDragStop]);

  const onGridReady = (params, side) => {
    if (side === 0) {
      setLeftApi(params.api);
      setLeftColumnApi(params.columnApi);
    }

    if (side === 1) {
      setRightApi(params.api);
    }
  };

  const getTopToolBar = () => (
    <div className="example-toolbar panel panel-default">
      <div className="panel-body">
        <div onChange={onRadioChange}>
          <input
            type="radio"
            id="move"
            name="radio"
            value="0"
            checked={radioChecked === 0}
          />{' '}
          <label htmlFor="move">Remove Source Rows</label>
          <input
            type="radio"
            id="deselect"
            name="radio"
            value="1"
            checked={radioChecked === 1}
          />{' '}
          <label htmlFor="deselect">Only Deselect Source Rows</label>
          <input
            type="radio"
            id="none"
            name="radio"
            value="2"
            checked={radioChecked === 2}
          />{' '}
          <label htmlFor="none">None</label>
        </div>
        <input
          type="checkbox"
          id="toggleCheck"
          checked={checkBoxSelected}
          onChange={onCheckboxChange}
        />
        <label htmlFor="toggleCheck">Checkbox Select</label>
        <span className="input-group-button">
          <button
            type="button"
            className="btn btn-default reset"
            style={{ marginLeft: '5px' }}
            onClick={reset}
          >
            <i className="fas fa-redo" style={{ marginRight: '5px' }}></i>Reset
          </button>
        </span>
      </div>
    </div>
  );

  const getGridWrapper = (id) => (
    <div className="panel panel-primary" style={{ marginRight: '10px' }}>
      <div className="panel-heading">
        {id === 0 ? 'Athletes' : 'Selected Athletes'}
      </div>
      <div className="panel-body">
        <AgGridReact
          style={{ height: '100%' }}
          defaultColDef={defaultColDef}
          getRowId={getRowId}
          rowDragManaged={true}
          animateRows={true}
          rowSelection={id === 0 ? 'multiple' : undefined}
          rowDragMultiRow={id === 0}
          suppressRowClickSelection={id === 0}
          suppressMoveWhenRowDragging={id === 0}
          rowData={id === 0 ? leftRowData : rightRowData}
          columnDefs={id === 0 ? leftColumns : rightColumns}
          onGridReady={(params) => onGridReady(params, id)}
        />
      </div>
    </div>
  );

  return (
    <div className="top-container">
      {getTopToolBar()}
      <div className="grid-wrapper ag-theme-alpine">
        {getGridWrapper(0)}
        {getGridWrapper(1)}
      </div>
    </div>
  );
};

const root = createRoot(document.getElementById('root'));
root.render(<GridExample />);

I would greatly appreciate any guidance, code examples, or best practices that can help me achieve the desired behavior. Thank you for your time and assistance!


Solution

  • In the answer below, I have used the data coming from this api: https://www.ag-grid.com/example-assets/olympic-winners.json

    I have used "Country" as group column instead of "Gender", because there is no such thing in the response body. Please tweak your code accordingly.

    Question 1

    "How can I display the "Male" and "Female" categories in the right grid by default, even when there is no data available in either grid?"

    Answer

    We should show a dummy data in which only the country field has a value.

    In this example, I am doing that for the right grid. Once we get the data, we group our data by country and get our dummy data ready.

    We'll get something like this in the end

    {
      country: 'United States',
      athlete: null,
      gold: null,
      silver: null,
      bronze: null,
    },
    {
      country: 'Russia',
      athlete: null,
      gold: null,
      silver: null,
      bronze: null,
    }
    

    To do that, we implement a new method

    const getRightGridData = useCallback((data) => {
      var dataGroupedByCountry = data.reduce(function (r, a) {
        r[a.country] = r[a.country] || [];
        r[a.country].push(a);
        return r;
      }, Object.create(null));
    
      var countries = Object.keys(dataGroupedByCountry);
    
      var data = [];
      for (let i = 0; i < countries.length; i++) {
        data.push({
          country: countries[i],
          athlete: null,
          gold: null,
          silver: null,
          bronze: null,
        });
      }
    
      return data;
    }, []);
    

    and use it in onGridReady event of the left grid

    const onLeftGridReady = (params) => {
      fetch('https://www.ag-grid.com/example-assets/olympic-winners.json')
        .then((resp) => resp.json())
        .then((data) => {
          console.log('data', data);
    
          var rightGridData = getRightGridData(data);
    
          rightGridRef.current.api.setRowData(rightGridData);
    
          var leftGridData = getLeftGridData(data);
    
          params.api.setRowData(leftGridData);
        });
    };
    

    Similarly, we should get the left grid's data ready

    const getLeftGridData = useCallback((data) => {
      const result = [
        ...data
          .reduce((r, o) => {
            const key = o.athlete + '-' + o.country;
    
            const item =
              r.get(key) ||
              Object.assign({}, o, {
                gold: 0,
                silver: 0,
                bronze: 0,
              });
    
            item.gold += o.gold;
            item.silver += o.silver;
            item.bronze += o.bronze;
    
            return r.set(key, item);
          }, new Map())
          .values(),
      ];
    
      // we do not need these fields
      delete result.age;
      delete result.date;
    
      return result;
    }, []);
    

    Now, we have this scene in the beginning The First Scene

    Question 2

    "When dragging rows from the left grid to the right grid, how can I ensure that the dropped rows are automatically placed under the appropriate default category (e.g., "Male" category for male athletes) in the right grid?"

    Answer

    As I said earlier, I take "Country" here as group column. Whenever we drag and drop a column, it should be placed under the right country.

    We want to make sure that we cannot drag and drop the same row. We also want to make sure that the dummy data disappears right after our first drag and drop action.

    const gridDrop = (grid, event) => {
      console.log('gridDrop');
      event.preventDefault();
    
      const jsonData = event.dataTransfer.getData('application/json');
      const data = JSON.parse(jsonData);
      console.log('bu data', data);
    
      // if data missing or data has no it, do nothing
      if (!data || data.country == null || data.athlete == null) {
        return;
      }
    
      const gridApi =
        grid === 'left' ? leftGridRef.current.api : rightGridRef.current.api;
    
      // do nothing if row is already in the grid, otherwise we would have duplicates
      const rowAlreadyInGrid = !!gridApi.getRowNode(
        data.country + '-' + data.athlete
      );
    
      if (rowAlreadyInGrid) {
        console.log('not adding row to avoid duplicates in the grid');
        return;
      }
    
      // if it is our first drag and drop action, remove the dummy data
      var removeAll = true;
      rightGridRef.current.api.forEachNode((node) => {
        console.log('node', node);
        if (node.group == false && node.data.athlete !== null) removeAll = false;
      });
      if (removeAll) gridApi.setRowData([]);
    
      // add data
      const transaction = {
        add: [data],
      };
      gridApi.applyTransaction(transaction);
    };
    

    Here's what we get after our first drag and drop action The Latest Scene

    Of course, base requirements for drag and drop actions should be set. But if I try to share all the code, it will be a very long answer. Please check the working plunker below.

    https://plnkr.co/edit/ZfhOKRI5PlQRDwxU?preview

    UPDATE

    If you want group columns to stay even if there is no real data under it, we can modify our code like below

    const gridDrop = (grid, event) => {
      console.log('gridDrop');
      event.preventDefault();
    
      const jsonData = event.dataTransfer.getData('application/json');
      const data = JSON.parse(jsonData);
      console.log('bu data', data);
    
      // if data missing or data has no it, do nothing
      if (!data || data.country == null || data.athlete == null) {
        return;
      }
    
      const gridApi =
          grid === 'left' ? leftGridRef.current.api : rightGridRef.current.api;
    
      // do nothing if row is already in the grid, otherwise we would have duplicates
      const rowAlreadyInGrid = !!gridApi.getRowNode(
        data.country + '-' + data.athlete
      );
    
      if (rowAlreadyInGrid) {
        console.log('not adding row to avoid duplicates in the grid');
        return;
      }
    
      // we check if we have dummy data to delete here
      var removeAll = true;
    
      rightGridRef.current.api.forEachNode((node) => {
        console.log('node', node);
        if (
          node.group == false &&
          node.data.country == data.country &&
          node.data.athlete !== null
        )
          removeAll = false;
      });
    
      // if we have dummy data, mark it as to be removed
      const itemsToDelete = [];
      if (removeAll) {
        rightGridRef.current.api.forEachNode((node) => {
          if (node.group == false && node.data.country == data.country) {
            const item = node.data;
    
            itemsToDelete.push(item);
          }
        });
      }
    
      // add current data
      const transaction = {
        add: [data],
      };
      gridApi.applyTransaction(transaction);
    
      // remove dummy data if exists
      gridApi.applyTransaction({ remove: itemsToDelete });
    };
    

    This is what we get after our first drag and drop action The Updated Scene

    Modified plunker: https://plnkr.co/edit/xTuOz4lasQGvQbCw?preview