viernes, 23 de marzo de 2018

Angular Material Data Table: A Complete Example (Server Pagination, Filtering, Sorting)


In this post, we are going to go through a complete example of how to use the Angular Material Data Table.
We are going to cover many of the most common use cases that revolve around the Angular Material Data Table component, such as: server-side pagination, sorting, and filtering.
This is a step-by-step tutorial, so I invite you to code along as we are going to start with a simple initial scenario. We will then progressively add features one by one and explain everything along the way (including gotchas).
We will learn in detail all about the reactive design principles involved in the design of the Angular Material Data Table and an Angular CDK Data Source.
The end result of this post will be:
  • a complete example of how to implement an Angular Material Data Table with server-side pagination, sorting and filtering using a custom CDK Data Source
  • a running example available on Github, which includes a small backend Express server that serves the paginated data

Table Of Contents

In this post, we will cover the following topics:
  • The Angular Material Data Table - not only for Material Design
  • The Material Data Table Reactive Design
  • The Material Paginator and Server-side Pagination
  • Sortable Headers and Server-side Sorting
  • Server-side Filtering with Material Input Box
  • A Loading Indicator
  • A Custom Angular Material CDK Data Source
  • Source Code (on Github) with the complete example
  • Conclusions
So without further ado, let's get started with our Material Data Table Guided Tour!

Importing Angular Material modules

In order to run our example, let's first import all the Angular Material modules that we will need:
import { MatInputModule, MatPaginatorModule, MatProgressSpinnerModule,
MatSortModule, MatTableModule } from "@angular/material";
@NgModule({
declarations: [
...
],
imports: [
BrowserModule,
BrowserAnimationsModule,
HttpClientModule,
MatInputModule,
MatTableModule,
MatPaginatorModule,
MatSortModule,
MatProgressSpinnerModule
],
providers: [
...
],
bootstrap: [AppComponent]
})
export class AppModule {
}
view raw 01.ts hosted with ❤ by GitHub
Here is a breakdown of the contents of each Material module:
  • MatInputModule: this contains the components and directives for adding Material design Input Boxes to our application (needed for the search input box)
  • MatTableModule: this is the core data table module, which includes the mat-table component and many related components and directives
  • MatPaginatorModule: this is a generic pagination module, that can be used to paginate data in general. This module can also be used separately from the Data table, for example for implementing Detail pagination logic in a Master-Detail setup
  • MatSortModule: this is an optional module that allows adding sortable headers to a data table
  • MatProgressSpinnerModule: this module includes the progress indicator component that we will be using to indicate that data is being loaded from the backend

Introduction to the Angular Material Data Table

The Material Data Table component is a generic component for displaying tabulated data. Although we can easily give it a Material Design look and feel, this is actually not mandatory.
In fact, we can give the Angular Material Data table an alternative UI design if needed. To see that this is so, let's start by creating a Data Table where the table cells are just plain divs with no custom CSS applied.
This data table will display a list of course lessons, and has 3 columns (sequence number, description and duration):
<mat-table class="lessons-table mat-elevation-z8" [dataSource]="dataSource">
<ng-container matColumnDef="seqNo">
<div *matHeaderCellDef>#</div>
<div *matCellDef="let lesson">{{lesson.seqNo}}</div>
</ng-container>
<ng-container matColumnDef="description">
<div *matHeaderCellDef>Description</div>
<div class="description-cell"
*matCellDef="let lesson">{{lesson.description}}</div>
</ng-container>
<ng-container matColumnDef="duration">
<div *matHeaderCellDef>Duration</div>
<div class="duration-cell"
*matCellDef="let lesson">{{lesson.duration}}</div>
</ng-container>
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
<mat-row *matRowDef="let row; columns: displayedColumns"></mat-row>
</mat-table>
view raw 02.html hosted with ❤ by GitHub

Material Data Table Column Definitions

As we can see, this table defines 3 columns, each inside its own ng-container element. The ng-container element will NOT be rendered to the screen (see this post for more details), but it will provide an element for applying the matColumnDef directive.
The matColumnDef directive uniquely identifies a given column with a key: seqNo, description or duration. Inside the ng-container element, we will have all the configuration for a given column.
Notice that the order of the ng-container elements does NOT determine the column visual order

The Material Data Table Auxiliary Definition Directives

The Material Data Table has a series of auxiliary structural directives (applied using the *directiveName syntax) that allow us to mark certain template sections has having a certain role in the overall data table design.
These directives always end with the Def postfix, and they are used to assign a role to a template section. The first two directives that we will cover are matHeaderCellDef and matCellDef.

