Creating a re-usable table component

Pre-requisites for this tutorial

In order to follow along with this tutorial you will need to have the completed project from the related course that covers how to build this project, the Skinet course. Alternatively you can run the completed project locally on your computer by following the set up tutorials here. The code provided to build this project is all taught and explained in the training course here.

It is expected you have the knowledge gained from building the project in the course for this tutorial series, but you can still follow along without completing the course.

Please ensure you have the project running in development mode and you can login with the admin account using the following credentials:

1username: admin@test.com
2password: Pa$$word

Browse to the admin page and you should see something like this:

Image

The Catalog tab just has placeholder text at the moment, but this is the feature we will work on in this tutorial!

Adding the components we need

In this set of tutorials we are going to create a simple inventory system for our e-commerce store and will implement the following in this lesson:

  1. An admin catalog component which will display the products in tabular form.
  2. A re-usable table which we will use for both the admin orders tab as well as the products tab.

Open the terminal and create the following components using the Angular CLI. Ensure you are in the context of the client folder (root of the angular app) when you use these commands:

1ng g c shared/components/custom-table --skip-tests
2ng g c features/admin/admin-catalog --skip-tests
3ng g c features/admin/admin-orders --skip-tests

Adding a re-usable table component

Currently we just have a single admin component with an angular material table for the orders table we are using to issue refunds. The angular material table is somewhat tricky to use as a shared table component so we will simplify things and just use a tailwind css table as our shared table here.

Open the custom-table.component.ts and add the following code:

1import { CommonModule } from '@angular/common';
2import { Component, Input, Output, EventEmitter } from '@angular/core';
3import { MatIcon } from '@angular/material/icon';
4import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
5import { MatTooltip } from '@angular/material/tooltip';
6
7@Component({
8  selector: 'app-custom-table',
9  standalone: true,
10  imports: [
11    CommonModule,
12    MatTooltip,
13    MatIcon,
14    MatPaginatorModule
15  ],
16  templateUrl: './custom-table.component.html',
17  styleUrls: ['./custom-table.component.scss']
18})
19export class CustomTableComponent {
20  @Input() columns: { field: string, header: string, pipe?: string, pipeArgs?: any }[] = [];
21  @Input() dataSource: any[] = [];
22  @Input() actions: { label: string, icon: string, tooltip: string, action: (row: any) => void, disabled?: (row: any) => boolean }[] = [];
23  @Input() totalItems: number = 0; 
24  @Input() pageSize: number = 5; 
25  @Input() pageIndex: number = 0; 
26
27  @Output() pageChange = new EventEmitter<PageEvent>();
28
29  onPageChange(event: PageEvent) {
30    this.pageIndex = event.pageIndex;
31    this.pageSize = event.pageSize;
32    this.pageChange.emit(event);
33  }
34
35  onAction(action: (row: any) => void, row: any) {
36    action(row);
37  }
38
39  getCellValue(row: any, column: any) {
40    const value = row[column.field];
41    if (column.pipe === 'currency') {
42      return new Intl.NumberFormat('en-US', { style: 'currency', currency: column.pipeArgs || 'USD' }).format(value);
43    }
44    if (column.pipe === 'date') {
45      return new Date(value).toLocaleDateString('en-US', column.pipeArgs);
46    }
47    return value;
48  }
49}

This component has the following features:

Inputs:

  • columns: Array of column definitions (field name, header, optional formatting pipe, and arguments).
  • dataSource: The array of data to be displayed in the table.
  • actions: Array of actions (buttons) for each row, each containing label, icon, tooltip, and an action callback.
  • totalItems: Total number of items for pagination.
  • pageSize: Number of rows to display per page.
  • pageIndex: Current page index.

Outputs:

  • pageChange: Event emitter that outputs a PageEvent (page change event).

Methods:

  • onPageChange(event: PageEvent): Updates pageIndex and pageSize when a pagination event occurs and emits the updated event.
  • onAction(action, row): Calls the specified action on the given row (e.g., delete, edit).
  • getCellValue(row, column): Formats the cell value based on the column’s pipe (e.g., format as currency or date).

This component is designed to be flexible, with customizable columns and actions, and also handles pagination and formatted display of data.

Next, open up the template for this component and add the following code:

