Transposing Rows and Columns in ag-grid

Real-world Angular applications often need to present tabular/grid data, and most grids make the most sense when presented with each column representing a certain type of data. For example, on a spreadsheet showing a pay schedule for a loan, the first column could be a date, the second column could be the interest accrued, the next could be the size of the payment, etc.

However, we sometimes need to show data in a transposed format, where the rows instead of the columns need to show a consistent data type. This is a rare case, which is why some major grid libraries like ag-grid don’t provide native support for the feature, but it’s still necessary.

Fortunately, ag-grid gives enough power to developers to be able to transpose data for display, and even to have features like renderers and editors apply by row instead of by column.


I’ve shared a small Angular app on GitHub that I’ll use to step through the process of transposing data. Think of it as a prototype for an app that shows names as they are translated in various languages (Matthew, Mateo, Matthäus, Матфей, etc). You can see the live-running app on StackBlitz, which shows the evolving grid on separate tabs.

Simple
The first version of the grid is the simplest; our manager has asked for each name to be a row, and each language to be in a column (so the versions of “Eugene” take the first row, and the German versions of each name are the third column, for example).

Each version of the grid is in a separate component, and each component has essentially the same template (so we won’t be looking at the template again).

simple.component.html:

<ag-grid-angular
    class="ag-theme-material"
    [rowData]="rowData"
    [columnDefs]="columnDefs"
    >
</ag-grid-angular>

The most important information here is that ag-grid receives information about each row through the rowData input, and each column is defined through the columnDefs input. rowData is an array of any data that represents our row, and columnDefs is an array of ColDef objects, which are ag-grid-specific configuration.

The TypeScript code for this component is pretty simple: the columnDefs are defined explicitly, with headerName being the visible text in the header and field being the name of the property on the rowData for that column. The rowData itself is defined separately and explicitly in a service.

simple.component.ts:

import { Component } from '@angular/core';
import { ColDef } from 'ag-grid-community';

import { Translation } from '../app.types';
import { DataService } from '../data.service';

@Component({
  selector: 'app-simple',
  templateUrl: './simple.component.html',
  styleUrls: ['./simple.component.scss']
})
export class SimpleComponent {
  rowData: Translation[];
  columnDefs: ColDef[];
  constructor(dataSvc: DataService) {
    this.rowData = dataSvc.getNameTranslations();
    this.columnDefs = [
      {
        headerName: 'English',
        field: 'english'
      },
      {
        headerName: 'Spanish',
        field: 'spanish'
      },
      {
        headerName: 'German',
        field: 'german'
      },
      {
        headerName: 'Russian',
        field: 'russian'
      },
    ];
  }
}

From data.service.ts:

const nameTranslations: Translation[] = [
  { english: 'Eugene', spanish: 'Eugenio', german: 'Eugen', russian: 'Евгений' },
  { english: 'Mary', spanish: 'Maria', german: 'Maria', russian: 'Мария' },
  { english: 'Matthew', spanish: 'Mateo', german: 'Matthäus', russian: 'Матфей' },
  { english: 'Barbara', spanish: 'Bárbara', german: 'Barbara', russian: 'Барбара' },
  { english: 'John', spanish: 'Juan', german: 'Johann', russian: 'Иван' },
  { english: 'Martha', spanish: 'Marta', german: 'Marta', russian: 'Марта' },
];

Greeting
Now that we’ve written the simplest version of this grid, our manager wants to show a greeting for each name. So, we’ll see “Hello Eugene” in an English cell, “Hola Eugenio” in a Spanish cell, etc. Fortunately, this is very simple to add in ag-grid. There are several ways to define cell renders to column in ag-grid, and we will use the function approach, defining a function that takes params: ICellRendererParams, which has the value to display and other information about the cell.

From greeting.component.ts:

