DEV Community

loading...

Writing a complex AG-grid popup cell editor

Gjorgji Kirkov
Passionate about building software and applying engineering principles to a solution.
・7 min read

What is AG-grid?

Be it that you want to display some data from your database or have an advanced way of editing information in a table in your application, you probably need a robust, easy-to-use grid component for managing that goal. That is where AG-grid comes up.

With over 600'000 weekly downloads it is one of the best data-grid libraries in the JavaScript world. Besides the obvious popularity it still boasts an enormous performance boost even when working with huge data sets and still manages to have a ton of useful features for even the most complex use cases.

That kind of a complex use case we are going to go explain in this post.

The problem

For this tutorial we are going to tackle a rather known problem, going over monthly expenses. What we would like to have is a table in which we can enter our expenses (rows) for separate months (columns).
Initial state

Now this seems fine and dandy, but what happens if you want to try and edit multiple cells at the same time or somehow input the same value for multiple months?
This is where the advanced cell editing of ag-grid comes up. We can override the simple text editing of the grid with a popup which knows how edit multiple cells at one time.
Input popup cell editor

The solution

First thing we need to setup is a basic HTML file which will hold a div with an id so we can reference the grid from inside our script file. Besides that we can also define a preexisting theme for the grid. (More about themes can be found here).

<!DOCTYPE html>
<html lang="en">
<head>
    <title>AG grid input widget popup</title>
    <script src="https://unpkg.com/@ag-grid-community/all-modules@23.0.2/dist/ag-grid-community.min.js"></script>
</head>

<body>
<div id="myGrid"  style="height: 100%;" class="ag-theme-balham"></div>

<script src="index.js"></script>
</body>
</html>

Once that is set up we can also add some default styling for the grid so it looks proper.

html, body {
    height: 100%;
    width: 100%;
    margin: 0;
    box-sizing: border-box;
    -webkit-overflow-scrolling: touch;
}

html {
    position: absolute;
    top: 0;
    left: 0;
    padding: 0;
    overflow: auto;
}

body {
    padding: 1rem;
    overflow: auto;
}

td, th {
    text-align: left;
    padding: 8px;
}

#monthSelection, #inputValue {
    width: 100%;
}

.input-widget-popup {
    width: 250px;
    height: 150px;
}        

For the styling applied to the td and tr elements and the specific id and class selectors below them - we will go over them in detail when implementing the popup cell editor.

After we have set up the basic HTML skeleton of our grid we now have to head over to the JavaScript side and somehow wire up the grid so we can display some data in it.

What we need to do now is create and index.js file and create the grid with some configuration.

const rowData = [
  {
    expenses: 'Rent',
    january: 1000,
    february: 1000
  },
  {
    expenses: 'Food',
    january: 150,
    february: 125
  },
  {
    expenses: 'Car',
    january: 100,
    february: 200
  },
  {
    expenses: 'Electricity',
    january: 100,
    february: 200
  },
];

const columnDefs = [
  { field: 'expenses', editable: false },
  { field: 'january', headerName: 'January' },
  { field: 'february', headerName: 'February' },
  { field: 'march', headerName: 'March' },
  { field: 'april', headerName: 'April' },
  { field: 'may', headerName: 'May' },
  { field: 'june', headerName: 'June' },
  { field: 'july', headerName: 'July' },
  { field: 'august', headerName: 'August' },
  { field: 'september', headerName: 'September' },
  { field: 'october', headerName: 'October' },
  { field: 'november', headerName: 'November' },
  { field: 'december', headerName: 'December' }
];

const gridOptions = {
  columnDefs,
  rowData,
  defaultColDef: {
    editable: true,
    sortable: true
  }
};

document.addEventListener('DOMContentLoaded', () => {
  const gridDiv = document.querySelector('#myGrid');
  new agGrid.Grid(gridDiv, gridOptions);
});

OK, so this might look a little bit overwhelming, but bear with me - we will go over the points and explain it.

  1. First we need to somehow the element from the DOM. (Remember we introduced a div with an id of myGrid in the HTML file)
  2. After that we just create a new ag grid instance by calling the constructor made available by the ag-grid library new agGrid.Grid with the div element as an argument and the grid options.
  3. The gridOptions are where the magic happens and all of the configurations can be done.
  4. We define the row data (a simple JavaScript array of objects) which holds the data that we want to display
  5. We define the columnDefs - an array of objects that has field which is a unique identifier of a column and a headerName which is the text that is displayed in the header of a column
  6. The defaulColDef is exactly what the name says - it acts as a default option and adds the defined properties in it to all the other column definitions.

Now that we have the grid setup and all the fields are editable we can go over into wiring up our custom cell editor.
We first need to extend the defaultColDef with another property cellEditor which will hold a reference to our custom class for the cell editor.

const gridOptions = {
  columnDefs,
  rowData,
  defaultColDef: {
    editable: true,
    sortable: true,
    cellEditor: ExpensePopupCellEditor
  }
};

We will also need to update the first columnDef for the expenses to use the default cell renderer so for now we can just initialize the cellRenderer property as an empty string.

{ field: 'expenses', editable: false, cellRenderer: '' }

For the cell editor we will define a JavaScript class called ExpensePopupCellEditor which will hold our custom logic.

class ExpensePopupCellEditor {

  // gets called once after the editor is created
  init(params) {
    this.container = document.createElement('div');
    this.container.setAttribute('class', 'input-widget-popup');
    this._createTable(params);
    this._registerApplyListener();
    this.params = params;
  }

  // Return the DOM element of your editor,
  // this is what the grid puts into the DOM
  getGui() {
   return this.container;
  }

