Sometimes less is more. That's why you should enable users to filter data on your Angular Material table.

Fortunately, you can do that with just a free-text input field on the UI. Then, your code can apply logic that will filter when any column matches that search term.

But sometimes you need an app that's a little more user-friendly. You'd like to enable users, for example, to filter on only a single column based on a value they select from a dropdown.

That's what I'll show you how to do here.

When all is said and done, you'll have a table that can filter on a couple of columns. It will look like this:

 

If that's what you're looking for, feel free to go straight to the code on GitHub. Or you can stay here and read the guide. 

Remember: if you're having problems running the Angular app locally, be sure to check the README.

Please note: this guide is a part of an ongoing series of tutorials on how to create a customer relationship management (CRM) application from scratch. The GitHub code for each guide belongs to a specific branch. The master branch holds the entire application up to the current date. If you want to follow the whole series, just view the careydevelopmentcrm tag. Note that the website displays the guides in reverse chronological order so if you want to start from the beginning, go to the last page.

The Business Requirements

Your boss Smithers walks into your office and shouts "HELLO!!!!" at the top of his lungs. 

He clearly had a good weekend with that lady he met at the recent speed-dating event.

Without even breaking a smile, he gives you the latest requirements: "That CRM app you're working on displays all contacts wonderfully. But what I really need now is something that allows users to filter contacts so they can easily find the people that they're looking for."

He pauses but continues to smile.

"I'm sure you can do that, right sport?"

He walks out of your office while you process the fact that he called you "sport."

Predicate vs. Alien

As I alluded to above, Angular Material tables offer an out-of-the-box filtering solution that applies to any of the columns. But that ain't gonna work here because you want to filter only on specific columns.

For that, you need something called a filter predicate.

What's a filter predicate? In this case, it's a property in the MatTableDataSource class that's more than just a simple variable assignment. It's a function.

It's called a predicate because it returns a boolean. That's what predicates do in computer science.

A filter predicate answers one simple question: should I display this row?

If the boolean is true, it displays the row. Otherwise, no.

So here's how the whole thing will ultimately work: for each row in the table, MatTableDataSource will use that filter predicate to determine if the row should be displayed or not. It will make that determination based on the filter that the user selected.

For example, let's say the user filters on only contacts discovered via email. In that case, the MatTableDataSource object will examine the criteria ("Source=Email" as in the image above) and filter out everything that doesn't have a source value of "Email."

Okay, with that in mind let's get coding.

Variables on a Theme

Let's start with a disclaimer: this guide assumes you already know how to code an Angular Material table. If not, I've got a wonderful guide on the subject that you can check out.

With that out of the way, go to your Microsoft Visual Studio IDE and edit view-contacts.component.ts. Start by adding a few new properties.

  statusFilter = new FormControl('');
  sourceFilter = new FormControl('');

  filterValues: any = {
    status: '',
    source: ''
  }

The first two properties map to a dropdown that you'll soon create in the HTML code.

The filterValues variable is an object that specifies the current filter. It defaults both filter fields to empty strings, meaning that there is no filter.

And yes, for the purposes of this guide, you're only filtering on two fields. But you can apply what you learn here to filter as many fields as you like.

If a Field Changes and Nobody Listens...

The next thing you need to do is add a couple of listeners to the form fields you saw in the previous section.

  private fieldListener() {
    this.statusFilter.valueChanges
      .subscribe(
        status => {
          this.filterValues.status = status;
          this.dataSource.filter = JSON.stringify(this.filterValues);
        }
      )
    this.sourceFilter.valueChanges
      .subscribe(
        source => {
          this.filterValues.source = source;
          this.dataSource.filter = JSON.stringify(this.filterValues);
        }
      )
  }

That's going to listen for changes to either of the dropdowns. When a change happens, the code applies the filter immediately.

In other words, what we don't have here is a solution where the user selects a bunch of criteria and then clicks an Apply Filter button. That may be useful in some situations, but I don't think it's needed for this requirement.

