Angular Custom Table Component Paging

Home / Angular Custom Table Component Paging

After finishing up a component to filter data, generically, client-side paging seems like the next logical step to implement.


Initially, I thought about putting paging functionality within the table component itself. But then, I thought it would kind of neat if the pager could kinda-sorta stand on its own. This does create a few challeneges since, now, the controlling component would have to control dataflow. That is to say, if it gets notified that the data within the table has been filtered, it would also need to trigger sorting and paging prior to returning data to the table for display. Essentially, it means the table would always need to receive paged data.

To facilitate this data flow, I added an EventEmitter to the custom table and removed the sort functionality. The sort functionality was moved to what I’m calling my DataService which is injectable. The DataService will contain paging and sorting functionality specific to the table/table definition (columns). This allows reuse of that functionality.

The page/sort methods are essentially the same code that I’ve been using for a while to page and sort.

The difference here is that I use the TypeScript defined types for passing the CustomTableColumnDefinitons and CustomTableOptions. So, yes, this does become very specific to the table data.

  public sort(array: Array<any>, fieldName: string, direction: string, columns: Array<CustomTableColumnDefinition> ) {
    var column: CustomTableColumnDefinition = columns.filter((column) => column.value === fieldName)[0];
    var isNumeric: bool = (column.filter && column.filter.indexOf("currency") != -1) || (column.isNumeric === true);

    var sortFunc = function (field, rev, primer) {
      // Return the required a,b function
      return function (a, b) {
        // Reset a, b to the field
        a = primer(pathValue(a, field)), b = primer(pathValue(b, field));
        // Do actual sorting, reverse as needed
        return ((a < b) ? -1 : ((a > b) ? 1 : 0)) * (rev ? -1 : 1);
      }
    };
  
    // Have to handle deep paths
    var pathValue = function (obj, path) {
      for (var i = 0, path = path.split('.'), len = path.length; i < len; i++) {
        obj = obj[path[i]];
      };
      return obj;
    };
  
    var primer = isNumeric ?
      function (a) {
        var retValue = parseFloat(String(a).replace(/[^0-9.-]+/g, ''));
        return isNaN(retValue) ? 0.0 : retValue;
      } :
      function (a) { return String(a).toUpperCase(); };
  
    var start = new Date().getTime();
    array.sort(sortFunc(fieldName, direction === 'desc', primer));
    var end = new Date().getTime();
    var time = end - start;
    console.log('Sort time: ' + time);
  }
  
  public pageData(records:Array<any>, options:CustomTableOptions):Array<any> {
    if (records) {
      var arrLength = options.config.totalCount = records.length;
      var startIndex:number = (options.config.pageNumber - 1) * options.config.pageSize;
      var endIndex:number = (options.config.pageNumber - 1) * options.config.pageSize + options.config.pageSize;
      endIndex = endIndex > arrLength ? arrLength : endIndex;
      options.config.lowerRange = options.config.lowerRange = ((options.config.pageNumber - 1) * options.config.pageSize) + 1;
      if (!options.clientPaging) {
          options.config.upperRange = options.config.lowerRange + arrLength - 1;
      } else {
        options.config.upperRange = options.config.lowerRange + options.config.pageSize - 1;
        if (options.config.upperRange > records.length) {
            options.config.upperRange = recordsrecords.length;
        }
      }
      options.config.totalPages = parseInt(Math.ceil(options.config.totalCount / options.config.pageSize));
      return records.slice(startIndex, endIndex);
    } else {
      options.config.lowerRange = 0;
      options.config.upperRange = 0;
      options.config.totalPages = 0;
      options.config.totalCount = 0;
    }
    
    return [];
  }

The pager is a pretty basic Bootstrap-styled pagination control.

<div>
  <ul class="pagination">
    <li *ngIf="boundaryLinks" class="page-item" [ngClass]="{'disabled': noPrevious()}"><a class="page-link" (click)="selectPage(1)">{{getText('first')}}</a></li>
    <li *ngIf="directionLinks" class="page-item" [ngClass]="{'disabled': noPrevious()}"><a class="page-link" (click)="selectPage(options.config.pageNumber - 1)">{{getText('previous')}}</a></li>
    <li *ngFor="let page of pages" class="page-item" [ngClass]="{'active': page.active}"><a class="page-link" (click)="selectPage(page.number)">{{page.text}}</a></li>
    <li *ngIf="directionLinks" class="page-item" [ngClass]="{'disabled': noNext()}"><a class="page-link" (click)="selectPage(options.config.pageNumber + 1)">{{getText('next')}}</a></li>
    <li *ngIf="boundaryLinks" class="page-item" [ngClass]="{'disabled': noNext()}"><a class="page-link" (click)="selectPage(options.config.totalPages)">{{getText('last')}}</a></li>
  </ul>