The matHeaderCellDef and matCellDef Directives

Inside of each ng-container with a given column definition, there are a couple of configuration elements:
  • we have the template that defines how to display the header of a given column, identified via the matHeaderCellDef structural directive
  • we also have another template that defines how to display the data cells of a given column, using the matCellDef structural directive
These two structural directives only identify which template elements have a given role (cell template, header template), but they do not attach any styling to those elements.
For example, in this case, matCellDef and matHeaderCellDef are being applied to plain divs with no styling, so this is why this table does not have a Material design yet.

Applying a Material Design to the Data Table

Let's now see what it would take to give this Data Table a Material Look and Feel. For that, we will use a couple of built-in components in our header and cell template definitions:
<mat-table class="lessons-table mat-elevation-z8" [dataSource]="dataSource">
<ng-container matColumnDef="seqNo">
<mat-header-cell *matHeaderCellDef>#</mat-header-cell>
<mat-cell *matCellDef="let lesson">{{lesson.seqNo}}</mat-cell>
</ng-container>
<ng-container matColumnDef="description">
<mat-header-cell *matHeaderCellDef>Description</mat-header-cell>
<mat-cell class="description-cell"
*matCellDef="let lesson">{{lesson.description}}</mat-cell>
</ng-container>
<ng-container matColumnDef="duration">
<mat-header-cell *matHeaderCellDef>Duration</mat-header-cell>
<mat-cell class="duration-cell"
*matCellDef="let lesson">{{lesson.duration}}</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
<mat-row *matRowDef="let row; columns: displayedColumns"></mat-row>
</mat-table>
view raw 03.html hosted with ❤ by GitHub
This template is almost the same as the one we saw before, but now we are using the mat-header-cell and mat-cell components inside our column definition instead of plain divs.
Using these components, lets now have a look at what the Data Table looks like with this new Material Design:
Angular Material Data Table
Notice that the table already has some data! We will get to the data source in a moment, right now let's continue exploring the rest of the template.

The matCellDef Directive

The data cell template has access to the data that is being displayed. In this case, our data table is displaying a list of lessons, so the lesson object in each row is accessible via the let lesson syntax, and can be used in the template just like any component variable.

The mat-header-row component and the matHeaderRowDef directive

This combination of related component / directive works in the following way:
  • the matHeaderRowDef identifies a configuration element for the table header row, but it does not apply any styling to the element
  • The mat-header-row on the other hand applies some minimal Material stying
The matHeaderRowDef directive also defines in which order the columns should be displayed. In our case, the directive expression is pointing to a component variable named displayedColumns.
Here is what the displayedColumns component variable will look like:
displayedColumns = ["seqNo", "description", "duration"];
view raw 04.ts hosted with ❤ by GitHub
The values of this array are the column keys, which need to be identical to the names of the ng-container column sections (specified via the matColumnDef directive).
Note: It's this array that determines the visual order of the columns!

The mat-row component and the matRowDef directive

This component / directive pair also works in a similar way to what we have seen in previous cases:
  • matRowDef identifies which element inside mat-table provides configuration for how a data row should look like, without providing any specific styling
  • on the other hand, mat-row will provide some Material stying to the data row
With mat-row, we also have a variable exported that we have named row, containing the data of a given data row, and we have to specify the columns property, which contains the order on which the data cells should be defined.

Interacting with a given table data row

We can even use the element identified by the matRowDef directive to interact with a given data row. For example, this is how we can detect if a given data row was clicked:
<mat-row *matRowDef="let row; columns: displayedColumns"
(click)="onRowClicked(row)">
</mat-row>
view raw 05.html hosted with ❤ by GitHub
When a row is clicked, we will call the onRowClicked() component method, that will then log the row data to the console:
onRowClicked(row) {
console.log('Row clicked: ', row);
}
view raw 06.ts hosted with ❤ by GitHub
If we now click on the first row of our data table, here is what the result will look like on the console:
Material Data Table
As we can see the data for the first row is being printed to the console, as expected! But where is this data coming from?
To answer that, let's then talk about the data source that is linked to this data table, and go over the Material Data Table reactive design.

Data Sources and the Data Table Reactive Design

The data table that we have been presenting receives the data that it displays from a Data Source that implements an Observable-based API and follows common reactive design principles.
This means for example that the data table component does not know where the data is coming from. The data could be coming for example from the backend, or from a client-side cache, but that is transparent to the Data table.
The Data table simply subscribes to an Observable provided by the Data Source. When that Observable emits a new value, it will contain a list of lessons that then get's displayed in the data table.

