Torsten Müller

Creating a reusable table component in Angular

published Oct 15th, 2019

“There has to be a better way” was all I could think about when I reviewed the implementation of an application heavy with data tables and saw that with the various abstractions and specific implementations, a table required the use of eight or more Angular components with the accompanying passing of data through all those layers via property and event bindings. So I sat down and tried to tackle the problem myself.

Architecture

I came up with a first-order architecture which will work with non-nested data structures and has the following properties and behaviors. It

  1. accepts a configuration object with
    • specification of the columns, their title, data attribute on the object
    • the available filterable columns
    • any sort options that needed to be displayed
  2. would solely deal with the display of the data
  3. would not deal with the loading or management of the data, because that would destroy its reusability. Instead, it would emit events and let the parent component manage the interaction with the respective services.
  4. would provide UI elements to filter and change pages but rely on the containing component to retrieve the actual data for the current table settings
  5. would maintain the state of the display of the data, which should not be handled by the containing component

On page load, the flow of data and the communication between two components ParentComponent and TableComponent and a DataService would look like this:

Sequence diagram of TableComponent interaction with Services and ParentComponents

The benefits of this setup are that the TableComponent can be reused for the display of any data type, as it receives a configuration object describing the columns with name, the object property containing the data and instructions on which columns the user can sort by and which ones allow filtering.

Further, the loading of the data happens in the containing component, as it should be interfaced with the service or data source retrieving the data type to be displayed. That nicely separates the data structure and loading from its display and keeps the table display component blissfully unaware of the exact data structure — Everything comes in through the configuration and property binding.

Leaving the setting of the sort and filtering settings to the table component has the benefit that all the presentation concerns are handled by the table component and it can inform the host component what data to load by passing events back to the host component in a standardized format. This nicely separates the concerns of displaying the data and actually interfacing with the various data services, which will be handled by the specialized components and not by the generic table component.

The configuration for the TableComponent could look like this:

table.component.ts
const tableConfig: DataTableType = {
  columns: [
    {title: "First Name", dataProperty: "firstName", sortable: true, filterable: false},
    {title: "Last Name", dataProperty: "lastName", sortable: true, filterable: true},
    {title: "Occupation", dataProperty: "occupation", sortable: false, filterable: false},
    {title: "Branch", dataProperty: "companyBranch", sortable: false, filterable: true},
  ],
  rowActions: [
    {label: "Edit", actionIdToReturn: "edit", logoImageUrl: "...", showOption: (x) => true },
    {label: "Copy", actionIdToReturn: "copy", logoImageUrl: "...", showOption: (x) => x.completed },
    {label: "Delete", actionIdToReturn: "delete", logoImageUrl: "...", showOption: (x) => !x.isActive },
    {label: "Message", actionIdToReturn: "message", logoImageUrl: "...", showOption: (x) => x.permitsMessaging },
  ],
  rowsPerPage: 20,
}

This configuration shows a property called rowActions which is another configuration object, this time for the rows, indicating what a user should be able to do with the data on the row. To effect the change in the data set, the TableComponent would emit an event with the actionIdToReturn value to the host component. It is then the HostComponent’s responsibility to perform the action indicated by the actionIdToReturn identifier (like “edit” or “delete”)

That last option in the above rowActions entry, showOption, is likely the most interesting: It accepts a function that is passed the object for the row to be displayed in the table and returns a boolean indicating whether to display the action for the row to the user, based on the data in the row. That way, one could implement a deletion button that only shows for disabled entries or a button which would initiate messaging only for users who have opted into that feature, as shown in this example.

Component implementation for generic data table

The following code excerpt shows the beginnings of a generic component to manage the tasks for a table component. It uses two @Input() and two @Output() bindings. The first @Input() accepts the table configuration as shown in the previous listing, the second is a property binding to accept the data to be rendered from its parent component.