  // Gets called once by grid after editing is finished
  // if your editor needs to do any cleanup, do it here
  destroy() {
    this.applyButton.removeEventListener('click', this._applyValues);
  }

  // Gets called once after GUI is attached to DOM.
  // Useful if you want to focus or highlight a component
  afterGuiAttached() {
    this.container.focus();
  }

  // Should return the final value to the grid, the result of the editing
  getValue() {
    return this.inputValue.value;
  }

  // Gets called once after initialised.
  // If you return true, the editor will appear in a popup
  isPopup() {
    return true;
  }
}

Most of the methods in the popup are self describing so the most interesting part here would be to dive into the init method.

  1. First we create the container element which will contain the whole popup and apply the CSS class we defined earlier in our HTML file.
  2. After that we create the table structure and register the click listener for the Apply button
  3. At the end we also save the params object for later use.
 _createTable(params) {
    this.container.innerHTML = `
      <table>
        <tr>
            <th></th>
            <th>From</th>
            <th>To</th>
        </tr>
        <tr>
            <td></td>
            <td>${params.colDef.headerName}</td>
            <td><select id="monthSelection"></select></td>
        </tr>
        <tr></tr>
        <tr>
            <td>${params.data.expenses}</td>
            <td></td>
            <td><input id="inputValue" type="number"/></td>
        </tr>
        <tr>
            <td></td>
            <td></td>
            <td><button id="applyBtn">Apply</button></td>
        </tr>
      </table>
    `;
    this.monthDropdown = this.container.querySelector('#monthSelection');
    for (let i = 0; i < months.length; i++) {
      const option = document.createElement('option');
      option.setAttribute('value', i.toString());
      option.innerText = months[i];
      if (params.colDef.headerName === months[i]) {
        option.setAttribute('selected', 'selected');
      }
      this.monthDropdown.appendChild(option);
    }
    this.inputValue = this.container.querySelector('#inputValue');
    this.inputValue.value = params.value;
  }

In this _createTable(params) method we create the necessary HTML structure of our popup. We have generated three rows of data for the column headers, the cell input, the dropdown for our months selection and the Apply button. Note that we also set the cell input value to be the same as the one in the cell that is currently edited.

The months variable is generated at the start as an array based on the columnDefs.

let months = columnDefs
                .filter(colDef => colDef.field !== 'expenses')
                .map(colDef => colDef.headerName);

The last thing to do is to add a listener to the Apply button and execute logic when it is clicked.

  _registerApplyListener() {
    this.applyButton = this.container.querySelector('#applyBtn');
    this.applyButton.addEventListener('click', this._applyValues);
  }

  _applyValues = () => {
    const newData = { ...this.params.data };
    const startingMonthIndex = months.indexOf(this.params.colDef.headerName);
    const endMonthIndex = parseInt(this.monthDropdown.value);
    const subset = startingMonthIndex > endMonthIndex
      ? months.slice(endMonthIndex, startingMonthIndex)
      : months.slice(startingMonthIndex, endMonthIndex + 1);

    subset
      .map(month => month.toLowerCase())
      .forEach(month => {
        newData[month] = this.inputValue.value;
      });
    this.params.node.setData(newData);
    this.params.stopEditing();
  }

After the registering the _applyValues callback to the click event on the button we do the following:

  1. Create a copy of the data object on the params
    • In this case the data holds the whole row data as one object from the rowData array, based on which cell is edited
  2. Then we need to determing the starting index (based on the currently edited cell) and ending index (based on the selected month from the dropdown) of the months
  3. After this we can generate an sub array of month keys based on the selection
  4. While looping through that array we can set the input value for all months from the subset and set that newData to the rowNode

For example:
A cell edit that stemmed in the March column for the Rent expenses and a selection for the ending month of June with an input value of 500 would generate an object like this:

{
  expenses: 'Rent',
  january: 1000, // preexisting value
  february: 1000, // preexisting value
  march: 500,
  april: 500,
  may: 500,
  june: 500
}

At the end we call the stopEditing() method on the params after which the grid will close the popup automatically and take over the new values from the newData object.

As a bonus - we can also have a simple custom cell renderer which will render the cell values as monetary values. We only need to extend the defaultColDef with another property and define the renderer class similar to the one we did for the editor.

defaultColDef: {
    ...
    cellRenderer: ExpensesCellRenderer,
    cellEditor: ExpensePopupCellEditor
}

class ExpensesCellRenderer {
  init(params) {
    this.gui = document.createElement('span');
    if (this._isNotNil(params.value)
        && (this._isNumber(params.value) || this._isNotEmptyString(params.value))) {
      this.gui.innerText = `$ ${params.value.toLocaleString()}`;
    } else {
      this.gui.innerText = '';
    }
  }

  _isNotNil(value) {
    return value !== undefined && value !== null;
  }

  _isNotEmptyString(value) {
    return typeof value === 'string' && value !== '';
  }

  _isNumber(value) {
    return !Number.isNaN(Number.parseFloat(value)) && Number.isFinite(value);
  }

  getGui() {
    return this.gui;
  }
}

In contrast to the editor - the renderer only needs to define the getGui method which will return the DOM element of the renderer and the init which will create the element with the necessary values.

Conclusion

And basically that's all of it!
We saw how easy is to implement a more complex use case of custom editing of cells in AG-grid with only JavaScript, HTML and CSS.

P.S.

The full source code can be found in the following repo on github.
Feel free to raise an issue or open a PR.
Cheers!

Discussion (1)

Collapse
yashwanth2714 profile image
Yashwanth Kumar

Hey! Thanks for the post.

In my case, getValue() is not returning the editor value. Do you know the reasons for this?