1<div class="p-4">
2    <table class="min-w-full bg-white border border-gray-300">
3      <thead>
4        <tr class="border-b bg-gray-200">
5          <th *ngFor="let column of columns" class="py-2 px-4 text-left">{{ column.header }}</th>
6          <th class="py-2 px-4 text-left" *ngIf="actions.length">Actions</th>
7        </tr>
8      </thead>
9      <tbody>
10        <tr *ngFor="let row of dataSource" class="border-b">
11          <td *ngFor="let column of columns" class="py-2 px-4">{{ getCellValue(row, column) }}</td>
12          <td class="py-2 px-4" *ngIf="actions.length">
13            <div class="flex gap-2">
14              <ng-container *ngFor="let action of actions">
15                <button
16                  mat-icon-button
17                  matTooltip="{{ action.tooltip }}"
18                  [disabled]="action.disabled ? action.disabled(row) : false"
19                  (click)="onAction(action.action, row)">
20                  <mat-icon>{{ action.icon }}</mat-icon>
21                </button>
22              </ng-container>
23            </div>
24          </td>
25        </tr>
26      </tbody>
27    </table>
28  
29    <mat-paginator 
30      [length]="totalItems"
31      [pageSize]="pageSize"
32      [pageIndex]="pageIndex"
33      [pageSizeOptions]="[5, 10, 20]"
34      (page)="onPageChange($event)"
35      class="mt-4 bg-white"
36    >
37    </mat-paginator>
38  </div>

This is a fairly standard tailwind css table. We loop over the columns to provide the column headers which we receive as in Input from the consumer of this component. We also conditionally add a column if actions are provided to go in the ‘action’ column.

In the <tbody> we loop over the ‘dateSource’ (array of data we pass into the component as an Input prop) and then in the <td> we loop over the columns. We use the getCellValue() method as this will allow us to use functionality such as the ‘date’ pipe or the ‘currency’ pipe in the column if we need it. In the second <td> we again check to see if any ‘actions’ have been passed as Input props to this component, and if so loop over the actions and display the buttons for each action specified.

We also include the <mat-paginator> to, well, paginate the data.

Using the re-usable table

We will use the new table to replace the existing angular material table we used in the main part of the course. As mentioned, the angular material table was tricky to use as a re-usable component. Not impossible of course, but the amount of code required to achieve the same functionality as above was… lots, and a little more complex than I would like for what I intend to be a straightforward demo.

Open the admin-orders.component.ts

We will be refactoring moving the code from the admin.component.ts into the admin-orders.component.ts here so the admin-orders.component should have the following code after you move it:

1import { Component, inject, OnInit } from '@angular/core';
2import { AdminService } from '../../../core/services/admin.service';
3import { Order } from '../../../shared/models/order';
4import { OrderParams } from '../../../shared/models/orderParams';
5import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
6import { DialogService } from '../../../core/services/dialog.service';
7import { MatTooltip } from '@angular/material/tooltip';
8import { MatIcon } from '@angular/material/icon';
9import { CommonModule } from '@angular/common';
10import { CustomTableComponent } from "../../../shared/components/custom-table/custom-table.component";
11import { Router } from '@angular/router';
12
13@Component({
14  selector: 'app-admin-orders',
15  standalone: true,
16  imports: [
17    MatPaginatorModule,
18    MatTooltip,
19    MatIcon,
20    CommonModule,
21    CustomTableComponent
22],
23  templateUrl: './admin-orders.component.html',
24  styleUrls: ['./admin-orders.component.scss']
25})
26export class AdminOrdersComponent implements OnInit {
27  orders: Order[] = [];
28  private adminService = inject(AdminService);
29  private dialogService = inject(DialogService);
30  private router = inject(Router);
31  orderParams = new OrderParams();
32  totalItems = 0;
33
34  columns = [
35    { field: 'id', header: 'No.' },
36    { field: 'buyerEmail', header: 'Buyer Email' },
37    { field: 'orderDate', header: 'Order Date', pipe: 'date', pipeArgs: { year: 'numeric', month: 'short', day: 'numeric' } },
38    { field: 'total', header: 'Total', pipe: 'currency', pipeArgs: 'USD' },
39    { field: 'status', header: 'Status' }
40  ];
41
42  actions = [
43    {
44      label: 'View',
45      icon: 'visibility',
46      tooltip: 'View Order',
47      action: (row: any) => {
48        this.router.navigateByUrl(`/orders/${row.id}`)
49      }
50    },
51    {
52      label: 'Refund',
53      icon: 'undo',
54      tooltip: 'Refund Order',
55      disabled: (row: any) => row.status === 'Refunded',
56      action: (row: any) => {
57        this.openConfirmDialog(row.id);
58      }
59    }
60  ];
61
62  ngOnInit(): void {
63    this.loadOrders();
64  }
65
66  loadOrders() {
67    this.adminService.getOrders(this.orderParams).subscribe({
68      next: response => {
69        if (response.data) {
70          this.orders = response.data;
71          this.totalItems = response.count;
72        }
73      }
74    });
75  }
76
77  onPageChange(event: PageEvent) {
78    this.orderParams.pageNumber = event.pageIndex + 1;
79    this.orderParams.pageSize = event.pageSize;
80    this.loadOrders();
81  }
82
83  onAction(action: (row: any) => void, row: any) {
84    action(row);
85  }
86
87  async openConfirmDialog(id: number) {
88    const confirmed = await this.dialogService.confirm(
89      'Confirm refund',
90      'Are you sure you want to issue this refund? This cannot be undone'
91    );
92    if (confirmed) this.refundOrder(id);
93  }
94
95  refundOrder(id: number) {
96    this.adminService.refundOrder(id).subscribe({
97      next: order => {
98        this.orders = this.orders.map(o => o.id === id ? order : o);
99        this.loadOrders(); // Update displayed data after refund
100      }
101    });
102  }
103}