Data Table core design principles

With this Observable-based API, not only the Data table does not know where the data is coming from, but the data table also does not know what triggered the arrival of new data.
Here are some possible causes for the emission of new data:
  • the data table is initially displayed
  • the user clicks on a paginator button
  • the user sorts the data by clicking on a sortable header
  • the user types a search using an input box
Again, the Data Table has no information about exactly which event caused new data to arrive, which allows the Data Table components and directives to focus only on displaying the data, and not fetching it.
Let's then see how can we implement such a reactive data source.

Why not use MatTableDataSource?

In this example, we will not be using the built-in MatTableDataSource because its designed for filtering, sorting and pagination of a client-side data array.
In our case, all the filtering, sorting and pagination will be happening on the server, so we will be building our own Angular CDK reactive data source from first principles.

Fetching Data from the backend

In order to fetch data from the backend, our custom Data Source is going to be using the LessonsService. This is a standard Observable-based stateless singleton service that is built internally using the Angular HTTP Client.
Let's have a look at this service, and break down how its implemented:
@Injectable()
export class CoursesService {
constructor(private http:HttpClient) {}
findLessons(
courseId:number, filter = '', sortOrder = 'asc',
pageNumber = 0, pageSize = 3): Observable<Lesson[]> {
return this.http.get('/api/lessons', {
params: new HttpParams()
.set('courseId', courseId.toString())
.set('filter', filter)
.set('sortOrder', sortOrder)
.set('pageNumber', pageNumber.toString())
.set('pageSize', pageSize.toString())
}).pipe(
map(res => res["payload"])
);
}
}
view raw 07.ts hosted with ❤ by GitHub

Breaking down the LessonsService implementation

As we can see, this service is completely stateless, and every method forwards calls to the backend using the HTTP client, and returns an Observable to the caller.
Our REST API is available in URLs under the /api directory, and multiple services are available (here is the complete implementation).
In this snippet, we are just showing the findLessons() method, that allows to obtain one filtered and sorted page of lessons data for a given course.
Here are the arguments that we can pass to this function:
  • courseId: This identifies a given course, for which we want to retrieve a page of lessons
  • filter: This is a search string that will help us filter the results. If we pass the empty string '' it means that no filtering is done on the server
  • sortOrder: our backend allows us to sort based on the seqNo column, and with this parameter, we can specify is the sort order is ascending (which is the default asc value), or descending by passing the value desc
  • pageNumber: With the results filtered and sorted, we are going to specify which page of that full list of results we need. The default is to return the first page (with index 0)
  • pageSize: this specifies the page size, which defaults to a maximum of 3 elements
With this arguments, the loadLessons() method will then build an HTTP GET call to the backend endpoint available at /api/lessons.
Here is what an HTTP GET call that fetches the lessons for the first page looks like:
http://localhost:4200/api/lessons?courseId=1&filter=&sortOrder=asc&pageNumber=0&pageSize=3
As we can see, we are appending a series of HTTP query parameters to the GET URL using the HTTPParams fluent API.
This loadLessons() method will be the basis of our Data Source, as it will allow us to cover the server pagination, sorting and filtering use cases.

Implementing a Custom Angular CDK Data Source

Using the LessonsService, let's now implement a custom Observable-based Angular CDK Data Source. Here is some initial code, so that we can discuss its Reactive design (the full version is shown in a moment):
import {CollectionViewer, DataSource} from "@angular/cdk/collections";
export class LessonsDataSource implements DataSource<Lesson> {
private lessonsSubject = new BehaviorSubject<Lesson[]>([]);
constructor(private coursesService: CoursesService) {}
connect(collectionViewer: CollectionViewer): Observable<Lesson[]> {
...
}
disconnect(collectionViewer: CollectionViewer): void {
...
}
loadLessons(courseId: number, filter: string,
sortDirection: string, pageIndex: number, pageSize: number) {
...
}
}
view raw 08.ts hosted with ❤ by GitHub

Breaking down the design of an Angular CDK Data Source

Has we can see, in order to create a Data Source we need to create a class that implements DataSource. This means that this class needs to implement a couple of methods: connect() and disconnect().
Note that these methods provide an argument which is a CollectionViewer, which provides an Observable that emits information about what data is being displayed (the start index and the end index).
We would recommend for now not to focus so much on the CollectionViewer at this moment, but on something much more important for understanding the whole design: the return value of the connect() method.

How to implement the DataSource connect() method