</div>

Putting the pager into the parent component:

<div class="row">
  <pager [options]="tableOptions" (pageChange)="pageChange($event)"></pager>
</div>

On the code side, it’s rather verbose to calculate the displayed pages, boundary links, adjacents, and such. I won’t paste all of that here since you can see it in the plunk source, but suffice it to say, when a pagination button is clicked, the pager will behave in a similar fashion to the filter component. An EventEmitter is triggered.

selectPage(page:number) {
  if (this.options.config.pageNumber !== page && page > 0 && page <= this.options.config.totalPages) {
    this.options.config.pageNumber = page;
    this.pages = this.getPages(this.options.config.pageNumber, this.options.config.totalPages);
    this.pageChange.emit();
  }
}

Also, like the filter component, a subscription is used to alert the pager component when the displayed page buttons need to be refreshed.

ngOnInit() {
  this.options.records.subscribe(res => {
    this.pages = this.getPages(this.options.config.pageNumber, this.calculateTotalPages());
    this.changeRef.markForCheck();
  });
}

The one change to the table component, like I mentioned, was to remove the sort functionality and replace it with an emitter to let the controlling component know that a request to sort the data has been made. You can see that the “tableOptions” that were passed in carry the sortBy and sortDireciton. As you can imaging, these same options will be passed into the sort function.

sortHeaderClick(headerName: string) {
  if (headerName) {
    if (this.options.config.sortBy === headerName) {
      this.options.config.sortDirection = this.options.config.sortDirection === 'asc' ? 'desc' : 'asc';
    }
    this.options.config.sortBy = headerName;
    this.sortChange.emit();
  }
}

Now, within the controlling component, we have to control the flow of data. By handling the events that are emitted, this is pretty straight forward.

filterChange($event) {
  this.filteredData = $event;
  this.sortChange();
  this.pushChange();
}
  
sortChange($event:any) {
  if (this.tableOptions.config.clientSort) {
    this.dataSvc.sort(this.filteredData, this.tableOptions.config.sortBy, this.tableOptions.config.sortDirection, this.tableOptions.columns);
    if (this.tableOptions.config.clientPaging) {
      this.pageChange();
    } else {
      this.pushChange();
    }
  }
}

pageChange($event:any) {
  if (this.tableOptions.config.clientPaging) {
    this.pagedData = this.dataSvc.pageData(this.filteredData, this.tableOptions);
    this.pushChange();
  }
}

Note that I’m tracking the filtered data separately from the original generated data. This filtered data array is passed into the sorting and paging functions and on returns the paged/sorted data. The “pushChange” method pushes the pagedData on the tableSubject for any subscribers that are listening.

pushChange() {
  this.tableSubject.next(this.pagedData);
}

The plunk below shows everything in action.

8 thoughts on “Angular Custom Table Component Paging”

    1. Thanks! These components fully work with Angular 4. If you view the Plunker view source, you will see that it the primary entry point gets this attribute: ng-version=”4.1.3″

  1. Hi, First of all thanks for this post.
    I am able to setup all things in angular 8 using this approach and run code with hardcoded value which you use.
    But when i tried to call API and set data in table it’s not populating my data in

    1. How are you calling your API and populating the the object that is bound to the table? Generally, if you get a handle to the “tableSubject,” and call the next method, it will trigger a change to the observable and the UI will be refreshed accordingly. An example is below..

                  this.apiSvc
                      .getUrl("/api/somedata", options)
                      .subscribe(res => {
                          let result = <any>res;
                          this.records = <Array<any>>result.records;
                          this.pagedData = this.records;
                          this.tableOptions.config.pageSize = <number>result.pageSize;
                          this.tableOptions.config.pageNumber = <number>result.pageNumber;
                          this.tableOptions.config.totalCount = <number>result.totalCount;
                          this.tableOptions.config.totalPages = <number>result.totalPages;
                          this.tableOptions.config.sortBy = <string>result.sortBy;
                          this.tableOptions.config.sortDirection = <string>result.sortDirection;
                          this.tableOptions.config.upperRange = <number>result.upperRange;
                          this.tableOptions.config.lowerRange = <number>result.lowerRange;
      
                          console.log("Received " + this.records.length + " records.  Sorted by " + this.tableOptions.config.sortBy + ".");
      
                          // Change the URL/state if you want for history
                          this.location.go('baseUrl/' + this.tableOptions.config.pageNumber + "/" + this.tableOptions.config.pageSize + "/" + this.tableOptions.config.sortBy + "/" + this.tableOptions.config.sortDirection);
                          this.tableSubject.next(this.pagedData);
                          this.filterSubject.next(this.records);
                          this.changeRef.markForCheck();
                      });
      

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.