For each change, the code updates the filterValues object that you saw in the previous section to reflect the new filter. Then, it sets the filter property on MatTableDataSource.

We need to talk about that filter property.

It lives up to its name. It's what the MatTableDataSource object uses to eliminate rows that don't match the filter.

However, it normally filters on any column. Again, that's not what you want here.

So that's why you need the filter predicate. It gives you the power to more selectively filter.

Predicatory Behavior

Now you can create the predicate. Here's what it looks like:

  private createFilter(): (contact: Contact, filter: string) => boolean {
    let filterFunction = function (contact, filter): boolean {
      let searchTerms = JSON.parse(filter);

      return contact.status.indexOf(searchTerms.status) !== -1
        && contact.source.indexOf(searchTerms.source) !== -1;
    }

    return filterFunction;
  }

That's different than most methods in TypeScript. It's returning a function.

Remember: a predicate is a function that returns a boolean. That's exactly what you see above.

So what's goin on?

The filter gets sent in as a string. It will look something like this: "{'status': 'NEW'}". 

But remember, that's a string. It's not an object.

So you gots to make it an object. Hence the JSON.parse() thing you see above there.

Now that the code has the filter as an object, it can use the filter to determine if the current row matches.

Remember: this function gets called for every row in the table. You can do some console logging to see that.

Finally, that return statement just checks to see if the row matches based on the given criteria. 

All criteria have to match for the row to qualify for display. That's why you need to put the && between the two checks.

Also, keep in mind that an empty string as the filter will always match.

Why? Because an empty string is part of any string and the indexOf() function will always return something higher than -1.

On Clearance

It's user-friendly to give people a way to clear all filters with the click of a button. To that end, this method exists:

  clearFilter() {
    this.sourceFilter.setValue('');
    this.statusFilter.setValue('');
  }

That sets the form fields to empty string values. That, in turn, triggers the listeners to reset the filterValues object.

As a result, the app displays all rows in the table.

Just for good measure, here's what the whole component looks like when you're done with everything.

import { Component, OnInit, ViewChild } from '@angular/core';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { User } from '../../../models/user';
import { AlertService } from '../../../ui/alert/alert.service';
import { ContactService } from '../../service/contact.service';
import { UserService } from '../../service/user.service';
import { Contact } from '../models/contact';
import { DropdownOption } from '../../ui/model/dropdown-option';
import { addressTypes } from '../constants/address-type';
import { contactStatuses } from '../constants/contact-status';
import { linesOfBusiness } from '../constants/line-of-business';
import { phoneTypes } from '../constants/phone-type';
import { sources } from '../constants/source';
import { DropdownService } from '../../ui/service/dropdown.service';
import { FormControl } from '@angular/forms';


@Component({
  selector: 'app-view-contacts',
  templateUrl: './view-contacts.component.html',
  styleUrls: ['./view-contacts.component.css']
})
export class ViewContactsComponent implements OnInit {

  displayedColumns: string[] = ['lastName', 'firstName', 'status', 'title', 'company', 'source'];

  dataSource: MatTableDataSource<Contact>;
  currentUser: User;
  dataLoading: boolean = true;

  availableAddressTypes: DropdownOption[] = addressTypes;
  availablePhoneTypes: DropdownOption[] = phoneTypes;
  availableContactStatuses: DropdownOption[] = contactStatuses;
  availableLinesOfBusiness: DropdownOption[] = linesOfBusiness;
  availableSources: DropdownOption[] = sources;

  @ViewChild(MatPaginator) paginator: MatPaginator;
  @ViewChild(MatSort) sort: MatSort;

  statusFilter = new FormControl('');
  sourceFilter = new FormControl('');

  filterValues: any = {
    status: '',
    source: ''
  }

  constructor(private userService: UserService, private contactService: ContactService,
    private alertService: AlertService, private dropdownService: DropdownService) {
  }

  ngOnInit(): void {
    this.currentUser = this.userService.user;
    this.loadContacts();
    this.fieldListener();
  }