This method will be called once by the Data Table at table bootstrap time. The Data Table expects this method to return an Observable, and the values of that observable contain the data that the Data Table needs to display.
In this case, this observable will emit a list of Lessons. As the user clicks on the paginator and changes to a new page, this observable will emit a new value with the new lessons page.
We will implement this method by using a subject that is going to be invisible outside this class. That subject (the lessonsSubject) is going to be emitting the values retrieved from the backend.
The lessonsSubject is a BehaviorSubject, which means its subscribers will always get its latest emitted value (or an initial value), even if they subscribed late (after the value was emitted).

Why use BehaviorSubject?

Using BehaviorSubject is a great way of writing code that works independently of the order that we use to perform asynchronous operations such as: calling the backend, binding the data table to the data source, etc.
For example, in this design, the Data Source is not aware of the data table or at which moment the Data Table will require the data. Because the data table subscribed to the connect() observable, it will eventually get the data, even if:
  • the data is still in transit coming from the HTTP backend
  • or if the data was already loaded

Custom Material CDK Data Source - Full Implementation Review

Now that we understand the reactive design of the data source, let's have a look at the complete final implementation and review it step-by-step.
Notice that in this final implementation, we have also included the notion of a loading flag, that we will use to display a spinning loading indicator to the user later on:
export class LessonsDataSource implements DataSource<Lesson> {
private lessonsSubject = new BehaviorSubject<Lesson[]>([]);
private loadingSubject = new BehaviorSubject<boolean>(false);
public loading$ = this.loadingSubject.asObservable();
constructor(private coursesService: CoursesService) {}
connect(collectionViewer: CollectionViewer): Observable<Lesson[]> {
return this.lessonsSubject.asObservable();
}
disconnect(collectionViewer: CollectionViewer): void {
this.lessonsSubject.complete();
this.loadingSubject.complete();
}
loadLessons(courseId: number, filter = '',
sortDirection = 'asc', pageIndex = 0, pageSize = 3) {
this.loadingSubject.next(true);
this.coursesService.findLessons(courseId, filter, sortDirection,
pageIndex, pageSize).pipe(
catchError(() => of([])),
finalize(() => this.loadingSubject.next(false))
)
.subscribe(lessons => this.lessonsSubject.next(lessons));
}
}
view raw 09.ts hosted with ❤ by GitHub

Data Source Loading Indicator Implementation Breakdown

Let's start breaking down this code, we will start first with the implementation of the loading indicator. Because this Data Source class has a reactive design, let's implement the loading flag by exposing a boolean observable called loading$.
This observable will emit as first value false (which is defined in the BehaviorSubject constructor), meaning that no data is loading initially.
The loading$ observable is derived using asObservable() from a subject that is kept private to the data source class. The idea is that only this class knows when data is loading, so only this class can access the subject and emit new values for the loading flag.

The connect() method implementation

Let's now focus on the implementation of the connect method:
connect(collectionViewer: CollectionViewer): Observable<Lesson[]> {
return this.lessonsSubject.asObservable();
}
view raw 20.ts hosted with ❤ by GitHub
This method will need to return an Observable that emits the lessons data, but we don't want to expose the internal subject lessonsSubject directly.
Exposing the subject would mean yielding control of when and what data gets emitted by the data source, and we want to avoid that. We want to ensure that only this class can emit values for the lessons data.
So we are also going to return an Observable derived from lessonsSubject using the asObservable() method. This gives the data table (or any other subscriber) the ability to subscribe to the lessons data observable, without being able to emit values for that same observable.

The disconnect() method implementation

Let's now break down the implementation of the disconnect method:
disconnect(collectionViewer: CollectionViewer): void {
this.lessonsSubject.complete();
this.loadingSubject.complete();
}
view raw 21.ts hosted with ❤ by GitHub
This method is called once by the data table at component destruction time. In this method, we are going to complete any observables that we have created internally in this class, in order to avoid memory leaks.
We are going to complete both the lessonsSubject and the loadingSubject, which are then going to trigger the completion of any derived observables.

The loadLessons() method implementation

