Building a Generic Mat Table component in Angular

Building a Generic Mat Table component in Angular

You now have everything needed to create your own generic table with Angular Material. Just be creative, and you’ll build a powerful table to reuse in every project.

In this guide, I’ll show you simply how to create a generic table with Angular Material.

First and foremost, it’s important you understand the decorator concept in Typescript and how MatTable works.

Originally, the concept was thought up by my colleague and friend Francisco, and I just reworked his idea to extend it more easily with more options.

At the end, you’ll have a model to manage how to display your data in your table, like this:

import { Column } from "../decorators/column";
import { autoserializeAs } from "cerialize";

export class Car {
  @autoserializeAs(Number)
  id: number;
  @autoserializeAs(String)
  @Column()
  maker: string;
  @autoserializeAs(String)
  @Column({
    order: 1,
    canSort: true,
  })
  model: string;
  @autoserializeAs(Number)
  @Column({
    canSort: true,
  })
  year: number;
}

gmt_car.model.v4.ts

Sometimes our application contains several tables that we have to manage, and the more we have, the more maintenance becomes heavy. However, time is an important factor in a developer’s life.

Rather than creating a library to maintain, I explain step by step how you can create your own generic table and extend it with some examples. Like this, you can extend everything following your needs.

Step 0: Install the Necessary node_modules

Required:

  • @angular/material (ng add @angular/material)
  • reflect-metadata(to access metadata properties)

Optional:

  • lodash (set of handy functions)
  • cerialize (a powerful tool to map JSON properties in a JS prop class. To install it, please use [email protected]instead of cerialize)

Step 1: Prepare Your Table Component

Let’s begin by creating our car model:

export class Car {
  id: number;
  maker: string;
  model: string;
  year: number;
}

gmt_car.model.v1.ts

To mock the data, I chose Mockaroo, created the JSON file, put it in my assets folder, and then in my app.component.ts, I typed the following:

import { Component, OnInit } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { Car } from "./resources";
import { Observable } from "rxjs";
import { DeserializeArray } from "cerialize";
import { JsonArray } from 'cerialize/dist/util';

@Component({
  selector: "app-root",
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.scss"],
})
export class AppComponent implements OnInit {
  cars$: Observable<Car[]>;

  constructor(private http: HttpClient) {}

  ngOnInit() {
    this.cars$ = this.http.get("/assets/car.json").pipe(
      map((res: JsonArray) => DeserializeArray(res, Car)),
      tap(res => console.log(res))
    );
  }
}

gmt_app.component.ts

Now, we’re ready to create our table component. Generate the component using the CLI — then your code should look like this:

<table mat-table [dataSource]="data">
  <ng-container [matColumnDef]="column" *ngFor="let column of columns">
    <th mat-header-cell *matHeaderCellDef>{{ column }}</th>
    <td mat-cell *matCellDef="let element">{{ element[column] }}</td>
  </ng-container>

  <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
  <tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>

gmt_table.component.v1.html

import { Component, OnInit, Input } from "@angular/core";
import { TableModel } from "./../decorators/table.model";
import { sortBy } from "lodash";

@Component({
  selector: "app-table",
  templateUrl: "./table.component.html",
  styleUrls: ["./table.component.scss"],
})
export class TableComponent implements OnInit {
  private _data: any[];

  @Input() set data(values: any[]) { // Because AsyncPipe is used
    if (values) {
      this._data = cloneDeep(values);
    }
  }
  get data(): any[] {
    return this._data;
  }

  displayedColumns: string[];

  constructor() {}

  ngOnInit() {}
}

gmt_table.component.v1.ts

Finally, call your table component in the app.component.html:

<app-table [data]="cars$ | async"></app-table>

gmt_app.component.html

Step 2: The Decorator Column

import { autoserializeAs } from "cerialize";

export class Car {
  @autoserializeAs(Number)
  id: number;
  @autoserializeAs(String)
  @Column()
  maker: string;
  @autoserializeAs(String)
  @Column()
  model: string;
  @autoserializeAs(Number)
  @Column()
  year: number;
}

gmt_car.model.v2.ts

autoserializeAs is used by cerializeto automatically bind the properties from a JSON file to a JS object instance. You just need to provide the type. See the docs.

Column is the decorator that’ll allow us to select the properties of an object we want to display in our table. Later, we’ll see we’ll be able to add options that’ll allow us to manage the behavior of a property.

Before creating our decorator, it would be interesting to have two classes:

  • One to manage all our columns (TableModel)
  • One to manage all the options for a specific column (ColumnModel)
import { ColumnModel } from "./column.model";
export class TableModel {
  columns: ColumnModel[] = [];

  addColumn(column: ColumnModel) {
    this.columns = [...this.columns, column];
  }
}

gmt_table.model.ts

export class ColumnModel {
  /** List of options */
  key: string;

  constructor(options: Partial<ColumnModel> = {}) {
    this.key = options.key;
  }
}