Of course there are some extras in this version of the code, specifically the columns[] and the actions[], as well as the addition of the onAction() method.

The columns:

1  columns = [
2    { field: 'id', header: 'No.' },
3    { field: 'buyerEmail', header: 'Buyer Email' },
4    { field: 'orderDate', header: 'Order Date', pipe: 'date', pipeArgs: { year: 'numeric', month: 'short', day: 'numeric' } },
5    { field: 'total', header: 'Total', pipe: 'currency', pipeArgs: 'USD' },
6    { field: 'status', header: 'Status' }
7  ];

These specify the same columns we had in the original table, but since we are passing these to the custom-table component we created and we are not in the context of an angular template we need an alternative way to specify the date and currency pipes so we add them as object properties in this columns array for handling by the getCellValue() method in the custom-table component.

The actions:

1  actions = [
2    {
3      label: 'View',
4      icon: 'visibility',
5      tooltip: 'View Order',
6      action: (row: any) => {
7        this.router.navigateByUrl(`/orders/${row.id}`)
8      }
9    },

Each action is surfaced as a button in the custom-table as an icon button. Each action has an action callback function which will take in the ‘row’ as a paramater and execute whatever functionality we need. The ‘row’ in this case is the ‘order’.

The onAction() method:

1  onAction(action: (row: any) => void, row: any) {
2    action(row);
3  }

This is designed to trigger the action associated with a specific table row when a user interacts with an action button. When an action is clicked (such as “View” or “Refund”) in the table, the onAction() method is called. This method simply executes the action() callback, passing the specific row of data for the corresponding order.

Next, open the template for this component and add the following code:

1<div class="text-2xl font-semibold mt-4">
2    Customer orders
3</div>
4
5<app-custom-table
6  [columns]="columns"
7  [dataSource]="orders"
8  [actions]="actions"
9  [totalItems]="totalItems"
10  [pageSize]="orderParams.pageSize"
11  (pageChange)="onPageChange($event)"
12></app-custom-table>

Here we make use of our custom-table, passing it the columns, the orders, the actions and the metadata needed for the pagination.

Next, open up the admin.component.ts and clean up any unused code. This is what you should have:

1import { Component } from '@angular/core';
2import {MatTabsModule} from '@angular/material/tabs';
3import { AdminOrdersComponent } from "./admin-orders/admin-orders.component";
4
5@Component({
6  selector: 'app-admin',
7  standalone: true,
8  imports: [
9    MatTabsModule,
10    AdminOrdersComponent
11],
12  templateUrl: './admin.component.html',
13  styleUrl: './admin.component.scss'
14})
15export class AdminComponent {
16
17}
18
19

Open up the template and replace the existing code with the following:

1<div class="min-h-screen">
2    <mat-tab-group class="bg-white">
3        <mat-tab label="Orders">
4            <app-admin-orders />
5        </mat-tab>
6        <mat-tab label="Catalog">
7            Catalog goes here
8        </mat-tab>
9        <mat-tab label="Customer service">
10            Customer service
11        </mat-tab>
12    </mat-tab-group>
13</div>
14

Browse to the admin page and you should be back to where you started, albeit with a simpler tailwind table, but this will make it easier for us going forward. Ensure you see the following:

Image

Next, we will use our new custom-table to create the admin catalog component content. Ensure the refund and view functionality still works.

Optional challenge

It would be nice if the action buttons had some color. See if you can implement button colors for the different actions.