As discussed before, there is an event emitter getDataForPage to initiate getting the data to be displayed from the containing component. There also is an event emitter called startRowAction to communicate to the containing component that the user wants to manipulate data in a row. The emitted data from this method adheres to an interface RowActionWithData<A> so that events containing the typed data payload can be emitted and processed by one method in the parent component. The interface specifies that any event emitted should specify two things:

  1. the action to be performed (in the form of a string identifier) and
  2. the row data for which the action was started.

This allows the event receiver to react to any data contained in the row. The containing component then decides how to fulfill that request — through a redirect, a modal or any other action. That no longer is the responsibility of the table component, though.

Further, there is a generic method called onRowActionClicked(), which will be called in the data table template for any button the user clicks to initiate an action on the data in a specific row. As discussed previously, the data table component should not be aware of any data specifics, so it delegates the processing of the request to the host component via the startRowAction event emitter discussed in the previous paragraph.

data-table-wrapper.component.ts
@Component({
  selector: 'data-table-wrapper',
  templateUrl: './data-table-wrapper.component.html'
})
export class DataTableWrapperComponent<A> implements OnChanges {

  @Input()
  public tableConfig: DataTableType;

  @Input()
  public tableContent: A[];

  @Output()
  public getDataForPage = new EventEmitter<PageChangeEventType>();

  @Output()
  public startRowAction = new EventEmitter<{}>();

  public pageList: number[];

  public ngOnChanges(changes: SimpleChanges) {}

  public onRowActionClicked(actionType: string, rowData: A): void {
    const userAction: RowActionWithData<A> = {
      actionToPerform: actionType,
      rowData: rowData
    };

    this.startRowAction.emit(userAction);
  }
}

The template of this component is fairly straightforward angular HTML:

data-table-wrapper.component.html
<table>
  <thead>
  <tr>
    <th *ngFor="let columnHead of tableConfig.columns">{{ columnHead.title }}</th>
  </tr>
  </thead>
  <tbody>
  <tr *ngFor="let columnData of tableContent">
    <td *ngFor="let columnHead of tableConfig.columns">
      <div *ngIf="columnHead.dataField !== 'ROW_ACTIONS';else action_content">{{ columnData[columnHead.dataField] }}</div>
      <ng-template #action_content>
        <div *ngFor="let action of tableConfig.rowActions">
          <img src="{{ action.logoImageUrl }}"
               alt="{{ action.actionDescription }}"
               *ngIf="action.showOption(columnData)"
               (click)="onRowActionClicked(action.actionToPerform, columnData)"/>
        </div>
      </ng-template>
    </td>
  </tr>
  </tbody>
</table>

In rows 3-5, the table header is created from the previously discussed tableConfig. Each row in the data set is rendered in rows 9 and 10 in the <ng-template /> tag pair, and this implementation is using an Angular template reference, #action_content, and *ngIf to render the row actions in lines 11-18. Each configuration object contains the URL to an image and the alt label. We see here how the showOption() method defined above gets used in the *ngIf in row 15: It gets passed a reference to the object being displayed in the row and can therefore use any data on the object to make its decision. When this method returns false, it suppresses the rendering of the action button.

Implementing pagination

The pagination implemented here works as described in this sequence diagram:

Flow chart of the event happening when a user paginates the data in the table

Here, the pagination implementation is part of the TableComponent, implemented as a subcomponent which manages its own pagination state. This has the benefit that we avoid code duplication for the pagination in every component using a table and we can simply pass the change of a page through the getDataForPage EventEmitter mechanism introduced earlier.

The markup for the component looks like this:

pagination.component.html
<div class="pagination" *ngIf="listOfAvailablePages.length > 1">
  <ul>
    <li *ngIf="this.paginationService.currentPage > 1" (click)="onPageSelection(1)">&lt;&lt;</li>
    <li *ngIf="this.paginationService.currentPage > 1" (click)="onAdjacentPageSelection(-1)">&lt;</li>
    <li *ngFor="let pageNum of listOfAvailablePages"
        (click)="onPageSelection(pageNum)"
        style="padding:10px;cursor:pointer">
      <span *ngIf="pageNum !== this.paginationService.currentPage; else current_page">{{ pageNum }}</span>
      <ng-template #current_page>
        <span style="background: #ccc">{{ pageNum }}</span>
      </ng-template>
    </li>
    <li *ngIf="this.paginationService.currentPage < listOfAvailablePages.length"
        (click)="onAdjacentPageSelection(1)">&gt;</li>
    <li *ngIf="this.paginationService.currentPage < listOfAvailablePages.length"
        (click)="onPageSelection(listOfAvailablePages.length)">&gt;&gt;</li>
  </ul>
</div>
  1. We of course suppress the display of the navigation if we don’t have more than one page for the data. This is implemented in line 5.
  2. We have links to return to the beginning and go to the end of the data set and to page through it
  3. The center list item gets repeated using *ngFor() and generates a list of clickable items, which represent the pages of the table
  4. The <ng-template/> on line 13 gets rendered from the *ngIf on the previous line in case the page number to render is the current page number. It therefore is rendered differently.

The listOfAvailablePages property is an array with page numbers generated by the component based on the last page number. The reason for this hacky work-around is that *ngFor can, as of this writing, only traverse collections/arrays and not increment numbers We therefore loop through an array in line 5 and attach a click handler for each page.

The corresponding TypeScript code looks like this:

pagination.component.ts
@Component({
  selector: 'data-table-pagination',
  templateUrl: './pagination.component.html'
})
export class PaginationComponent {

  @Input()
  public listOfAvailablePages: number[];

  @Output()
  public pageChangeRequested = new EventEmitter<number>();

  constructor(public paginationService: PaginationService) {
    this.paginationService.currentPage = 1;
  }

  public onPageSelection(pageNumber: number): void {
    this.paginationService.currentPage = pageNumber;
    this.pageChangeRequested.emit(pageNumber);
  }

  public onAdjacentPageSelection(dir: number): void {
    if (dir < 0 && this.paginationService.currentPage > 1) {
      this.onPageSelection(this.paginationService.currentPage - 1);
    } else if (dir > 0 && this.paginationService.currentPage < this.listOfAvailablePages.length) {
      this.onPageSelection(this.paginationService.currentPage + 1);
    }
  }
}

We see in the TypeScript that we have two methods, one for changing to a specific page number (via onPageSelection()) and one for incrementing and decrementing from the current page via the arrow buttons at the end of the page lists. Both methods lead to an event on the pageChangeRequested event emitter, which the main component will listen for to request the corresponding data that will later be rendered in the table.

So with that component now able to keep track of the current page, we can include it in the data table component as follows:

data-table.component.html
<table>
... (as shown above)
</table>
<data-table-pagination [listOfAvailablePages]="pageList"
                       (pageChangeRequested)="changePageTo($event)">
</data-table-pagination>

The changePageTo() method in the parent component will receive the requested page number and needs to pull the data from the data source for that page. This will be accomplishes through the getDataForPage() event emitter introduced earlier, which will emit an event of type PageChangeEventType, as in the following code snippet:

pagination.component.ts
  public changePageTo(pageNumber: number): void {
    const eventData: PageChangeEventType = {
      fromEntry: (pageNumber - 1) * this.tableConfig.entriesPerPage,
      toEntry: (pageNumber * this.tableConfig.entriesPerPage),
    };

    this.getDataForPage.emit(eventData);
  }

All that’s left now for the main component is to retrieve the data to be displayed for the requested page and pass that data to the tableContent property of the TableComponent. And that concludes my overview of how to implement a generically usable table component which can be used for a number of very different data sets.

Summary

In this post, I described a generic table component which can display and paginate a number of disparate data types. To accomplish that goal, I used two nested Angular components, one for the rendering of the data and the other for displaying the pagination and reacting to user requests for page changes.

This architecture also allows to add the functionality to sort and filter data through a similar mechanism described here, where the table component makes its needs known and passes them to the containing component to load the data via a service.