this.columnDefs = [
  {
    headerName: 'English',
    field: 'english',
    cellRenderer: (params: ICellRendererParams) => `Hello ${params.value}`
  },
  {
    headerName: 'Spanish',
    field: 'spanish',
    cellRenderer: (params: ICellRendererParams) => `Hola ${params.value}`
  },
  {
    headerName: 'German',
    field: 'german',
    cellRenderer: (params: ICellRendererParams) => `Guten Tag ${params.value}`
  },
  {
    headerName: 'Russian',
    field: 'russian',
    cellRenderer: (params: ICellRendererParams) => `Привет ${params.value}`
  },
];

While this works, I have a couple problems with it:

  • There’s a lot of code duplication: we’re essentially doing the same thing four times. We could do this in a loop if we had an array of languages with the name and greeting for each language.
  • This data would probably not be hard-coded in our component; we probably would get this data from the server

To address both problems, I extracted the language information to an array in the service (it doesn’t fully address the second issue, but it’s closer to a realistic implementation as far as the component is concerned).

From data.service.ts:

const languages: Language[] = [
  {
    name: 'English',
    greeting: 'Hello'
  },
  {
    name: 'Spanish',
    greeting: 'Hola'
  },
  {
    name: 'German',
    greeting: 'Guten Tag'
  },
  {
    name: 'Russian',
    greeting: 'Привет'
  },
];

With this in place, we can write an alternate implementation of this version’s columnDefs that takes the languages array and maps it to a new array that has the headerName, field, and cellRenderer that we need for our column definition:

From greeting.component.ts:

this.columnDefs = dataSvc.getLanguages().map(language => {
  return {
    headerName: language.name,
    field: language.name.toLowerCase(),
    cellRenderer: (params: ICellRendererParams) => `${language.greeting} ${params.value}`
  };
});

Transposed
Now that we’re happy with our grid with greetings, our manager has thrown us for a loop; we need to transpose the grid. Instead of the headers being the language names, the headers will be the names in English. Each row will be a different non-English language, and we’ll need to have the greetings apply per-row for each language. We’re going to use the same backing data as before, but now nameTranslations defines the contents of a column, and that’s not how ag-grid consumes data.

Overall, we’ll have to figure out how to take the data that we displayed before as a row’s data, and instead show it as a column’s data. Since ag-grid doesn’t take column data (just column definitions), we’ll have to transform it somehow. Additionally, we will have to figure out how to show different renderers on each row, even though the renderers are defined on the column definition.

How will we do it?

Step by step.

Let’s ignore the greeting for the moment and look at the transposed data. Here’s a hard-coded version of what we want:

From transposed.component.ts:

rowData = [
  {
    language: 'Spanish', eugene: 'Eugenio', mary: 'Maria', matthew: 'Mateo',
    barbara: 'Bárbara', john: 'Juan', martha: 'Marta'
  },
  {
    language: 'German', eugene: 'Eugen', mary: 'Maria', matthew: 'Matthäus',
    barbara: 'Barbara', john: 'Johann', martha: 'Marta'
  },
  {
    language: 'Russian', eugene: 'Евгений', mary: 'Мария', matthew: 'Матфей',
    barbara: 'Барбара', john: 'Иван', martha: 'Марта'
  },
];

columnDefs: ColDef[] = [
  {
    headerName: '',
    field: 'language',
    cellStyle: { 'font-size': 'large'},
    pinned: 'left',
  },
  {
    headerName: 'Eugene',
    field: 'eugene',
  },
  {
    headerName: 'Mary',
    field: 'mary',
  },
  {
    headerName: 'Matthew',
    field: 'matthew',
  },
  {
    headerName: 'Barbara',
    field: 'barbara',
  },
  {
    headerName: 'John',
    field: 'john',
  },
  {
    headerName: 'Martha',
    field: 'martha',
  },
];

This will produce a row for each language, with properties containing each name in that language (and a special property for the name of the language). The column definitions will mostly be a collection of columns specifically for each name, but also with special definition for a pinned column showing the language name in larger size.

This certainly works to show us what state we want in the end, but it’s not dynamic. We’ll need to use logic to convert the data as we actually receive it into this format.

