Sort & Filter Data Tables in Angular

Fri Apr 9, 2021

No comments 19265 reads

Angular / Tutorials

armin-zia

Sort & Filter Data Tables in Angular

In this post, I'll show you how to sort and filter data tables in Angular applications. We'll create a pipe for filtering data, and an attribute directive for sorting tables by columns.

There are many free and commercial libraries out there with full-fledged data tables and advanced features. If you have a good reason for using a 3rd party solution or spending a ton of time developing your own components, that's fine, but oftentimes you only need basic features like sorting, filtering, and pagination, all of which can be achieved with JavaScript and the flexibility that Angular provides.

We're going to build a simple app that displays a list of countries and enables you to filter the results by name or capital city. Additionally, we're going to create an attribute directive for sorting table headers in ascending and descending order. The end result is going to look like this:

Sort & filter data tables in Angular

Getting Started

Let's start by creating a new Angular project. Make sure you have Node.js, NPM, and Angular CLI installed. I'm using the following versions:

  • node v12.19.0
  • npm v6.14.8
  • ng v11.2.6

Open a terminal (PowerShell, Command-Prompt) and run:

ng new ng-table-sort-and-filter --style=scss --routing=false

This will create a new project named ng-table-sort-and-filter with SCSS styling and no routing. I'm going to use Bootstrap for UI components, install that too:

npm install --save bootstrap

Next, open src/styles.scss and import Bootstrap:

@import "~bootstrap/scss/bootstrap";

Alternatively, instead of importing the SCSS file, you could import the CSS in angular.json:

"styles": [
  "node_modules/bootstrap/dist/css/bootstrap.min.css",
  "src/styles.scss"
]

Open the index.html file and replace it with the following:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <title>Sort & Filter Data Tables in Angular</title>
  </head>
  <body>
    <div class="container">
      <main role="main">
        <app-root>loading...</app-root>
      </main>
    </div>
  </body>
</html>

A basic Bootstrap layout, wrapping the <app-root> component in a container. Finally, open the app.module.ts file and import the FormsModule:

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';

import { AppComponent } from './app.component';

@NgModule({
  declarations: [AppComponent],
  imports: [BrowserModule, FormsModule],
  providers: [],
  bootstrap: [AppComponent],
})
export class AppModule {}

Great, we've set up our project. Continuing with the data table.

The Dataset

To keep things simple, we're going to define a JSON data collection to use in our table. I've taken the data from REST Countries, a free (and fun) API for getting information about countries, make sure to check it out. In a real-world application, the data would come from your database, an API call, etc. Create a file named data.ts under the src/app directory and paste the following code:

export interface Country {
  name: string;
  capital: string;
  alpha3Code: string;
  population: number;
  flag: string;
}

export const dataset: Array<Country> = [
  {
    name: 'Iran',
    capital: 'Tehran',
    alpha3Code: 'IRN',
    population: 79369900,
    flag: 'https://restcountries.eu/data/irn.svg',
  },
  {
    name: 'Turkey',
    capital: 'Ankara',
    alpha3Code: 'TUR',
    population: 78741053,
    flag: 'https://restcountries.eu/data/tur.svg',
  },
  {
    name: 'Germany',
    capital: 'Berlin',
    alpha3Code: 'DEU',
    population: 81770900,
    flag: 'https://restcountries.eu/data/deu.svg',
  },
  {
    name: 'France',
    capital: 'Paris',
    alpha3Code: 'FRA',
    population: 66710000,
    flag: 'https://restcountries.eu/data/fra.svg',
  },
  {
    name: 'Italy',
    capital: 'Rome',
    alpha3Code: 'ITA',
    population: 60665551,
    flag: 'https://restcountries.eu/data/ita.svg',
  },
  {
    name: 'Serbia',
    capital: 'Belgrade',
    alpha3Code: 'SRB',
    population: 7076372,
    flag: 'https://restcountries.eu/data/srb.svg',
  },
  {
    name: 'United Kingdom',
    capital: 'London',
    alpha3Code: 'GBR',
    population: 65110000,
    flag: 'https://restcountries.eu/data/gbr.svg',
  },
  {
    name: 'United States',
    capital: 'Washington D.C',
    alpha3Code: 'USA',
    population: 323947000,
    flag: 'https://restcountries.eu/data/usa.svg',
  },
  {
    name: 'Russia',
    capital: 'Moscow',
    alpha3Code: 'RUS',
    population: 146599183,
    flag: 'https://restcountries.eu/data/rus.svg',
  },
  {
    name: 'China',
    capital: 'Beijing',
    alpha3Code: 'CHN',
    population: 1377422166,
    flag: 'https://restcountries.eu/data/chn.svg',
  },
  {
    name: 'India',
    capital: 'New Delhi',
    alpha3Code: 'IND',
    population: 1295210000,
    flag: 'https://restcountries.eu/data/ind.svg',
  },
];