gmt_column.model.v1.ts

Now, we have everything to build our decorator.

import { ColumnModel } from "./column.model";
import { TableModel } from "./table.model";

export const tableSymbol = Symbol("table");

export function Column(options: Partial<ColumnModel> = {}) {
  return function(target: any, propertyKey: string) {
    if (!target[tableSymbol]) {
      target[tableSymbol] = new TableModel();
    }
    options.key = options.key || propertyKey;
    const columnOptions = new ColumnModel(options);
    target[tableSymbol].addColumn(columnOptions);
  };
}

gmt_column.v1.ts

What happens here? First, we create a symbol we name table (it’s just for debugging — you can call it as you wish). This symbol will be in every instance of the object and allow us to access, by reference, the instance of TableModel for that object.

We check if an instance of TableModel exists — otherwise, we create it.

Then we build our ColumnModel instance with the options we get from the decorator.

Step 3: Retrieve Columns in Our Generic Table

import { Component, OnInit, Input } from "@angular/core";
import { tableSymbol } from "../decorators/column";
import { ColumnModel } from "./../decorators/column.model";
import { TableModel } from "./../decorators/table.model";
import { cloneDeep } from "lodash";

@Component({
  selector: "app-table",
  templateUrl: "./table.component.html",
  styleUrls: ["./table.component.scss"],
})
export class TableComponent implements OnInit {
  private _data: any[];
  private _tableModel: TableModel;

  @Input() set data(values: any[]) {
    if (values) {
      this._data = cloneDeep(values);
      this._tableModel = this._data[0][tableSymbol];
      this.buildColumns();
    }
  }
  get data(): any[] {
    return this._data;
  }

  columns: ColumnModel[];
  displayedColumns: string[];

  constructor() {}

  ngOnInit() {}

  private buildColumns() {
    this.columns = this._tableModel.columns;
    this.displayedColumns = this.columns.map(col => col.key);
  }
}

gmt_table.component.v2.ts

As we saw in our AppComponent, I created a new instance of my car object. By doing that, I triggered the decorator, so I created my TableModel that contains all the information of the columns to display in my table.

In my TableComponent, I can retrieve an instance from the list of data, and through the symbol, the instance of the TableModel. It only remains to create our columns, and then you’ll have a generic table by model.

Pay attention to adapt the HTML as well. column is now an object, not a string. column.key is the right property to use, as it represents the propertyKey in our decorator (or the custom one you provided).

Going Further

Example 1: Manage the order of our columns

For now, columns are displayed following the order of the properties defined in your model. It could be interesting, in some cases, to change this order.

export class ColumnModel {
  /** List of options */
  key: string;
  order: number;

  constructor(options: Partial<ColumnModel> = {}) {
    this.key = options.key;
    this.order = options.order || 0;
  }
}

gmt_column.model.v2.ts

import { Column } from "../decorators/column";
import { autoserializeAs } from "cerialize";

export class Car {
  @autoserializeAs(Number)
  id: number;
  @autoserializeAs(String)
  @Column()
  maker: string;
  @autoserializeAs(String)
  @Column({
    order: 1,
  })
  model: string;
  @autoserializeAs(Number)
  @Column()
  year: number;
}

gmt_car.model.v3.ts

Now, we’ve explicitly stated all columns have an order of 0 — except the model, which has an order of 1. This column should therefore be in the last position. It only remains to create a small method to sort the columns in our TableComponent.

import { Component, OnInit, Input } from "@angular/core";
import { tableSymbol } from "../decorators/column";
import { ColumnModel } from "./../decorators/column.model";
import { TableModel } from "./../decorators/table.model";
import { sortBy } from "lodash";

...

  constructor() {}

  ngOnInit() {}

  private buildColumns() {
    this.columns = this._tableModel.columns;
    this.sortColumns(); // Sort columns by order
    this.displayedColumns = this.columns.map(col => col.key);
  }

  private sortColumns() {
    this.columns = sortBy(this.columns, ["order"]);
  }
}

gmt_table.component.v3.ts

That’s it.

Example 2: Get the type of property

This example can be interesting if you want to know the type of property that you’ll use in the table. If we detect a date, it’d be interesting to be able to put it in a format. Or if it’s a number, it’d be nice to align it to the right by default — it’s up to you!

Be sure to enable these two options in your tsconfig.json:

experimentalDecorators: true

emitDecoratorMetadata: true

import { ColumnModel } from "./column.model";
import { TableModel } from "./table.model";
import "reflect-metadata";

export const tableSymbol = Symbol("column");

export function Column(options: Partial<ColumnModel> = {}) {
  return function(target: any, propertyKey: string) {
    ...

    // Get the type of the property
    const propType = Reflect.getMetadata("design:type", target, propertyKey);
    options.propertyType = propType.name;

    ...
  };
}

gmt_column.v2.ts