  private fieldListener() {
    this.statusFilter.valueChanges
      .subscribe(
        status => {
          this.filterValues.status = status;
          this.dataSource.filter = JSON.stringify(this.filterValues);
        }
      )
    this.sourceFilter.valueChanges
      .subscribe(
        source => {
          this.filterValues.source = source;
          this.dataSource.filter = JSON.stringify(this.filterValues);
        }
      )
  }

  clearFilter() {
    this.sourceFilter.setValue('');
    this.statusFilter.setValue('');
  }

  private createFilter(): (contact: Contact, filter: string) => boolean {
    let filterFunction = function (contact, filter): boolean {
      let searchTerms = JSON.parse(filter);

      return contact.status.indexOf(searchTerms.status) !== -1
        && contact.source.indexOf(searchTerms.source) !== -1;
    }

    return filterFunction;
  }


  private loadContacts() {
    if (this.currentUser) {
      this.contactService.fetchMyContacts()
        .subscribe(
          (contacts: Contact[]) => this.handleContacts(contacts),
          err => this.handleContactsError(err)
        );
    } else {
      this.alertService.error("Problem identifying user!");
      this.dataLoading = false;
    }
  }

  private handleContacts(contacts: Contact[]) {
    this.dataLoading = false;
    this.dataSource = new MatTableDataSource(contacts);
    this.dataSource.paginator = this.paginator;
    this.dataSource.sort = this.sort;
    this.dataSource.filterPredicate = this.createFilter();
  }

  private handleContactsError(err) {
    console.error(err);
    this.alertService.error("Problem loading contacts!");
  }
}

Hypertext Stuff

Now it's time to update the HTML. Add an expansion panel that shows the filter:

<mat-expansion-panel>
  <mat-expansion-panel-header>
    <mat-panel-title>
      Filter
    </mat-panel-title>
  </mat-expansion-panel-header>

  <div class="vertical-form-field">
    <div class="label">Source</div>
    <div>
      <mat-form-field appearance="fill" class="no-label-field">
        <mat-select [formControl]="sourceFilter">
          <mat-option value="">-- Select a Source --</mat-option>
          <mat-option *ngFor="let source of availableSources" [value]="source.value">
            {{source.display}}
          </mat-option>
        </mat-select>
      </mat-form-field>
    </div>
  </div>

  <div class="vertical-form-field">
    <div class="label">Status</div>
    <div>
      <mat-form-field appearance="fill" class="no-label-field">
        <mat-select [formControl]="statusFilter">
          <mat-option value="">-- Select a Status --</mat-option>
          <mat-option *ngFor="let status of availableContactStatuses" [value]="status.value">
            {{status.display}}
          </mat-option>
        </mat-select>
      </mat-form-field>
    </div>
  </div>

  <div>
    <button mat-raised-button color="primary" (click)="clearFilter()">Clear Filter</button>
  </div>
</mat-expansion-panel>

The first thing to notice about that panel is that it includes two dropdowns: one for source and one for status.

You'll also notice that they each map to their respective FormControl objects in the TypeScript class. That's important because each time the user changes the value of a dropdown field, the listener will trigger a new filter immediately.

Finally, the <div> element at the bottom adds a Clear Filter button. You saw the code that handles that button click in the previous section.

So now the entire HTML looks like this:

<div class="route-content">
  <div>
    <h4>View Contacts</h4>
  </div>
  <div style="margin-bottom:20px">
    <alert></alert>
  </div>
  <div class="absolute-center" *ngIf="dataLoading">
    <mat-spinner [diameter]="80"></mat-spinner>
  </div>
  <div [style.visibility]="dataLoading ? 'hidden' : 'visible'">
    <div style="margin-bottom: 30px">
      <mat-expansion-panel>
        <mat-expansion-panel-header>
          <mat-panel-title>
            Filter
          </mat-panel-title>
        </mat-expansion-panel-header>

        <div class="vertical-form-field">
          <div class="label">Source</div>
          <div>
            <mat-form-field appearance="fill" class="no-label-field">
              <mat-select [formControl]="sourceFilter">
                <mat-option value="">-- Select a Source --</mat-option>
                <mat-option *ngFor="let source of availableSources" [value]="source.value">
                  {{source.display}}
                </mat-option>
              </mat-select>
            </mat-form-field>
          </div>
        </div>

        <div class="vertical-form-field">
          <div class="label">Status</div>
          <div>
            <mat-form-field appearance="fill" class="no-label-field">
              <mat-select [formControl]="statusFilter">
                <mat-option value="">-- Select a Status --</mat-option>
                <mat-option *ngFor="let status of availableContactStatuses" [value]="status.value">
                  {{status.display}}
                </mat-option>
              </mat-select>
            </mat-form-field>
          </div>
        </div>

        <div>
          <button mat-raised-button color="primary" (click)="clearFilter()">Clear Filter</button>
        </div>
      </mat-expansion-panel>
    </div>
    <div class="example-container mat-elevation-z8">
      <table mat-table [dataSource]="dataSource" matSort>

        <ng-container matColumnDef="lastName">
          <tr><th mat-header-cell *matHeaderCellDef mat-sort-header> Last Name </th></tr>
          <tr><td mat-cell *matCellDef="let row" style="width:20%"> {{row.lastName}} </td><tr>
        </ng-container>

        <ng-container matColumnDef="firstName">
          <tr><th mat-header-cell *matHeaderCellDef mat-sort-header> First Name </th></tr>
          <tr><td mat-cell *matCellDef="let row" style="width:15%"> {{row.firstName}} </td></tr>
        </ng-container>

        <ng-container matColumnDef="status">
          <tr><th mat-header-cell *matHeaderCellDef mat-sort-header style="width:500px"> Status </th></tr>
          <tr>
            <td mat-cell *matCellDef="let row" style="width:10%">
              {{dropdownService.getDisplay(row.status, availableContactStatuses)}}
            </td>
          </tr>
        </ng-container>

        <ng-container matColumnDef="title">
          <tr><th mat-header-cell *matHeaderCellDef mat-sort-header style="width:500px"> Title </th></tr>
          <tr><td mat-cell *matCellDef="let row" style="width:15%"> {{row.title}} </td></tr>
        </ng-container>

        <ng-container matColumnDef="company">
          <tr><th mat-header-cell *matHeaderCellDef mat-sort-header style="width:500px"> Company </th></tr>
          <tr><td mat-cell *matCellDef="let row" style="width:15%"> {{row.company}} </td></tr>
        </ng-container>

        <ng-container matColumnDef="source">
          <tr><th mat-header-cell *matHeaderCellDef mat-sort-header> Source </th></tr>
          <tr>
            <td mat-cell *matCellDef="let row" [style.color]="row.color" style="width:25%">
              {{dropdownService.getDisplay(row.source, availableSources)}}
            </td>
          </tr>
        </ng-container>

        <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
        <tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
      </table>
    </div>
    <mat-paginator [pageSizeOptions]="[5, 10, 25, 100]"></mat-paginator>
  </div>
</div>

How Do You Know?

Now it's time to test out this code to make sure it works.

Launch your Spring Boot applications: the contact service and the user service.

Then, launch the Angular CRM app. Head over to http://localhost:4200/login and login with the usual credentials (darth/thedarkside).

Navigate to Contacts and View Contacts. You should see the expansion element. By default it's closed.

 

Open up the expansion area by clicking on the twistie. In the Source dropdown, select Email.

 

Once that filter is applied, you should see a familiar result:

 

Now add to the filter by selecting an item from Status.

When you're satisfied that works, click the Clear Filter button and the app should display every row.

Wrapping It Up

And now you know how to use filter predicates in an Angular Material table.

Now it's your turn to update the code to meet your own business requirements.

Add new filters. Create a responsive filter section. Or maybe even add filters directly into column headers.

It's up to you.

As always, feel free to grab the code on GitHub on start working from there.

Have fun!

Photo by Blake Richard Verdoorn on Unsplash