We're exporting an interface named Country to strongly type the data:

export interface Country {
  name: string;
  capital: string;
  alpha3Code: string;
  population: number;
  flag: string;
}

Then we're exporting a constant named dataset with a hard-coded collection of countries. Now we can display this data in a table.

Displaying the data in a table

First, open app.component.ts and import the data:

import { Component } from '@angular/core';
import { Country, dataset } from './data';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss'],
})
export class AppComponent {
  countries: Array<Country> = dataset;
}

We've imported the Country interface and the dataset collection from data.ts, and we've defined a property named countries set to the data collection.

Next, Open app.component.html and replace it with the following template:

<div class="row mt-3">
  <div class="col">
    <h1>Sort & Filter Data Tables in Angular</h1>

    <div class="table-responsive">
      <table class="table table-hover">
        <thead class="thead-light">
          <tr>
            <th>Name</th>
            <th width="150">Capital</th>
            <th class="text-center" width="100">A3 Code</th>
            <th class="text-center" width="150">Population</th>
          </tr>
        </thead>
        <tbody>
          <tr *ngFor="let country of countries">
            <td>
              <img [src]="country.flag" alt="flag" width="30" height="20">
              {{ country.name }}
            </td>
            <td>{{ country.capital }}</td>
            <td class="text-center">{{ country.alpha3Code }}</td>
            <td class="text-center">{{ country.population | number }}</td>
          </tr>
        </tbody>
      </table>
    </div>
  </div>
</div>

Nothing special here, we have a table element styled with Bootstrap classes. Using the ngFor directive, we iterate through the collection and render a row for each country. If you run the app using npm start you should see something like this:

Displaying the data in a table

Cool, so we have our data table. Now let's see how we can filter the list with an input.

Filtering results

We want to display an input element where you enter the country name or the capital city and filter the table as you type. We're going to need a Pipe directive for that. If you're not familiar with pipes in Angular, make sure to have a read on the official docs. Basically, pipes are TypeScript classes used to transform data. You give them some input, transform it however you want, and return a result.

Create a file named country.pipe.ts under the src/app directory and paste the following code:

import { Pipe, PipeTransform } from '@angular/core';
import { Country } from './data';

@Pipe({ name: 'country' })
export class CountryPipe implements PipeTransform {
  transform(values: Country[], filter: string): Country[] {
    if (!filter || filter.length === 0) {
      return values;
    }

    if (values.length === 0) {
      return values;
    }

    return values.filter((value: Country) => {
      const nameFound =
        value.name.toLowerCase().indexOf(filter.toLowerCase()) !== -1;
      const capitalFound =
        value.capital.toLowerCase().indexOf(filter.toLowerCase()) !== -1;

      if (nameFound || capitalFound) {
        return value;
      }
    });
  }
}

Pipes are simple classes that implement the PipeTransform interface. We've named our pipe country, that's the keyword we're going to use in our template. We've also imported the Country interface to get intellisense support and strong typing. The PipeTransform interface has a single method you need to implement, the transform method. The values argument is the data we want to transform, passed in from our template, and the filter argument is the search term coming from the input field. You notice that the transform method returns an array of Country objects.

You can pass multiple arguments to pipes, or none at all. If you need a pipe to transform the data without an input argument, you just define the values argument in the transform method.

The first thing we do is check whether we have a filter value or not. If the filter argument is undefined or empty, we return the original values, nothing happens. Next, we check whether the values collection is empty or not. If so, we return the original values, again, nothing happens.

Lastly, if we do have a filter and a collection of values, we filter the results using the JavaScript filter function. We want to find matching results by the country name, or the capital city. So we define two variables for each. We transform the values to lowercase and check whether the country name or capital city includes the search term. If either is found, we return the object.

Before we can use this pipe, we need to import it in the app module, so open app.module.ts and add it to the declarations array:

// other imports
import { CountryPipe } from './country.pipe';