Finally, let's now focus on the implementation of the loadLessons method:
loadLessons(courseId: number, filter = '',
sortDirection = 'asc', pageIndex = 0, pageSize = 3) {
this.loadingSubject.next(true);
this.coursesService.findLessons(courseId, filter, sortDirection,
pageIndex, pageSize).pipe(
catchError(() => of([])),
finalize(() => this.loadingSubject.next(false))
)
.subscribe(lessons => this.lessonsSubject.next(lessons));
}
view raw 22.ts hosted with ❤ by GitHub
The Data Source exposes this public method named loadLessons(). This method is going to be called in response to multiple user actions (pagination, sorting, filtering) to load a given data page.
Here is how this method works:
  • the first thing that we will do is to report that some data is being loaded, by emitting true to the loadingSubject, which will cause loading$ to also emit true
  • the LessonsService is going to be used to get a data page from the REST backend
  • a call to findLessons() is made, that returns an Observable
  • by subscribing to that observable, we trigger an HTTP request
  • if the data arrives successfully from the backend, we are going to emit it back to the data table, via the connect() Observable
  • for that, we will call next() on the lessonsSubject with the lessons data
  • the derived lessons observable returned by connect() will then emit the lessons data to the data table

Handling Backend Errors

Let's now see, still in the loadLessons() method, how the Data Source handles backend errors, and how the loading indicator is managed:
  • if an error in the HTTP request occurs, the Observable returned by findLessons() will error out
  • If that occurs, we are going to catch that error using catchError() and we are going to return an Observable that emits the empty array using of
  • we could complementary also use another MessagesService to show a closable error popup to the user
  • wether the call to the backend succeeds or fails, we will in both cases have the loading$ Observable emit false by using finalize() (which works like finally in plain Javascript try/catch/finally)
And with this last bit, we have completed the review of our custom Data Source!
This version of the data source will support all our use cases: pagination, sorting and filtering. As we can see, the design is all about providing data transparently to the Data Table using an Observable-based API.
Let's now see how we can take this Data Source and plug it into the Data Table.

Linking a Data Source with the Data Table

The Data Table will be displayed as part of the template of a component. Let's write an initial version of that component, that displays the first page of lessons:
@Component({
selector: 'course',
templateUrl: './course.component.html',
styleUrls: ['./course.component.css']
})
export class CourseComponent implements OnInit {
dataSource: LessonsDataSource;
displayedColumns= ["seqNo", "description", "duration"];
constructor(private coursesService: CoursesService) {}
ngOnInit() {
this.dataSource = new LessonsDataSource(this.coursesService);
this.dataSource.loadLessons(1);
}
}
view raw 10.ts hosted with ❤ by GitHub
This component contains a couple of properties:
  • the displayedColumns array defines the visual order of the columns
  • The dataSource property defines an instance of LessonsDataSource, and that is being passed to mat-table via the template

Breaking down the ngOnInit method

In the ngOnInit method, we are calling the Data Source loadLessons() method to trigger the loading of the first lessons page. Let's detail what happens as a result of that call:
  • The Data Source calls the LessonsService, which triggers an HTTP request to fetch the data
  • The Data Source then emits the data via the lessonsSubject, which causes the Observable returned by connect() to emit the lessons page
  • The mat-table Data Table component has subscribed to the connect() observable and retrieves the new lessons page
  • The Data Table then displays the new lessons page, without knowing where the data came from or what triggered its arrival
And with this "glue" component in place, we now have a working Data Table that displays server data!
The problem is that this initial example is always loading only the first page of data, with a page size of 3 and with no search criteria.
Let's use this example as a starting point, and starting adding: a loading indicator, pagination, sorting, and filtering.

Displaying a Material Loading Indicator

In order to display the loading indicator, we are going to be using the loading$ observable of the Data Source. We will be using the mat-spinner Material component:
<div class="course">
<div class="spinner-container" *ngIf="dataSource.loading$ | async">
<mat-spinner></mat-spinner>
</div>
<mat-table class="lessons-table mat-elevation-z8" [dataSource]="dataSource">
....
</mat-table>
</div>
view raw 11.ts hosted with ❤ by GitHub
As we can see, we are using the async pipe and ngIf to show or hide the material loading indicator. Here is what the table looks like while the data is loading:
Material Data Table Loading Indicator
We will also be using the loading indicator when transitioning between two data pages using pagination, sorting or filtering.

Adding a Data Table Material Paginator

The Material Paginator component that we will be using is a generic paginator that comes with an Observable-based API. This paginator could be used to paginate anything, and it's not specifically linked to the Data Table.
For example, on a Master-Detail component setup, we could use this paginator to navigate between two detail elements.
This is how the mat-paginator component can be used in a template:
<div class="course">
<div class="spinner-container" *ngIf="dataSource.loading$ | async">
<mat-spinner></mat-spinner>
</div>
<mat-table class="lessons-table mat-elevation-z8" [dataSource]="dataSource">
....
</mat-table>
<mat-paginator [length]="course?.lessonsCount" [pageSize]="3"
[pageSizeOptions]="[3, 5, 10]"></mat-paginator>
</div>
view raw 12.ts hosted with ❤ by GitHub
As we can see, there is nothing in the template linking the paginator with either the Data Source or the Data Table - that connection will be done at the level of the CourseComponent.
The paginator only needs to know how many total items are being paginated (via the length property), in order to know how many total pages there are!
Its based on that information (plus the current page index) that the paginator will enable or disable the navigation buttons.
In order to pass that information to the paginator, we are using the lessonsCount property of a new course object.