export class ColumnModel {
  /** List of options */
  key: string;
  order: number;
  propertyType: string;

  constructor(options: Partial<ColumnModel> = {}) {
    this.key = options.key;
    this.order = options.order || 0;
    this.propertyType = options.propertyType;
  }
}

gmt_column.model.v3.ts

Example 3: Choose columns you want to sort

By default, we have no sort enabled for our table. That’s not a problem — we just need to adapt our table.

Let’s add this new option:

export class ColumnModel {
  /** List of options */
  key: string;
  order: number;
  propertyType: string;
  canSort: boolean;

  constructor(options: Partial<ColumnModel> = {}) {
    this.key = options.key;
    this.order = options.order || 0;
    this.propertyType = options.propertyType;
    this.canSort = options.canSort || false;
  }
}

gmt_column.model.v4.ts

import { Column } from "../decorators/column";
import { autoserializeAs } from "cerialize";

export class Car {
  @autoserializeAs(Number)
  id: number;
  @autoserializeAs(String)
  @Column()
  maker: string;
  @autoserializeAs(String)
  @Column({
    order: 1,
    canSort: true,
  })
  model: string;
  @autoserializeAs(Number)
  @Column({
    canSort: true,
  })
  year: number;
}

gmt_car.model.v4.ts

Now we know which column can be sorted. Finally, we adapt our TableComponent (TS and HTML):

import { Component, OnInit, Input } from "@angular/core";
import { tableSymbol } from "../decorators/column";
import { ColumnModel } from "./../decorators/column.model";
import { TableModel } from "./../decorators/table.model";
import { sortBy, orderBy, cloneDeep } from "lodash";
import { Sort, SortDirection } from "@angular/material/sort";

@Component({
  selector: "app-table",
  templateUrl: "./table.component.html",
  styleUrls: ["./table.component.scss"],
})
export class TableComponent implements OnInit {
  private _data: any[];
  private _originalData: any[] = [];
  private _tableModel: TableModel;

  @Input() set data(values: any[]) {
    if (values) {
      this._data = cloneDeep(values);
      this._tableModel = this._data[0][tableSymbol];
      this.buildColumns();
      if (!this._originalData.length) {
        // Keep original order of data
        this._originalData = cloneDeep(this._data);
      }
    }
  }
  get data(): any[] {
    return this._data;
  }

  columns: ColumnModel[];
  displayedColumns: string[];

  constructor() {}

  ngOnInit() {}

  sortData(params: Sort) {
    const direction: SortDirection = params.direction;
    this.data = direction
      ? orderBy(this.data, [params.active], [direction])
      : this._originalData;
  }

  private buildColumns() {
    this.columns = this._tableModel.columns;
    this.sortColumns();
    this.displayedColumns = this.columns.map(col => col.key);
  }

  private sortColumns() {
    this.columns = sortBy(this.columns, ["order"]);
  }
}

gmt_table.component.v4.ts

<table mat-table [dataSource]="data" matSort (matSortChange)="sortData($event)">
  <ng-container [matColumnDef]="column.key" *ngFor="let column of columns">
    <ng-container *ngIf="column.canSort; else noSort">
      <th mat-header-cell *matHeaderCellDef mat-sort-header="{{ column.key }}">
        {{ column.key }}
      </th>
    </ng-container>
    <ng-template #noSort>
      <th mat-header-cell *matHeaderCellDef>{{ column.key }}</th>
    </ng-template>
    <td mat-cell *matCellDef="let element">{{ element[column.key] }}</td>
  </ng-container>

  <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
  <tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>

gmt_table.component.v2.html

Check the TableComponent TS file. In the setter of data, we keep the original order of our data. It’ll be useful in our new method, sortData.

Don’t forget to adapt the HTML as well. We can create a simple ngIfElse to add the sort feature for that column.

Conclusion

You now have everything needed to create your own generic table with Angular Material. Just be creative, and you’ll build a powerful table to reuse in every project.

“I choose a lazy person to do a hard job. Because a lazy person will find an easy way to do it.” — Bill Gates

Here is the StackBlitz of the project, in case you want to play with it

Angular 9 Tutorial: Learn to Build a CRUD Angular App Quickly

What's new in Bootstrap 5 and when Bootstrap 5 release date?

Brave, Chrome, Firefox, Opera or Edge: Which is Better and Faster?

How to Build Progressive Web Apps (PWA) using Angular 9

What is new features in Javascript ES2020 ECMAScript 2020

Migrating from AngularJS to Angular

Migrating from AngularJS to Angular a hybrid system architecture running both AngularJS and Angular

What is the difference between JavaScript and AngularJS?

JavaScript is a client-side programming language used for creating dynamic websites and apps to run in the client's browser whereas AngularJS is a fully featured web app framework established on JavaScript and managed by Google.

What’s the difference between AngularJS and Angular?

Angular vs Angularjs - key differences, performance, and popularity