@NgModule({
  declarations: [AppComponent, CountryPipe],
  // ...
})
export class AppModule {}

Next, open app.component.ts and define the filter property:

import { Component } from '@angular/core';
import { Country, dataset } from './data';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss'],
})
export class AppComponent {
  filter: string;
  countries: Array<Country> = dataset;
}

And lastly, open app.component.html and add the input field, and apply the pipe to the table:

<div class="row mt-3">
  <div class="col">
    <h1>Sort & Filter Data Tables in Angular</h1>

    <div class="form-group">
      <input type="search" class="form-control" [(ngModel)]="filter" placeholder="Filter by name or capital">
    </div>

    <div class="table-responsive">
      <table class="table table-hover">
        <thead class="thead-light">
          <tr>
            <th>Name</th>
            <th width="150">Capital</th>
            <th class="text-center" width="100">A3 Code</th>
            <th class="text-center" width="150">Population</th>
          </tr>
        </thead>
        <tbody>
          <tr *ngFor="let country of countries | country:filter">
            <td>
              <img [src]="country.flag" alt="flag" width="30" height="20">
              {{ country.name }}
            </td>
            <td>{{ country.capital }}</td>
            <td class="text-center">{{ country.alpha3Code }}</td>
            <td class="text-center">{{ country.population | number }}</td>
          </tr>
        </tbody>
      </table>
    </div>
  </div>
</div>

The input field is bound to the filter property using two-way data binding. In the <tbody> section of our table where we render countries using ngFor, we're applying our pipe on the data source, the countries property. So the countries collection is passed to our pipe as the values argument, and the filter argument is whatever we have in the input field. Run the app again and you should see this:

Table Filter UI

Type in a country name or a capital city and you should see the filtered results:

Table Filter by united

Brilliant, now we can filter the table. The pipe logic could be anything you need, depends on your data structure and what you want to return as the result. For instance, you could break the filter string into words, and search for multiple keywords. I leave that to you to have fun with this example. Moving on with the sorting functionality.

Sorting by headers

When users click a table header, what we want to do is, sort the data based on the clicked property. You could wire up the headers to click handlers, and sort the table using simple properties and functions. But that's no good, we need a reusable mechanism. We'll create an attribute directive for that. Create a file named sortable-header.directive.ts under the src/app directory. Here's the complete code:

import { Directive, EventEmitter, Input, Output } from '@angular/core';
import { Country } from './data';

export type SortColumn = keyof Country | '';
export type SortDirection = 'asc' | 'desc' | '';

const rotate: { [key: string]: SortDirection } = {
  asc: 'desc',
  desc: '',
  '': 'asc',
};

export const compare = (
  v1: string | number | boolean | Date,
  v2: string | number | boolean | Date
) => (v1 < v2 ? -1 : v1 > v2 ? 1 : 0);

export interface SortEvent {
  column: SortColumn;
  direction: SortDirection;
}

@Directive({
  selector: 'th[sortable]',
  host: {
    '[class.asc]': 'direction === "asc"',
    '[class.desc]': 'direction === "desc"',
    '(click)': 'rotate()',
  },
})
export class SortableHeaderDirective {
  @Input() sortable: SortColumn = '';
  @Input() direction: SortDirection = '';
  @Output() sort = new EventEmitter<SortEvent>();

  rotate() {
    this.direction = rotate[this.direction];
    this.sort.emit({ column: this.sortable, direction: this.direction });
  }
}

Let's go through the code and see what we're doing here. The directive selector is th[sortable], which means you attach the directive to table headers like this:

<table class="table table-hover">
  <thead class="thead-light">
    <tr>
      <th sortable="name">Name</th>
      <!-- other headers -->
    </tr>
  </thead>
  <tbody>
    <!-- table body goes here -->
  </tbody>
</table>

The snippet above means when we click on the header, we want to sort by the name property. You also notice the host property on the directive:

@Directive({
  selector: 'th[sortable]',
  host: {
    '[class.asc]': 'direction === "asc"',
    '[class.desc]': 'direction === "desc"',
    '(click)': 'rotate()',
  },
})
export class SortableHeaderDirective {}

Using the host property, we can change the host element that the directive is attached to. We're adding CSS classes to the host element, based on the direction property. When the direction is asc, we set the asc class, and when the direction is desc, we set the desc class. We've also wired up a click handler. When the host element (table header) is clicked, we call the rorate function.