How to Link the Material Paginator to the Data Source

Let's now have a look at the CourseComponent, to see where course is coming from and how the paginator is linked to the Data Source:
@Component({
selector: 'course',
templateUrl: './course.component.html',
styleUrls: ['./course.component.css']
})
export class CourseComponent implements AfterViewInit, OnInit {
course:Course;
dataSource: LessonsDataSource;
displayedColumns= ["seqNo", "description", "duration"];
@ViewChild(MatPaginator) paginator: MatPaginator;
constructor(private coursesService: CoursesService, private route: ActivatedRoute) {}
ngOnInit() {
this.course = this.route.snapshot.data["course"];
this.dataSource = new LessonsDataSource(this.coursesService);
this.dataSource.loadLessons(this.course.id, '', 'asc', 0, 3);
}
ngAfterViewInit() {
this.paginator.page
.pipe(
tap(() => this.loadLessonsPage())
)
.subscribe();
}
loadLessonsPage() {
this.dataSource.loadLessons(
this.course.id,
'',
'asc',
this.paginator.pageIndex,
this.paginator.pageSize);
}
}
view raw 14.ts hosted with ❤ by GitHub

Breaking down the ngOnInit() method

Let's start with the course object: as we can see this object is available at component construction time via the router.
This data object was retrieved from the backend at router navigation time using a router Data Resolver (see an example here).
This is a very common design, that ensures that the target navigation screen already has some pre-fetched data ready to display.
We are also loading the first page of data directly in this method (on line 20).

How is the Paginator linked to the Data Source?

We can see in the code above that the link between the paginator and the Data Source is done in the ngAfterViewInit() method, so let's break it down:
ngAfterViewInit() {
this.paginator.page
.pipe(
tap(() => this.loadLessonsPage())
)
.subscribe();
}
view raw 23.ts hosted with ❤ by GitHub
We are using the AfterViewInit lifecycle hook because we need to make sure that the paginator component queried via @ViewChild is already available.
The paginator also has an Observable-based API, and it exposes a page Observable. This observable will emit a new value every time that the user clicks on the paginator navigation buttons or the page size dropdown.
So in order to load new pages in response to a pagination event, all we have to do is to subscribe to this observable, and in response to a pagination event, we are going to make a call to the Data Source loadLessons() method, by calling loadLessonsPage().
In that call to loadLessons(), we are going to pass to the Data Source what page index we would like to load, and what page size, and that information is taken directly from the paginator.

Why have we used the tap() operator?

We could also have done the call to the data source from inside a subscribe() handler, but in this case, we have implemented that call using the pipeable version of the RxJs do operator called tap.

View the Paginator in Action

And with this in place, we now have a working Material Paginator! Here is what the Material Paginator looks like on the screen, while displaying page 2 of the lessons list:
Angular Material Paginator
Let's now continue to add more features to our example, let's add another very commonly needed feature: sortable table headers.

Adding Sortable Material Headers

In order to add sortable headers to our Data Table, we will need to annotate it with the matSort directive. In this case, we will make only one column in the table sortable, the seqNo column.
Here is what the template with all the multiple sort-related directives looks like:
<mat-table class="lessons-table mat-elevation-z8" [dataSource]="dataSource"
matSort matSortActive="seqNo" matSortDirection="asc" matSortDisableClear>
<ng-container matColumnDef="seqNo">
<mat-header-cell *matHeaderCellDef mat-sort-header>#</mat-header-cell>
<mat-cell *matCellDef="let lesson">{{lesson.seqNo}}</mat-cell>
</ng-container>
....
</mat-table>
view raw 15.html hosted with ❤ by GitHub
Besides the matSort directive, we are also adding a couple of extra auxiliary sort-related directives to the mat-table component:
  • matSortActive: When the data is passed to the Data Table, its usually already sorted. This directive allows us to inform the Data Table that the data is already initally sorted by the seqNo column, so the seqNo column sorting icon will be displayed as an upwards arrow
  • matSortDirection: This is a companion directive to matSortActive, it specifies the direction of the initial sort. In this case, the data is initially sorted by the seqNo column in ascending order, and so the column header will adapt the sorting icon accordingly (screenshot below)
  • matSortDisableClear: Sometimes, besides ascending and descending order we might want a third "unsorted" state for the sortable column header, where we can clear the sorting order. In this case, we want to disable that to make sure the seqNo column always shown either the ascending or descending states