Transposed (DRY)
I call this version DRY, because I keep in mind the advice: Don’t Repeat Yourself. I want to have a dynamic way of converting the source information to the right format without needing to define every row and column.

To create the rows, we will need to have one less row than the number of languages we have (English will be in the header, and all the other languages will get a row). This suggests that we should create our rows from the languages array:

From transposed-dry.component.ts:

this.rowData = dataSvc.getLanguages()
  .filter((_, index) => index > 0) // we don't show english - it's the header
  .map(language => {
    const lowerLang = language.name.toLowerCase();
    // add a special column for the language name
    const langValues = {
      language: language.name,
    };
    // use forEach to populate the row from the root data
    nameTranslations.forEach(translation => {
      langValues[translation.english.toLowerCase()] = translation[lowerLang];
    });
    return langValues;
  });

There’s a lot going on here, so let’s take it step-by-step.

  • On line 1, we get the languages array from the service.
  • On line 2, we use filter(...) to remove the english value from the array – we don’t want a row for that because it’ll be the header.
  • On lines 6-8, we create an object to represent our row, and we initialize it with a property that holds the language name (like “Russian”).
  • On lines 10-12, we add a property to that object for each name. For example, we will add a property with the name “eugene” (calculated via translation.english.toLowerCase()) and the value “Eugenio” (calculated via translation[lowerLang]).

With this, we have transposed the data from columns to rows. We use forEach on the nameTranslations on line 10 to add a property for each name.

We will do a similar transformation to build our column definitions:

From transposed-dry.component.ts:

// add a special column for the language name
this.columnDefs = [
  {
    headerName: '',
    field: 'language',
    cellStyle: { 'font-size': 'large' },
    pinned: 'left',
  }
];

// use map, spread, and push to populate the rest of the columns
this.columnDefs.push(...nameTranslations.map(translation => {
  return {
    headerName: translation.english,
    field: translation.english.toLowerCase(),
  };
}));

ag-grid expects its column definitions to be an array, so we initialize the array with the special column that we saw in the previous version.

After that, we add a column for each set of name translations we received from the service. So, instead of repeating ourselves, we have one spot that converts each English name to the header (translation.english) and property name that ag-grid uses to populate the rows (translation.english.toLowerCase()).

Note: using push with the spread operator like this is an alternative to using forEach earlier – both approaches work well.

Transposed w/ Greeting
We now have our transposed rowData in place, and we have figured out how to create our column definitions, but what about the greetings? In the non-transposed version, we used the cellRenderer field on each column definition for this, but that was when we shared rendering across the entire column. How about when it changes from row to row within a column?

Fortunately, ag-grid has this situation covered, because the params that each cellRenderer function give us the tool that we need: the row index.

Here’s the cell renderer that we used when we weren’t transposing:

From greeting.component.ts:

cellRenderer: (params: ICellRendererParams) => `${language.greeting} ${params.value}`

Here’s the cell renderer that we can use with transposing:

From transposed-greeting.component.ts:

cellRenderer: (params: ICellRendererParams) => {
  const rowIndex = params.rowIndex;
  const language = languages[rowIndex + 1];
  return `${language.greeting} ${params.value}`;
},

This is certainly more complicated, but it’s really not that bad. In the non-transposed version, we were iterating over all the languages to produce each column, so we used the language from the iteration to get the greeting. In this case, the column isn’t associated with the language; the row is. So, we use the array of languages to get the relevant language for the row (adding 1 to cover for skipping English), and then use that language in the greeting.

This works great, but this would be a lot of code to duplicate if we were explicitly defining each column (we won’t do that by the end, since we’ve already seen how to DRY this code). Further, this code is complicated enough that it would be good to test it, but it’s not trivial to test as it’s written. We would need to get the element from the array to call the cellRenderer function on the array, but even then we would have the complication that languages isn’t passed into the function. We would need to rely on side effects because this function isn’t a pure function, which can only operate on the parameters passed into it.

A better approach would be to have a separate function that takes both of the values that we will be using: the languages and the params:

From transposed-greeting.component.ts:

function cellRenderer(languages: Language[], params: ICellRendererParams) {
  const rowIndex = params.rowIndex;
  const language = languages[rowIndex + 1];
  return `${language.greeting} ${params.value}`;
}

This is much better for testing, but how do we adapt this for ag-grid, which expects this function to have only one argument (params: ICellRendererParams)?

There are a few ways to do it (all of these examples are from transposed-greeting.component.ts).

  • The most common way is probably to call the extracted function from within the arrow function:
    {
      headerName: 'Matthew',
      field: 'matthew',
      cellRenderer: (params: ICellRendererParams) => {
        return cellRenderer(languages, params);
      },
    },
    
  • The next level is to recognize that we should be able to extract the arrow function that calls cellRenderer to a named function, which simplifies the code for passing it into ag-grid:
    {
      headerName: 'Barbara',
      field: 'barbara',
      // further extraction
      cellRenderer: cellRenderer2(languages),
    },
    
    // a function that returns a function that calls the function that does what we need
    function cellRenderer2(languages: Language[]): (params: ICellRendererParams) => string {
      return (params: ICellRendererParams) => {
        return cellRenderer(languages, params);
      };
    }
  • However, my preferred approach is to use partial application, where I convert a function that takes two arguments into a function that takes one argument through a functional programming utility. In this case, I use lodash’s partial function to “partially apply” the arguments. I can give partial the languages array, and it will use that to convert the cellRenderer function from a function that takes two arguments into a function that just takes the params argument, which is exactly what ag-grid expects.This is essentially the same as the cellRenderer2 approach, but more flexible (and I don’t have to write as much code).
    {
      headerName: 'Martha',
      field: 'martha',
      cellRenderer: partial(cellRenderer, languages),
    },
    

Transposed w/ Greeting (DRY)
Putting it all together, we now have a component that does what our manager wants: it takes the row-like data about names and the column-like data about languages from the service and dynamically converts them to the transposed form that ag-grid expects. We use partial application to convert a testable pure function that needs more information than ag-grid will give it into a form that ag-grid can use.

transposed-greeting-dry.component.ts:

import { Component, OnInit } from '@angular/core';
import { ColDef, ICellRendererParams } from 'ag-grid-community';
import { partial } from 'lodash';

import { Language, TransposedRow } from '../app.types';
import { DataService } from '../data.service';

@Component({
  selector: 'app-transposed-greeting-dry',
  templateUrl: './transposed-greeting-dry.component.html',
  styleUrls: ['./transposed-greeting-dry.component.scss']
})
export class TransposedGreetingDryComponent {
  rowData: TransposedRow[];
  columnDefs: ColDef[];

  constructor(dataSvc: DataService) {
    const nameTranslations = dataSvc.getNameTranslations();
    const languages = dataSvc.getLanguages();

    this.rowData = dataSvc.getLanguages()
      .filter((_, index) => index > 0) // we don't show english - it's the header
      .map(language => {
        const lowerLang = language.name.toLowerCase();
        // add a special column for the language name
        const langValues = {
          language: language.name,
        };
        // use forEach to populate the row from the root data
        nameTranslations.forEach(translation => {
          langValues[translation.english.toLowerCase()] = translation[lowerLang];
        });
        return langValues;
      });

    // add a special column for the language name
    this.columnDefs = [
      {
        headerName: '',
        field: 'language',
        cellStyle: { 'font-size': 'large' },
        pinned: 'left',
      }
    ];

    const cellRenderer = partial(cellRendererFn, languages);
    // use map, spread, and push to populate the rest of the columns
    this.columnDefs.push(...nameTranslations.map(translation => {
      return {
        headerName: translation.english,
        field: translation.english.toLowerCase(),
        cellRenderer
      };
    }));
  }

}

function cellRendererFn(languages: Language[], params: ICellRendererParams) {
  const rowIndex = params.rowIndex;
  const language = languages[rowIndex + 1];
  return `${language.greeting} ${params.value}`;
}