Now let's see how this directive actually sorts the data. At the top, we're exporting two types: SortColumn and SortDirection. The SortColumn can be any key in the Country interface or an empty string. And the SortDirection can be an empty string, asc, or desc. Simple enough.

The directive defines two inputs and an output property:

export class SortableHeaderDirective {
  @Input() sortable: SortColumn = '';
  @Input() direction: SortDirection = '';
  @Output() sort = new EventEmitter<SortEvent>();
  // ...
}

The sortable input is the property name we pass in from the template. So like the snippet you saw above, if we want to sort the header by the name property, we write sortable="name". The direction input defines how we want to sort the data when a particular header is clicked.

The sort output property is an EventEmitter of type SortEvent, the interface is defined this way:

export interface SortEvent {
  column: SortColumn;
  direction: SortDirection;
}

Whenever a header is clicked, we emit an event with the column that was clicked and the direction value as its parameters. Remember that when the host element is clicked, we call the rotate function:

rotate() {
  this.direction = rotate[this.direction];
  this.sort.emit({ column: this.sortable, direction: this.direction });
}

Inside the rotate function, we update the direction value and emit the sort event. The parent component receives the sort event, and we actually sort the data there. This is good because we're not encapsulating the sorting logic inside the directive, we're just defining a reusable UI behavior. Updating the direction is easy, we have a constant named rotate which is of type SortDirection and accepts a string key as the argument:

const rotate: { [key: string]: SortDirection } = {
  asc: 'desc',
  desc: '',
  '': 'asc',
};

Based on the key value, we rotate the direction for the next click. So far so good. Lastly, we have the compare function which is exported and used by the parent component for sorting data:

export const compare = (
  v1: string | number | boolean | Date,
  v2: string | number | boolean | Date
) => (v1 < v2 ? -1 : v1 > v2 ? 1 : 0);

Nothing fancy going on, just standard JavaScript. The compare function accepts two values as arguments. The values can be strings, numbers, or dates. Recall that the SortColumn type is a key of our Country interface. Based on what you're trying to sort, you'd define the acceptable data types in the compare function.

Now that we have a reusable directive for sorting, we need to update our data table and implement an event handler for the sort event.

Before we can use this directive we need to import it into the app module. Open app.module.ts and replace it with the following:

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';

import { AppComponent } from './app.component';
import { CountryPipe } from './country.pipe';
import { SortableHeaderDirective } from './sortable-header.directive';

@NgModule({
  declarations: [AppComponent, CountryPipe, SortableHeaderDirective],
  imports: [BrowserModule, FormsModule],
  providers: [],
  bootstrap: [AppComponent],
})
export class AppModule {}

Open app.component.html and replace the <thead> element with the following:

<thead class="thead-light">
  <tr>
    <th scope="col" sortable="name" (sort)="onSort($event)">Name</th>
    <th scope="col" sortable="capital" (sort)="onSort($event)" width="150">Capital</th>
    <th scope="col" sortable="alpha3Code" (sort)="onSort($event)" class="text-center" width="100">A3 Code</th>
    <th scope="col" sortable="population" (sort)="onSort($event)" class="text-center" width="150">Population</th>
  </tr>
</thead>

We've attached the sortable directive to each header, passing in the property name we want to sort by. Each header defines an event handler for the sort event too.

Now we need to implement the sorting behavior. Open app.component.ts and update the imports:

import { Component, QueryList, ViewChildren } from '@angular/core';
import {
  SortableHeaderDirective,
  SortEvent,
  compare,
} from './sortable-header.directive';

We've imported QueryList and ViewChildren from Angular, and some exported members from our directive. Next, we need a copy of the data to bind our table to. We can't bind to the same data source, because when you sort and filter the results, there's no way of going back to the original dataset. So before the countries property, define the data property set to the countries collection:

data: Array<Country> = dataset;
countries: Array<Country> = dataset;

Next, we need to find all occurrences of our directive in the template, so define the headers property:

@ViewChildren(SortableHeaderDirective)
headers: QueryList<SortableHeaderDirective>;

We're using the @ViewChildren directive to find all instances of our directive. The QueryList<T> finds all instances of the provided type in your template, very handy. Great, so the headers property would give us a collection of SortableHeader directives which we have defined on the <thead> element.

The Sort Function

Whenever a header is clicked we receive a SortEvent with the column and direction values as the payload. Add the onSort function to the app component:

onSort({ column, direction }: SortEvent) {
  // resetting other headers
  this.headers.forEach((header) => {
    if (header.sortable !== column) {
      header.direction = '';
    }
  });

  // sorting countries
  if (direction === '' || column === '') {
    this.countries = this.data;
  } else {
    this.countries = [...this.data].sort((a, b) => {
      const res = compare(a[column], b[column]);
      return direction === 'asc' ? res : -res;
    });
  }
}

The first thing we want to do is reset the other headers. So we iterate through the headers collection and reset the direction.

If the direction value or the column name is empty, we reset the data to the original dataset. So the table displays the data in its original format.

Otherwise, we use the JavaScript sort function to sort the data. Inside the sort function, we use the compare function defined in our directive to compare the values by the clicked property.

Styling the headers

Remember those CSS classes we set on the host element? We need to define the CSS rules. Open src/styles.scss and add the following:

/* sortable header directive */
th[sortable] {
  cursor: pointer;
  user-select: none;
  -webkit-user-select: none;
}

th[sortable].desc:before,
th[sortable].asc:before {
  content: "";
  display: block;
  background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAmxJREFUeAHtmksrRVEUx72fH8CIGQNJkpGUUmakDEiZSJRIZsRQmCkTJRmZmJgQE0kpX0D5DJKJgff7v+ru2u3O3vvc67TOvsdatdrnnP1Y///v7HvvubdbUiIhBISAEBACQkAICAEhIAQ4CXSh2DnyDfmCPEG2Iv9F9MPlM/LHyAecdyMzHYNwR3fdNK/OH9HXl1UCozD24TCvILxizEDWIEzA0FcM8woCgRrJCoS5PIwrANQSMAJX1LEI9bqpQo4JYNFFKRSvIgsxHDVnqZgIkPnNBM0rIGtYk9YOOsqgbgepRCfdbmFtqhFkVEDVPjJp0+Z6e6hRHhqBKgg6ZDCvYBygVmUoEGoh5JTRvIJwhJo1aUOoh4CLPMyvxxi7EWOMgnCGsXXI1GIXlZUYX7ucU+kbR8NW8lh3O7cue0Pk32MKndfUxQFAwxdirk3fHappAnc0oqDPzDfGTBrCfHP04dM4oTV8cxr0SVzH9FF07xD3ib6xCDE+M+aUcVygtWzzbtGX2rPBrEUYfecfQkaFzYi6HjVnGBdtL7epqAlc1+jRdAap74RrnPc4BCijttY2tRcdN0g17w7HqZrXhdJTYAuS3hd8z+vKgK3V1zWPae0mZDMykadBn1hTQBLnZNwVrJpSe/NwEeDsEwCctEOsJTsgxLvCqUl2ACftEGvJDgjxrnBqkh3ASTvEWrIDQrwrnJpkB3DSDrGW7IAQ7wqnJtkBnLRztejXXVu4+mxz/nQ9jR1w5VB86ejLTFcnnDwhzV+F6T+CHZlx6THSjn76eyyBIOPHyDakhBAQAkJACAgBISAEhIAQYCLwC8JxpAmsEGt6AAAAAElFTkSuQmCC")
    no-repeat;
  background-size: 16px;
  width: 16px;
  height: 16px;
  float: left;
  margin-left: -10px;
  margin-top: 4px;
}

th[sortable].desc:before {
  transform: rotate(180deg);
  -ms-transform: rotate(180deg);
}

We want our headers to have a pointer cursor indicating that they're clickable. Both asc and desc classes use a Base64 string as the icon, a simple arrow. The desc class rotates the element 180 degrees for an inverted arrow. If you don't want to style the headers using CSS and instead want to use font libraries like FontAwesome or Bootstrap Icons, you can define a template for the directive and based on the direction value, render an icon. Easy to do, I'll leave that to you to have fun with it.

That's it, we have a simple sort and filtering implementation. Most UI libraries like Bootstrap or NG-ZORRO provide you with UI components for pagination and tables. For most applications, you can use simple UI components and simple JavaScript to implement sort and filtering, pagination, and whatnot. You could easily develop a table component supporting all kinds of advanced features.

Source Code

I've put up a live demo on StackBlitz for you to play with. You can find the project on StackBlitz or get the complete code on Github. Happy coding 🐉.

Updated at Fri Apr 9, 2021


Posted in Angular

Tagged Tutorials


Print
Armin Zia
Author

Armin Zia

Software Engineer

Leave a reply


Comment body cannot be empty 🐲