This is the sort configuration for the whole data table, but we also need to identify exactly what table headers are sortable!
In our case, only the seqNo column is sortable, so we are annotating the column header cell with the mat-sort-header directive.
And this covers the template changes, let's now have a look at the changes we made to the CourseComponent in order to enable table header sorting.

Linking the Sortable column header to the Data Source

Just like the case of pagination, the sortable header will expose an Observable that emits values whenever the user clicks on the sortable column header.
The MatSort directive then exposes a sort Observable, that can trigger a new page load in the following way:
@Component({
selector: 'course',
templateUrl: './course.component.html',
styleUrls: ['./course.component.css']
})
export class CourseComponent implements AfterViewInit, OnInit {
course:Course;
dataSource: LessonsDataSource;
displayedColumns= ["seqNo", "description", "duration"];
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
constructor(private coursesService: CoursesService, private route: ActivatedRoute) {}
ngOnInit() {
this.course = this.route.snapshot.data["course"];
this.dataSource = new LessonsDataSource(this.coursesService);
this.dataSource.loadLessons(this.course.id, '', 'asc', 0, 3);
}
ngAfterViewInit() {
// reset the paginator after sorting
this.sort.sortChange.subscribe(() => this.paginator.pageIndex = 0);
merge(this.sort.sortChange, this.paginator.page)
.pipe(
tap(() => this.loadLessonsPage())
)
.subscribe();
}
loadLessonsPage() {
this.dataSource.loadLessons(
this.course.id, '', this.sort.direction,
this.paginator.pageIndex, this.paginator.pageSize);
}
}
view raw 16.ts hosted with ❤ by GitHub
As we can see, the sort Observable is now being merged with the page observable! Now a new page load will be triggered in two cases:
  • when a pagination event occurs
  • when a sort event occurs
The sort direction of the seqNo column is now taken from the sort directive (injected via @ViewChild()) to the backend.
Notice that after each sort we are also resetting the paginator, by forcing the first page of the sorted data to be displayed.

The Material Sort Header In Action

Here is what the Data Table with sortable headers looks like, after loading the data and clicking the sortable header (triggering a descending sort by seqNo):
Material Data Table with Sortable Header
Notice the sort icon on the seqNo column
At this point, we have server pagination and sorting in place. We are now ready to add the final major feature: server-side filtering.

Adding Server-Side Filtering

In order to implement server-side filtering, the first thing that we need to do is to add a search box to our template.
And because this is the final version, let's then display the complete template with all its features: pagination, sorting and also server-side filtering:
<div class="course">
<!-- New part: this is the search box -->
<mat-input-container>
<input matInput placeholder="Search lessons" #input>
</mat-input-container>
<div class="spinner-container" *ngIf="dataSource.loading$ | async">
<mat-spinner></mat-spinner>
</div>
<mat-table class="lessons-table mat-elevation-z8" [dataSource]="dataSource"
matSort matSortActive="seqNo" matSortDirection="asc" matSortDisableClear>
<ng-container matColumnDef="seqNo">
<mat-header-cell *matHeaderCellDef mat-sort-header>#</mat-header-cell>
<mat-cell *matCellDef="let lesson">{{lesson.seqNo}}</mat-cell>
</ng-container>
<ng-container matColumnDef="description">
<mat-header-cell *matHeaderCellDef>Description</mat-header-cell>
<mat-cell class="description-cell"
*matCellDef="let lesson">{{lesson.description}}</mat-cell>
</ng-container>
<ng-container matColumnDef="duration">
<mat-header-cell *matHeaderCellDef>Duration</mat-header-cell>
<mat-cell class="duration-cell"
*matCellDef="let lesson">{{lesson.duration}}</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
<mat-row *matRowDef="let row; columns: displayedColumns"></mat-row>
</mat-table>
<mat-paginator [length]="course?.lessonsCount" [pageSize]="3"
[pageSizeOptions]="[3, 5, 10]"></mat-paginator>
</div>
view raw 17.html hosted with ❤ by GitHub

Breaking down the Search Box implementation

As we can see, the only new part in this final template version is the mat-input-container, containing the Material Input box where the user types the search query.
This input box follows a common pattern found in the Material library: The mat-input-container is wrapping a plain HTML input and projecting it.
This gives us full access to all standard input properties including for example all the Accessibility-related properties. This also gives us compatibility with Angular Forms, as we can apply Form directives directly in the input HTML element.
Read more about how to build a similar component in this post: Angular ng-content and Content Projection: The Complete Guide.
Notice that there is not even an event handler attached to this input box ! Let's then have a look at the component and see how this works.

Final Component with Server Pagination, Sorting and Filtering

This is the final version of CourseComponent with all features included:
@Component({
selector: 'course',
templateUrl: './course.component.html',
styleUrls: ['./course.component.css']
})
export class CourseComponent implements OnInit, AfterViewInit {
course:Course;
dataSource: LessonsDataSource;
displayedColumns= ["seqNo", "description", "duration"];
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
@ViewChild('input') input: ElementRef;
constructor(
private route: ActivatedRoute,
private coursesService: CoursesService) {}
ngOnInit() {
this.course = this.route.snapshot.data["course"];
this.dataSource = new LessonsDataSource(this.coursesService);
this.dataSource.loadLessons(this.course.id, '', 'asc', 0, 3);
}
ngAfterViewInit() {
// server-side search
fromEvent(this.input.nativeElement,'keyup')
.pipe(
debounceTime(150),
distinctUntilChanged(),
tap(() => {
this.paginator.pageIndex = 0;
this.loadLessonsPage();
})
)
.subscribe();
// reset the paginator after sorting
this.sort.sortChange.subscribe(() => this.paginator.pageIndex = 0);
// on sort or paginate events, load a new page
merge(this.sort.sortChange, this.paginator.page)
.pipe(
tap(() => this.loadLessonsPage())
)
.subscribe();
}
loadLessonsPage() {
this.dataSource.loadLessons(
this.course.id,
this.input.nativeElement.value,
this.sort.direction,
this.paginator.pageIndex,
this.paginator.pageSize);
}
}
view raw 18.ts hosted with ❤ by GitHub
Let's then focus on breaking down the server filtering part.

Getting a reference to the Search Input Box

We can see that we have injected a DOM reference to the <input> element using @ViewChild('input'). Notice that this time around, the injection mechanism gave us a reference to a DOM element and not to a component.
With that DOM reference, here is the part that triggers a server-side search when the user types in a new query:
// server-side search
fromEvent(this.input.nativeElement,'keyup')
.pipe(
debounceTime(150),
distinctUntilChanged(),
tap(() => {
this.paginator.pageIndex = 0;
this.loadLessonsPage();
})
)
.subscribe();
view raw 19.ts hosted with ❤ by GitHub
What we are doing in this is snippet is: we are taking the search input box and we are creating an Observable using fromEvent.
This Observable will emit a value every time that a new keyUp event occurs. To this Observable we will then apply a couple of operators:
  • debounceTime(150): The user can type quite quickly in the input box, and that could trigger a lot of server requests. With this operator, we are limiting the amount of server requests emitted to a maximum of one every 150ms.
  • distinctUntilChanged(): This operator will eliminate duplicate values
And with these two operators in place, we can now trigger a page load by passing the query string, the page size and page index to the the Data Source via the tap() operator.
Let's now have a look at the what the screen would look like if the user types the search term "hello":
Angular Material Server Filtering
And with this in place, we have completed our example! We now have a complete solution for how to implement an Angular Material Data Table with server-side pagination, sorting and filtering.
Let's now quickly summarize what we have learned.

Conclusions

The Data Table, the Data Source and related components are a good example of a reactive design that uses an Observable-based API. Let's highlight the key points of the design:
  • the Material Data Table expects to receive the data from the Data Source via an Observable
  • The Data Source main role is to build and provide an Observable that emits new versions of the tabular data to the Data Table
  • A component class like CourseService will then "glue" everything together
This reactive design helps to ensure the loose coupling of the multiple elements involved, and provides a strong separation of concerns.

Source Code + Github Running Example

A running example of the complete code is available here on this branch on Github, and it includes a small backend Express server that serves the data and does the server-side sorting/pagination/filtering.
I hope that this post helps with getting started with the Angular Material Data Table and that you enjoyed it!
If you have some questions or comments please let me know in the comments below and I will get back to you.
To get notified of upcoming posts on Angular Material and other Angular topics, I invite you to subscribe to our newsletter:

Other Angular Material posts:

Angular Material Dialog: A Complete Example

Video Lessons Available on YouTube

Have a look at the Angular University Youtube channel, we publish about 25% to a third of our video tutorials there, new videos are published all the time.
Subscribe to get new video tutorials:

0 comentarios:

Publicar un comentario