Metronic

The Ultimate Bootstrap & Angular 6 Admin Theme Framework For Next Generation Applications

eCommerce

Angular Data Table: A complete example of Server Pagination, Filtering, Sorting, Grouping, Edit forms, Sub-items.

This documentation contains a complete practical example of how to use Metronic for any CRUD applications development.

We use angular-in-memory library for back-end emulation (Mock Back-end). This library emulates CRUD operations over a RESTy API. When your Real backend is done, for switching to it is please read the document How switching to the Real Back-end.

The end result of this documentation will be:

A complete example of how to implement (using Metronic theme) an Angular Material Data Table with server-side pagination, sorting and filtering using a custom CDK Data Source.

Scenario:

eCommerce application structure

The given scheme is an example of eCommerce application showing cars sale. The following mockup represents entities to work with: eCommerce models


Folders and files
eCommerce mockup

_core folder structure
FolderDescription

_server:

eCommerce server

Fake database (for real REST server simulation)

Used library angular-in-memory.

The folder contains service fake-api.service which is imported into
e-commerce.module.ts:

HttpClientInMemoryWebApiModule.forFeature(FakeApiService)

models:

eCommerce models

The folder contains description of entities listed in the application.

  • CustomerModel
  • ProductModel
  • ProductSpecificationModel
  • ProductRemarkModel

Entities are inherited from BaseModel class which operates next entities:

  • IEdit
  • IFilter
  • ILog

There is also data-sources folder described below.

services:

eCommerce services

Standard angular services with REST API calls.

utils:

eCommerce utils
Auxiliary services

Importing our services and Angular Material modules

Import all the Angular Material modules as described below. See e-commerce.module.ts code:

import { NgModule } from '@angular/core';
import { CommonModule,  } from '@angular/common';
import { RouterModule, Routes } from '@angular/router';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http';
import { TranslateModule } from '@ngx-translate/core';
import { PartialsModule } from '../../../../partials/partials.module';
import { ECommerceComponent } from './e-commerce.component';
import { HttpClientInMemoryWebApiModule } from 'angular-in-memory-web-api';
// Core
import { FakeApiService } from './_core/_server/fake-api.service';
// Core => Services
import { CustomersService } from './_core/services/customers.service';
import { OrdersService } from './_core/services/orders.service';
import { ProductRemarksService } from './_core/services/product-remarks.service';
import { ProductSpecificationsService } from './_core/services/product-specifications.service';
import { ProductsService } from './_core/services/products.service';
import { SpecificationsService } from './_core/services/specification.service';
// Core => Utils
import { HttpUtilsService } from './_core/utils/http-utils.service';
import { TypesUtilsService } from './_core/utils/types-utils.service';
import { LayoutUtilsService } from './_core/utils/layout-utils.service';
// Shared
import { ActionNotificationComponent } from './_shared/action-natification/action-notification.component';
import { DeleteEntityDialogComponent } from './_shared/delete-entity-dialog/delete-entity-dialog.component';
import { FetchEntityDialogComponent } from './_shared/fetch-entity-dialog/fetch-entity-dialog.component';
import { UpdateStatusDialogComponent } from './_shared/update-status-dialog/update-status-dialog.component';
import { AlertComponent } from './_shared/alert/alert.component';
// Customers
import { CustomersListComponent } from './customers/customers-list/customers-list.component';
import { CustomerEditDialogComponent } from './customers/customer-edit/customer-edit.dialog.component';
// Products
import { ProductsListComponent } from './products/products-list/products-list.component';
import { ProductEditComponent } from './products/product-edit/product-edit.component';
import { RemarksListComponent } from './products/_subs/remarks/remarks-list/remarks-list.component';
import { SpecificationsListComponent } from './products/_subs/specifications/specifications-list/specifications-list.component';
import { SpecificationEditDialogComponent } from './products/_subs/specifications/specification-edit/specification-edit-dialog.component';
// Orders
import { OrdersListComponent } from './orders/orders-list/orders-list.component';
import { OrderEditComponent } from './orders/order-edit/order-edit.component';
// Material
import {
  MatInputModule,
  MatPaginatorModule,
  MatProgressSpinnerModule,
  MatSortModule,
  MatTableModule,
  MatSelectModule,
  MatMenuModule,
  MatProgressBarModule,
  MatButtonModule,
  MatCheckboxModule,
  MatDialogModule,
  MatTabsModule,
  MatNativeDateModule,
  MatCardModule,
  MatRadioModule,
  MatIconModule,
  MatDatepickerModule,
  MatAutocompleteModule,
  MAT_DIALOG_DEFAULT_OPTIONS,
  MatSnackBarModule,
  MatTooltipModule
} from '@angular/material';

const routes: Routes = [
  {
    path: '',
    component: ECommerceComponent,
    children: [
      {
        path: '',
        redirectTo: 'customers',
        pathMatch: 'full'
      },
      {
        path: 'customers',
        component: CustomersListComponent
      },
      {
        path: 'orders',
        component: OrdersListComponent
      },
      {
        path: 'products',
        component: ProductsListComponent,
      },
      {
        path: 'products/add',
        component: ProductEditComponent
      },
      {
        path: 'products/edit',
        component: ProductEditComponent
      },
      {
        path: 'products/edit/:id',
        component: ProductEditComponent
      },
    ]
  }
];

@NgModule({
  imports: [
    MatDialogModule,
    CommonModule,
    HttpClientModule,
    PartialsModule,
    RouterModule.forChild(routes),
    FormsModule,
    ReactiveFormsModule,
    TranslateModule.forChild(),
    MatButtonModule,
    MatMenuModule,
    MatSelectModule,
    MatInputModule,
    MatTableModule,
    MatAutocompleteModule,
    MatRadioModule,
    MatIconModule,
    MatNativeDateModule,
    MatProgressBarModule,
    MatDatepickerModule,
    MatCardModule,
    MatPaginatorModule,
    MatSortModule,
    MatCheckboxModule,
    MatProgressSpinnerModule,
    MatSnackBarModule,
    MatTabsModule,
    MatTooltipModule,
    HttpClientInMemoryWebApiModule.forFeature(FakeApiService)
 ],
  providers: [
    {
      provide: MAT_DIALOG_DEFAULT_OPTIONS,
      useValue: {
        hasBackdrop: true,
        panelClass: 'm-mat-dialog-container__wrapper', // CSS wrapper for Material dialog
        height: 'auto',
        width: '900px'
      }
    },
      HttpUtilsService,
      CustomersService,
      OrdersService,
      ProductRemarksService,
      ProductSpecificationsService,
      ProductsService,
      SpecificationsService,
      TypesUtilsService,
      LayoutUtilsService
  ],
  entryComponents: [
    ActionNotificationComponent,
    CustomerEditDialogComponent,
    DeleteEntityDialogComponent,
    FetchEntityDialogComponent,
    UpdateStatusDialogComponent,
    SpecificationEditDialogComponent
  ],
  declarations: [
    ECommerceComponent,
    // Shared
    ActionNotificationComponent,
    DeleteEntityDialogComponent,
    FetchEntityDialogComponent,
    UpdateStatusDialogComponent,
    AlertComponent,
    // Customers
    CustomersListComponent,
    CustomerEditDialogComponent,
    // Orders
    OrdersListComponent,
    OrderEditComponent,
    // Products
    ProductsListComponent,
    ProductEditComponent,
    RemarksListComponent,
    SpecificationsListComponent,
    SpecificationEditDialogComponent
  ]
})
export class ECommerceModule { }

Here is a breakdown of the contents of common 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

Сustomer list component

In order to have full understanding of what is going on we strongly recommend that you read the article https://blog.angular-university.io/angular-material-data-table. The article describes the bases of angular Material Data Table formation and solutions for specific tasks (Data binding and Material Table, Sorting, Paginator and Filtration).

Also, to understand how Grouping works we strongly recommend that you read official documentation of Material Angular Table (Selection feature)

The ‘Customers list’ component view:

eCommerce customers table view

Material Table with custom DataSource:

customers/customer-list/customer-list.component.html:

<mat-table class="lmat-elevation-z8" [dataSource]="dataSource" matSort matSortActive="id" matSortDirection="asc" matSortDisableClear>
  <!-- Material table HTML -->
  <mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
  <mat-row *matRowDef="let row; columns: displayedColumns"></mat-row>
</mat-table>
<div class="mat-table__message" *ngIf="!dataSource.hasItems">No records found</div><!-- Message for empty data  -->

customers/customer-list/customer-list.component.ts:

// Importing Material
import { SelectionModel } from '@angular/cdk/collections';
import { MatPaginator, MatSort, MatSnackBar, MatDialog } from '@angular/material';
// Importing CustomerDataSource - extends BaseDataSource 
import { CustomersDataSource } from '../../_core/models/data-sources/customers.datasource';
// ...
// ...
export class CustomersListComponent implements OnInit {
// Variables declaration
// ...
  // Columns which should view in table
  displayedColumns = ['select', 'id', 'lastName', 'firstName', 'email', 'gender', 'status', 'type', 'actions'];
// ...
// ...
  constructor(private customersService: CustomersService, ***other services) {}
// ...
  /** LOAD DATA */
  ngOnInit() {
    // ...
    this.dataSource = new CustomersDataSource(this.customersService); //Init DataSource
    // First load
    this.dataSource.loadCustomers(queryParams); // Loading data
    // ...
  }
}

Our CustomerDataSource extends BaseDataSource.

_core/models/data-sources/_base.datasource.ts:

import { CollectionViewer, DataSource } from '@angular/cdk/collections';
import { Observable, BehaviorSubject, from } from 'rxjs';
import { QueryParamsModel } from '../query-models/query-params.model';
import { QueryResultsModel } from '../query-models/query-results.model';
import { BaseModel } from '../_base.model';
import * as _ from 'lodash';

// 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.
  Read the article: 'https://blog.angular-university.io/angular-material-data-table/'
**/
export class BaseDataSource implements DataSource {
  entitySubject = new BehaviorSubject([]);
  hasItems: boolean = false; // Need to show message: 'No records found'

  // Loading | Progress bar
  loadingSubject = new BehaviorSubject(false);
  loading$: Observable;

  // Paginator | Paginators count
  paginatorTotalSubject = new BehaviorSubject(0);
  paginatorTotal$: Observable;

  constructor() {
    this.loading$ = this.loadingSubject.asObservable();
    this.paginatorTotal$ = this.paginatorTotalSubject.asObservable();
    this.paginatorTotal$.subscribe(res => this.hasItems = res > 0);
  }

  connect(collectionViewer: CollectionViewer): Observable {
    // Connecting data source
    return this.entitySubject.asObservable();
  }

  disconnect(collectionViewer: CollectionViewer): void {
    // Disonnecting data source
    this.entitySubject.complete();
    this.loadingSubject.complete();
    this.paginatorTotalSubject.complete();
  }
}

The next table contains fields added by Metronic into standard DataSource:

FieldDescription
loadingSubject:  BehaviorSubject
loading$:  Observable<boolean>

Used to display the load item. Example of use in customers/customer-list/customer-list.component.html:

<m-portlet [options]="{headLarge: true}" [loading$]="dataSource.loading$">

hasItems: boolean

Used to display text No records found if the result of the query returns an empty data array

<div class="mat-table__message" *ngIf="!dataSource.hasItems">No records found</div>

CustomersDataSource is inherited from BaseDataSource and has the only method loadCustomers:

import { Observable, of } from 'rxjs';
import { catchError, finalize, tap } from 'rxjs/operators';
import { CustomersService } from '../../services/customers.service';
import { QueryParamsModel } from '../query-models/query-params.model';
import { BaseDataSource } from './_base.datasource';
import { QueryResultsModel } from '../query-models/query-results.model';

export class CustomersDataSource extends BaseDataSource {
  constructor(private customersService: CustomersService) {
    super(); // Call BaseDataSource constructor
  }

  loadCustomers(queryParams: QueryParamsModel) {
    this.loadingSubject.next(true);
    this.customersService.findCustomers(queryParams).pipe(
      tap(res => {
        this.entitySubject.next(res.items); // Updating data
        this.paginatorTotalSubject.next(res.totalCount); // Refreshing paginator
      }),
       catchError(err => of(new QueryResultsModel([], err))),
       finalize(() => this.loadingSubject.next(false)) // Hiding loading
    ).subscribe();
  }
}

loadCustomers method is used to populate the table by using customers/customer-list/customer-list.component.ts component and has one queryParams input parameter with QueryParamsModel type:                                                                                                                                                                                                                                            
QueryParamsModel class intended for the wrapping of request object and sending to the server
Field Type Description
filteranyFiltration object.
For example, filtration object for customers is CustomerModel
sortOrderstringstring; // asc || desc
asc - is default value
sortFieldstringThe filed intended for sorting. id is the default sorting value for Customers.
pageNumbernumberPaginator number.
1 is default value
pageSizenumberThe field determines the number of rows displayed in the table.
By default pageSize equals 10.


loadCustomers method calls findCustomers(queryParams) from _core/services/customers.service.ts:

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, forkJoin, of } from 'rxjs';
import { mergeMap } from 'rxjs/operators';
import { HttpUtilsService } from '../utils/http-utils.service';
import { CustomerModel } from '../models/customer.model';
import { QueryParamsModel } from '../models/query-models/query-params.model';
import { QueryResultsModel } from '../models/query-models/query-results.model';

const API_CUSTOMERS_URL = 'api/customers';

@Injectable()
export class CustomersService {
  constructor(private http: HttpClient, private httpUtils: HttpUtilsService) { }

  // Method from server should return instance of QueryResultsModel type 
  // QueryResultsModel type  has two fields =>
  // 1. items:CustomerModel[]
  // 2. totalsCount: number
  findCustomers(queryParams: QueryParamsModel): Observable {
    const params = this.httpUtils.getFindHTTPParams(queryParams);
    const url = this.API_CUSTOMERS_URL + '/find';
    return this.http.get(url, params); // Server call which return filter&sorting result
  }
}

Data table Sorting:

Sorting view in the ‘Customers list’ component:

eCommerce sorting

customers/customer-list/customer-list.component.html:


<mat-table class="lmat-elevation-z8" 
	[dataSource]="dataSource" 
	matSort 
	matSortActive="id" 
	matSortDirection="asc" 
	matSortDisableClear>
  <ng-container matColumnDef="id">
    <!-- ATTRIBUTE mat-sort-header  for sorting | https://material.angular.io/components/sort/overview -->
    <mat-header-cell *matHeaderCellDef mat-sort-header>ID</mat-header-cell>
    <mat-cell *matCellDef="let customer">{{customer.id}}</mat-cell>
  </ng-container>
</mat-table>

customers/customer-list/customer-list.component.ts:


// Importing Material MatSort
import { MatSort } from '@angular/material';
// ...
export class CustomersListComponent implements OnInit {
  // ...
  // Variables declaration
  @ViewChild(MatSort) sort: MatSort;
  // ...
  ngOnInit() {
    // If the user changes the sort order, reset back to the first page.
    this.sort.sortChange.subscribe(() => (this.paginator.pageIndex = 0));

    /* Data load will be triggered in two cases:
    - when a pagination event occurs => this.paginator.page
    - when a sort event occurs => this.sort.sortChange
    **/
    merge(this.sort.sortChange, this.paginator.page)
      .pipe(
        tap(() => { this.loadCustomersList(); })
      )
      .subscribe();
  }
  // ...
}
			

Data table Paginator:

Paginator view in 'Customers list' component:

eCommerce paginator

customers/customer-list/customer-list.component.html:


<!-- MATERIAL PAGINATOR | Binded to dasources -->
<!-- See off.documentations 'https://material.angular.io/components/paginator/overview' -->
<mat-paginator [pageSize]="10" [pageSizeOptions]="[3, 5, 10]" [length]="dataSource.paginatorTotal$ | async" [showFirstLastButtons]="true"></mat-paginator>
			

customers/customer-list/customer-list.component.ts:


// Importing Material MatPaginator
import { MatPaginator } from '@angular/material';
// ...
export class CustomersListComponent implements OnInit {
  // ...
  // Variables declaration
  @ViewChild(MatPaginator) paginator: MatPaginator;
  // ...
  ngOnInit() {
    /* Data load will be triggered in two cases:
    - when a pagination event occurs => this.paginator.page
    - when a sort event occurs => this.sort.sortChange
    **/
    merge(this.sort.sortChange, this.paginator.page)
      .pipe(
        tap(() => { this.loadCustomersList(); })
      )
      .subscribe();
  }
  // ...
}
			

Data table Progress spinner (Loading element):

Progress spinner (loading element) view in the ‘Customers list’ component:

eCommerce spinner

customers/customer-list/customer-list.component.html:


<!-- MATERIAL SPINNER | Url: 'https://material.angular.io/components/progress-spinner/overview' -->
<mat-spinner [diameter]="20" *ngIf="dataSource.loading$ | async"></mat-spinner>

customers/customer-list/customer-list.component.html:

//nothing is needed, loading is binded in BaseDataSource

Data table Grouping:

Grouping view in the ‘Customers list’ component:

eCommerce grouping

customers/customer-list/customer-list.component.html:


<!-- STYCKY PORTLET CONTROL | See structure => /metronic/sticky-form-actions -->
<m-portlet [options]="{headLarge: true}" [loading$]="dataSource.loading$">
  <!-- start::Body (attribute: mPortletBody) -->
  <ng-container mPortletBody>
    <div class="m-form">
      <!-- start::GROUP ACTIONS -->
      <!-- Group actions list: 'Delete selected' | 'Fetch selected' | 'Update status for selected' -->
      <!-- Group actions are shared for all LISTS | See '../../_shared' folder -->
      <div class="row align-items-center collapse m-form__group-actions m--margin-top-20 m--margin-bottom-20"
        [ngClass]="{'show' : selection.selected.length > 0}">
        <!-- We show 'Group Actions' div if smth are selected -->
        <div class="col-xl-12">
          <div class="m-form__group m-form__group--inline">
            <div class="m-form__label m-form__label-no-wrap">
              <label class="m--font-bold m--font-danger-">
                <span translate="ECOMMERCE.COMMON.SELECTED_RECORDS_COUNT"></span> {{ selection.selected.length }}
              </label>
              <!-- selectedCountsTitle => function from codeBehind (customer-list.component.ts file) -->
              <!-- selectedCountsTitle => just returns title of selected items count -->
              <!-- for example: Selected records count: 9 -->
            </div>
            <div class="m-form__control m-form__group--inline">
              <button (click)="deleteCustomers()" mat-raised-button color="accent" matTooltip="Delete selected customers">
                <mat-icon>delete</mat-icon> Delete All
              </button> <!-- Call 'delete-entity-dialog' from _shared folder -->
              <button  (click)="fetchCustomers()" mat-raised-button matTooltip="Fetch selected customers">
                <mat-icon>clear_all</mat-icon> Fetch Selected
              </button> <!-- Call 'fetch-entity-dialog' from _shared folder -->
              <button (click)="updateStatusForCustomers()" mat-raised-button matTooltip="Update status for selected customers">
                <mat-icon>update</mat-icon> Update status
              </button><!-- Call 'update-stated-dialog' from _shared folder -->
             </div>
          </div>
        </div>
      </div>
      <!-- end::GROUP ACTIONS -->
    </div>
  </ng-container>
  <!-- end::Body -->
  <!-- other code -->
  <div class="mat-table__wrapper">
    <mat-table class="lmat-elevation-z8" [dataSource]="dataSource" matSort matSortActive="id" matSortDirection="asc" matSortDisableClear>
      <!-- Checkbox Column -->

      <!-- Table with selection -->
      <!-- https://run.stackblitz.com/api/angular/v1?file=app%2Ftable-selection-example.ts -->
      <ng-container matColumnDef="select">
        <mat-header-cell *matHeaderCellDef class="mat-column-checkbox">
          <mat-checkbox (change)="$event ? masterToggle() : null"
            [checked]="selection.hasValue() && isAllSelected()"
            [indeterminate]="selection.hasValue() && !isAllSelected()">
          </mat-checkbox>
        </mat-header-cell>
        <mat-cell *matCellDef="let row" class="mat-column-checkbox">
          <mat-checkbox (click)="$event.stopPropagation()" (change)="$event ? selection.toggle(row) : null" [checked]="selection.isSelected(row)">
          </mat-checkbox>
        </mat-cell>
      </ng-container>
      <!-- Other columns -->
    </mat-table>
  </div>
</m-portlet>

customers/customer-list/customer-list.component.ts:


// Importing Material SelectionModel
import { SelectionModel } from '@angular/cdk/collections';
// ...
export class CustomersListComponent implements OnInit {
  // ...
  // Variables declaration
	// Selection
  selection = new SelectionModel(true, []);
	customersResult: CustomerModel[] = [];  
  // ...
  ngOnInit() {
    // ...
    this.dataSource.entitySubject.subscribe(res => (this.customersResult = res));
    // ...
  }
  // ...
  // ...
  /** SELECTION */
  isAllSelected(): boolean {
    const numSelected = this.selection.selected.length;
    const numRows = this.customersResult.length;
    return numSelected === numRows;
  }

  masterToggle() {
    if (this.selection.selected.length === this.customersResult.length) {
      this.selection.clear();
    } else {
      this.customersResult.forEach(row => this.selection.select(row));
    }
  }
  // ...
}

Data table Filtration:

Filtration view in the ‘Customers list’ component:

eCommerce filtration

customers/customer-list/customer-list.component.html:


<!-- STYCKY PORTLET CONTROL | See structure => /metronic/sticky-form-actions -->
<m-portlet [options]="{headLarge: true}" [loading$]="dataSource.loading$">
  <!-- start::Body (attribute: mPortletBody) -->
  <ng-container mPortletBody>
    <div class="m-form">
      <!-- start::FILTERS -->
      <div class="m-form__filtration">
        <div class="row align-items-center">

          <div class="col-md-2 m--margin-bottom-10-mobile">
            <!-- 'm  margin-bottom-10-mobile' for adaptive make-up  -->
            <div class="m-form__control">
              <mat-form-field class="mat-form-field-fluid">
                <mat-select [(value)]="filterStatus" (selectionChange)="loadCustomersList()">
                  <mat-option value="">All</mat-option>
                  <mat-option value="0">Suspended</mat-option>
                  <mat-option value="1">Active</mat-option>
                  <mat-option value="Pending">Pending</mat-option>
               </mat-select>
               <mat-hint align="start">
                 <strong>Filter</strong> by Status</mat-hint>
              </mat-form-field>
            </div>
          </div>

          <div class="col-md-2 m--margin-bottom-10-mobile">
            <div class="m-form__control">
              <mat-form-field class="mat-form-field-fluid">
                <mat-select [(value)]="filterType" (selectionChange)="loadCustomersList()">
                  <mat-option value="">All</mat-option>
                  <mat-option value="0">Business</mat-option>
                  <mat-option value="1">Individual</mat-option>
                </mat-select>
                <mat-hint align="start">
                  <strong>Filter</strong> by Type</mat-hint>
              </mat-form-field>
            </div>
          </div>

          <div class="col-md-2 m--margin-bottom-10-mobile">
            <mat-form-field class="mat-form-field-fluid">
              <input matInput placeholder="Search customer" #searchInput placeholder="Search">
              <mat-hint align="start">
                <strong>Search</strong> in all fields</mat-hint>
            </mat-form-field>
          </div>

        </div>
      </div>
      <!-- end::FILTERS --> 
		
    </div>
  </ng-container>
  <!-- other code -->
</m-portlet>

customers/customer-list/customer-list.component.ts:


// Importing RXJS functions
// RXJS
import { debounceTime, distinctUntilChanged, tap } from 'rxjs/operators';
import { fromEvent, merge, forkJoin } from 'rxjs';
// ...
export class CustomersListComponent implements OnInit {
  // ...
  // Variables declaration
  // Filter fields
  @ViewChild('searchInput') searchInput: ElementRef;
  filterStatus: string = '';
  filterType: string = '';
  // ...
  ngOnInit() {
    // ...
    // Filtration, bind to searchInput
    fromEvent(this.searchInput.nativeElement, 'keyup')
      .pipe(
        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
        tap(() => {
          this.paginator.pageIndex = 0;
          this.loadCustomersList();
        })
      )
      .subscribe();
    // ...
  }
  // ...
  // ...
  /** FILTRATION */
  filterConfiguration(isGeneralSearch: boolean = true): any {
    const filter: any = {};
    const searchText: string = this.searchInput.nativeElement.value; // Read from input

    if (this.filterStatus && this.filterStatus.length > 0) {
      filter.status = +this.filterStatus; // Read from select
    }

    if (this.filterType && this.filterType.length > 0) {
      filter.type = +this.filterType; // Read from select
    }

    filter.lastName = searchText;
    if (!isGeneralSearch) { // Check for first loading
      return filter;
    }

    filter.firstName = searchText;
    filter.email = searchText;
    filter.ipAddress = searchText;
    return filter;
  }
  // ...
}

Data table Empty data Warning (in case of data absence):

Empty data Warning (in case of data absence) view in the ‘Customers list’ component:

eCommerce empty

customers/customer-list/customer-list.component.html:

<!-- Message for empty data  -->
<div class="mat-table__message" *ngIf="!dataSource.hasItems">No records found</div>

customers/customer-list/customer-list.component.html:

//nothing is needed, hasItems is binded in BaseDataSource

Interceptor for HTTP Requests & Responses

By using HttpInteceptors we can cache, log, debug and catch errors during work with REST Api. More information about HTTP Interceptors you can get from following article https://medium.com/@MetonymyQT/angular-http-interceptors-what-are-they-and-how-to-use-them-52e060321088

_core/utils/intercept.service.ts:


import { Injectable } from '@angular/core';
import {
  HttpEvent,
  HttpInterceptor,
  HttpHandler,
  HttpRequest,
  HttpResponse
} from '@angular/common/http';
import { Observable } from 'rxjs';
import { catchError, tap } from 'rxjs/operators';

@Injectable()
export class InterceptService implements HttpInterceptor {
  // intercept request and add token
  intercept(
    request: HttpRequest,
    next: HttpHandler
  ): Observable> {
    // modify request
    request = request.clone({
      setHeaders: {
        Authorization: `Bearer ${localStorage.getItem('accessToken')}`
      }
    });
    // console.log('----request----');
    console.log(request);
    // console.log('--- end of request---');

    return next.handle(request).pipe(
      tap(
        event => {
          if (event instanceof HttpResponse) {
            // console.log('all looks good');
            // http response status code
            console.log(event.status);
          }
        },
        error => {
          // http response status code
          // console.log('----response----');
          // console.error('status code:');
          console.error(error.status);
          console.error(error.message);
          // console.log('--- end of response---');
        }
      )
    );
  }
}

Now let's add our service to e-commerce.module.ts

e-commerce.module.ts:


//...
providers: [
    InterceptService,
      	{
          provide: HTTP_INTERCEPTORS,
          useClass: InterceptService,
          multi: true
      	},
    //..
  ]
//...    
    


Actions

'Delete item' action

eCommerce delete item

eCommerce delete item confirm

customers/customer-list/customer-list.component.ts:


<m-portlet [options]="{headLarge: true}" [loading$]="dataSource.loading$">
  <ng-container mPortletBody>
    <div class="mat-table__wrapper">
      <mat-table class="lmat-elevation-z8" [dataSource]="dataSource" matSort matSortActive="id" matSortDirection="asc" matSortDisableClear>
        <!-- other columns -->
        <ng-container matColumnDef="actions">
          <mat-header-cell *matHeaderCellDef>Actions</mat-header-cell>
          <mat-cell *matCellDef="let customer">
            <button mat-icon-button color="warn" 
              matTooltip="Delete customer" 
              type="button" 
              (click)="deleteCustomer(customer)">
              <mat-icon>delete</mat-icon>
            </button>
            <!-- other actions -->
          </mat-cell>
        </ng-container>
      </mat-table>
    </div>

   </ng-container>
</m-portlet>

customers/customer-list/customer-list.component.html:


// Importing Services
import { CustomersService } from '../../_core/services/customers.service';
import { LayoutUtilsService, MessageType } from '../../_core/utils/layout-utils.service';
import { TranslateService } from '@ngx-translate/core';
// ...
export class CustomersListComponent implements OnInit {
  // ...
  // Variables declaration
  // ...
  constructor(private customersService: CustomersService, 
    private layoutUtilsService: LayoutUtilsService,
    private translate: TranslateService) {}
  // ...
  /** ACTIONS */
  /** Delete */
  deleteCustomer(_item: CustomerModel) {
    // Translated messages
    const _title: string = this.translate.instant('ECOMMERCE.CUSTOMERS.DELETE_CUSTOMER_SIMPLE.TITLE');
    const _description: string = this.translate.instant('ECOMMERCE.CUSTOMERS.DELETE_CUSTOMER_SIMPLE.DESCRIPTION');
    const _waitDesciption: string = this.translate.instant('ECOMMERCE.CUSTOMERS.DELETE_CUSTOMER_SIMPLE.WAIT_DESCRIPTION');
    const _deleteMessage = this.translate.instant('ECOMMERCE.CUSTOMERS.DELETE_CUSTOMER_SIMPLE.MESSAGE');

    // Confirmation Dialog
    const dialogRef = this.layoutUtilsService.deleteElement(_title, _description, _waitDesciption);
    dialogRef.afterClosed().subscribe(res => {
      if (!res) {
        return; // User canceled action
      }

      // Server call
      this.customersService.deleteCustomer(_item.id).subscribe(() => {
        this.layoutUtilsService.showActionNotification(_deleteMessage, MessageType.Delete);
        this.loadCustomersList();
      });
    });
  }
}

deleteCustomer method calls customersService.deleteCustomer method within itself.

_core/services/customers.service.ts:

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, forkJoin, of } from 'rxjs';
import { mergeMap } from 'rxjs/operators';
import { HttpUtilsService } from '../utils/http-utils.service';
import { CustomerModel } from '../models/customer.model';
import { QueryParamsModel } from '../models/query-models/query-params.model';
import { QueryResultsModel } from '../models/query-models/query-results.model';

const API_CUSTOMERS_URL = 'api/customers';

@Injectable()
export class CustomersService {
  constructor(private http: HttpClient, private httpUtils: HttpUtilsService) { }

  // DELETE => delete the customer from the server
  deleteCustomer(customerId: number): Observable {
    const url = `${API_CUSTOMERS_URL}/${customerId}`;
    return this.http.delete(url);
  }
	
  // DELETE => delete selected customers from the server
  deleteCustomers(ids: number[] = []) {
    const url = this.API_CUSTOMERS_URL + '/delete';
    return this.http.get(url, { params: ids });
  }
}

'Delete selected items' action

eCommerce delete selected

customers/customer-list/customer-list.component.html:


<m-portlet [options]="{headLarge: true}" [loading$]="dataSource.loading$">
  <ng-container mPortletBody>
	
  <!-- start::GROUP ACTIONS -->
  <!-- Group actions list: 'Delete selected' | 'Fetch selected' | 'Update status for selected' -->
  <!-- Group actions are shared for all LISTS | See '../../_shared' folder -->
    <div class="row align-items-center collapse m-form__group-actions m--margin-top-20 m--margin-bottom-20"
      [ngClass]="{'show' : selection.selected.length > 0}">
      <!-- We show 'Group Actions' div if smth are selected -->
      <div class="col-xl-12">
        <div class="m-form__group m-form__group--inline">
          <div class="m-form__label m-form__label-no-wrap">
            <label class="m--font-bold m--font-danger-">
							<span translate="ECOMMERCE.COMMON.SELECTED_RECORDS_COUNT"></span> {{ selection.selected.length }}
            </label>
            <!-- selectedCountsTitle => function from codeBehind (customer-list.component.ts file) -->
            <!-- selectedCountsTitle => just returns title of selected items count -->
            <!-- for example: Selected records count: 4 -->
          </div>
          <div class="m-form__control m-form__group--inline">
            <button (click)="deleteCustomers()" mat-raised-button color="accent" matTooltip="Delete selected customers">
              <mat-icon>delete</mat-icon> Delete All
            </button> <!-- Call 'delete-entity-dialog' from _shared folder -->
          </div>
        </div>
      </div>
		</div>
    <!-- end::GROUP ACTIONS -->

   </ng-container>
</m-portlet>

customers/customer-list/customer-list.component.html:


// Importing Services
import { CustomersService } from '../../_core/services/customers.service';
import { LayoutUtilsService, MessageType } from '../../_core/utils/layout-utils.service';
import { TranslateService } from '@ngx-translate/core';
// ...
export class CustomersListComponent implements OnInit {
  // ...
  // Variables declaration
  // ...
  constructor(private customersService: CustomersService, 
    private layoutUtilsService: LayoutUtilsService,
    private translate: TranslateService) {}
  // ...
  /** ACTIONS */
  deleteCustomers() {
    const _title: string = this.translate.instant('ECOMMERCE.CUSTOMERS.DELETE_CUSTOMER_MULTY.TITLE');
    const _description: string = this.translate.instant('ECOMMERCE.CUSTOMERS.DELETE_CUSTOMER_MULTY.DESCRIPTION');
    const _waitDesciption: string = this.translate.instant('ECOMMERCE.CUSTOMERS.DELETE_CUSTOMER_MULTY.WAIT_DESCRIPTION');
    const _deleteMessage = this.translate.instant('ECOMMERCE.CUSTOMERS.DELETE_CUSTOMER_MULTY.MESSAGE');

    const dialogRef = this.layoutUtilsService.deleteElement(_title, _description, _waitDesciption);
    dialogRef.afterClosed().subscribe(res => {
    if (!res) {
      return;
    }

    const idsForDeletion: number[] = [];
      for (let i = 0; i < this.selection.selected.length; i++) {
        idsForDeletion.push(this.selection.selected[i].id);
			}
      // Server call
      this.customersService.deleteCustomers(idsForDeletion)
      .subscribe(() => {
        this.layoutUtilsService.showActionNotification(_deleteMessage, MessageType.Delete);
        this.loadCustomersList();
        this.selection.clear();
      });
    });
  }
}

'Create & Edit' item in modal:

'Edit & Create' items in 'Customers list' component:

eCommerce edit

customers/customer-list/customer-list.component.html:


<m-portlet [options]="{headLarge: true}" [loading$]="dataSource.loading$">

  <ng-container mPortletHeadTools>
    <button (click)="addCustomer()" mat-raised-button matTooltip="Create new customer" color="primary" type="button">
    <span translate="ECOMMERCE.CUSTOMERS.NEW_CUSTOMER">New Customer</span>
    </button>
    <!-- Buttons (Material Angular) | See off.documenations 'https://material.angular.io/components/button/overview' -->
    <!-- mat-raised-button | Rectangular contained button w/ elevation  -->
  </ng-container>
  <!-- end::Header -->

  <ng-container mPortletBody>
    <div class="mat-table__wrapper">
      <mat-table class="lmat-elevation-z8" [dataSource]="dataSource" matSort matSortActive="id" matSortDirection="asc" matSortDisableClear>
        <!-- other columns -->
        <ng-container matColumnDef="actions">
          <mat-header-cell *matHeaderCellDef>Actions</mat-header-cell>
          <mat-cell *matCellDef="let customer">
            <button mat-icon-button color="primary" 
              matTooltip="Edit customer" 
              (click)="editCustomer(customer)">
              <mat-icon>create</mat-icon>
            </button>
            <!-- other actions -->
          </mat-cell>
        </ng-container>
      </mat-table>
    </div>

   </ng-container>
</m-portlet>

customers/customer-list/customer-list.component.ts:


// Services
import { CustomersService } from '../../_core/services/customers.service';
import { LayoutUtilsService, MessageType } from '../../_core/utils/layout-utils.service';
// ...
export class CustomersListComponent implements OnInit {
  // ...
  // Variables declaration
	
  constructor(
    private customersService: CustomersService,
    public dialog: MatDialog,
    public snackBar: MatSnackBar,
    private layoutUtilsService: LayoutUtilsService,
    private translate: TranslateService
  ) {}

  addCustomer() {
    const newCustomer = new CustomerModel();
    newCustomer.clear(); // Set all defaults fields
    this.editCustomer(newCustomer);
  }

  /** Edit */
  editCustomer(customer: CustomerModel) {
    let saveMessageTranslateParam = 'ECOMMERCE.CUSTOMERS.EDIT.';
    saveMessageTranslateParam += customer.id > 0 ? 'UPDATE_MESSAGE' : 'ADD_MESSAGE';
    const _saveMessage = this.translate.instant(saveMessageTranslateParam);
    const _messageType = customer.id > 0 ? MessageType.Update : MessageType.Create;
    // Call 'Edit & Create' modal
    const dialogRef = this.dialog.open(CustomerEditDialogComponent, { data: { customer } });
    dialogRef.afterClosed().subscribe(res => {
      if (!res) {
        return; // The action was canceled by user
      }

      this.layoutUtilsService.showActionNotification(_saveMessage, _messageType, 10000, true, false);
      this.loadCustomersList();
    });
  }
}

eCommerce edit modal

eCommerce edit

customers/customer-edit/customer-edit.dialog.component.html:


<div class="m-portlet" [ngClass]="{ 'm-portlet--body-progress' : viewLoading, 'm-portlet--body-progress-overlay' : loadingAfterSubmit }">
  <div class="m-portlet__head">
    <div class="m-portlet__head-caption">
      <div class="m-portlet__head-title">
        <span class="m-portlet__head-icon m--hide">
          <i class="la la-gear"></i>
        </span>
        <h3 class="m-portlet__head-text">{{getTitle()}}</h3>
      </div>
    </div>
  </div>
  <form class="m-form" [formGroup]="customerForm">
    <div class="m-portlet__body">

      <div class="m-portlet__body-progress">
        <mat-spinner [diameter]="20"></mat-spinner>
      </div>

      <m-alert *ngIf="hasFormErrors" type="warn" [duration]="30000" [showCloseButton]="true" (close)="onAlertClose($event)">
        Oh snap! Change a few things up and try submitting again.
      </m-alert>

      <div class="form-group m-form__group row">
        <div class="col-lg-4 m--margin-bottom-20-mobile">
           <mat-form-field class="mat-form-field-fluid">
            <input matInput placeholder="Enter First Name" formControlName="firstName" />
            <mat-error>First Name is
              <strong>required</strong>
            </mat-error>
            <mat-hint align="start">Please enter
              <strong>First Name</strong>
            </mat-hint>
          </mat-form-field>
        </div>
        <div class="col-lg-4 m--margin-bottom-20-mobile">
          <mat-form-field class="mat-form-field-fluid">
            <input matInput placeholder="Enter Last Name" formControlName="lastName" />
            <mat-error>Last Name is
              <strong>required</strong>
            </mat-error>
            <mat-hint align="start">Please enter
              <strong>Last Name</strong>
            </mat-hint>
          </mat-form-field>
        </div>
        <div class="col-lg-4 m--margin-bottom-20-mobile">
          <mat-form-field class="mat-form-field-fluid">
            <input matInput placeholder="Enter Login" formControlName="userName" />
            <mat-error>Login is
              <strong>required</strong>
            </mat-error>
            <mat-hint align="start">Please enter
              <strong>Login</strong>
            </mat-hint>
          </mat-form-field>
        </div>
      </div>

      <div class="m-separator m-separator--dashed"></div>

      <div class="form-group m-form__group row">
        <div class="col-lg-4 m--margin-bottom-20-mobile">
          <mat-form-field class="mat-form-field-fluid">
            <input type="email" matInput placeholder="Enter Email" formControlName="email" />
            <mat-error>Email is
              <strong>required</strong>
            </mat-error>
            <mat-hint align="start">Please enter
              <strong>Email</strong>
            </mat-hint>
          </mat-form-field>
        </div>
        <div class="col-lg-4 m--margin-bottom-20-mobile">
          <mat-form-field class="mat-form-field-fluid">
            <input matInput [matDatepicker]="picker" placeholder="Choose a Date of Birth" formControlName="dob" />
            <mat-datepicker-toggle matSuffix [for]="picker"></mat-datepicker-toggle>
            <mat-datepicker #picker></mat-datepicker>
            <mat-hint align="start">Please enter
              <strong>Date of Birth</strong> in 'mm/dd/yyyy' format</mat-hint>
            </mat-form-field>
          </div>
          <div class="col-lg-4 m--margin-bottom-20-mobile">
            <mat-form-field class="mat-form-field-fluid">
              <input type="email" matInput placeholder="Enter IP Address" formControlName="ipAddress" />
              <mat-error>IP Address
                <strong>required</strong>
              </mat-error>
              <mat-hint align="start">We'll never share customer
                <strong>IP Address</strong> with anyone else</mat-hint>
            </mat-form-field>
          </div>
        </div>

        <div class="m-separator m-separator--dashed"></div>

        <div class="form-group m-form__group row">
          <div class="col-lg-4 m--margin-bottom-20-mobile">
            <mat-form-field class="mat-form-field-fluid">
              <mat-select placeholder="Gender" formControlName="gender">
                <mat-option value="Female">Female</mat-option>
                <mat-option value="Male">Male</mat-option>
              </mat-select>
              <mat-hint align="start">
                <strong>Gender</strong>
              </mat-hint>
            </mat-form-field>
          </div>
        <div class="col-lg-4 m--margin-bottom-20-mobile">
          <mat-form-field class="mat-form-field-fluid">
            <mat-select placeholder="Type" formControlName="type">
              <mat-option value="0">Business</mat-option>
              <mat-option value="1">Individual</mat-option>
            </mat-select>
            <mat-hint align="start">
              <strong>Account Type</strong>
            </mat-hint>
          </mat-form-field>
        </div>
      </div>
    </div>
    <div class="m-portlet__foot m-portlet__no-border m-portlet__foot--fit">
      <div class="m-form__actions m-form__actions--solid">
        <div class="row text-right">
          <div class="col-lg-12">
            <button type="button" mat-raised-button [mat-dialog-close]="data.animal" cdkFocusInitial matTooltip="Cancel changes">
              Cancel
            </button> 
            <button type="button" mat-raised-button color="primary" (click)="onSubmit()" [disabled]="viewLoading" matTooltip="Save changes">
              Save
            </button>
          </div>
        </div>
      </div>
    </div>
  </form>
</div>
		

customers/customer-edit/customer-edit.dialog.component.ts:


import { Component, OnInit, Inject, ChangeDetectionStrategy } from '@angular/core';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { TypesUtilsService } from '../../_core/utils/types-utils.service';
import { CustomersService } from '../../_core/services/customers.service';
import { CustomerModel } from '../../_core/models/customer.model';

@Component({
  selector: 'm-customers-edit-dialog',
  templateUrl: './customer-edit.dialog.component.html',
  // changeDetection: ChangeDetectionStrategy.OnPush
})
export class CustomerEditDialogComponent implements OnInit {
  customer: CustomerModel;
	customerForm: FormGroup;
  hasFormErrors: boolean = false;
	viewLoading: boolean = false;
  loadingAfterSubmit: boolean = false;

  constructor(public dialogRef: MatDialogRef,
    @Inject(MAT_DIALOG_DATA) public data: any,
    private fb: FormBuilder,
    private customerService: CustomersService,
    private typesUtilsService: TypesUtilsService) { }

  /** LOAD DATA */
  ngOnInit() {
    this.customer = this.data.customer;
    this.createForm();

    /* Server loading imitation. Remove this on real code */
    this.viewLoading = true;
		  setTimeout(() => {
      this.viewLoading = false;
    }, 1000);
  }

  createForm() {
    this.customer.dob = this.typesUtilsService.getDateFromString(this.customer.dateOfBbirth);
    this.customerForm = this.fb.group({
      firstName: [this.customer.firstName, Validators.required],
      lastName: [this.customer.lastName, Validators.required],
      email: [
        this.customer.email,
        [Validators.required, Validators.email]
      ],
      dob: [this.customer.dob, Validators.nullValidator],
      userName: [this.customer.userName, Validators.required],
      gender: [this.customer.gender, Validators.required],
      ipAddress: [this.customer.ipAddress, Validators.required],
      type: [this.customer.type.toString(), Validators.required]
    });
  }

  /** UI */
  getTitle(): string {
    if (this.customer.id > 0) {
      return `Edit customer '${this.customer.firstName} ${this.customer.lastName}'`;
    }

    return 'New customer';
  }

  isControlInvalid(controlName: string): boolean {
    const control = this.customerForm.controls[controlName];
    const result = control.invalid && control.touched;
    return result;
  }

  /** ACTIONS */
  prepareCustomer(): CustomerModel {
    const controls = this.customerForm.controls;
    const _customer = new CustomerModel();
   _customer.id = this.customer.id;
	 _customer.dateOfBbirth = this.typesUtilsService.dateCustomFormat(controls['dob'].value);
   _customer.firstName = controls['firstName'].value;
   _customer.lastName = controls['lastName'].value;
   _customer.email = controls['email'].value;
   _customer.userName = controls['userName'].value;
   _customer.gender = controls['gender'].value;
   _customer.ipAddress = controls['ipAddress'].value;
   _customer.type = +controls['type'].value;
   _customer.status = this.customer.status;
   return _customer;
  }

  onSubmit() {
    this.hasFormErrors = false;
    this.loadingAfterSubmit = false;
    const controls = this.customerForm.controls;
    /** check form */
    if (this.customerForm.invalid) {
      Object.keys(controls).forEach(controlName =>
        controls[controlName].markAsTouched()
      );

      this.hasFormErrors = true;
      return;
		}

    const editedCustomer = this.prepareCustomer();
    if (editedCustomer.id > 0) {
      this.updateCustomer(editedCustomer);
    } else {
      this.createCustomer(editedCustomer);
    }
  }

  updateCustomer(_customer: CustomerModel) {
    this.loadingAfterSubmit = true;
		this.viewLoading = true;
    // Server call
    this.customerService.updateCustomer(_customer).subscribe(res => {
      this.viewLoading = false;
      this.dialogRef.close({
       _customer,
       isEdit: true
      });
    });
  }

  createCustomer(_customer: CustomerModel) {
    this.loadingAfterSubmit = true;
		this.viewLoading = true;
    // Server call
    this.customerService.createCustomer(_customer).subscribe(res => {
      this.viewLoading = false;
      this.dialogRef.close({
        _customer,
        isEdit: false
      });
    });
  }

  onAlertClose($event) {
    this.hasFormErrors = false;
  }
}

_core/services/customers.service.ts:

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, forkJoin, of } from 'rxjs';
import { mergeMap } from 'rxjs/operators';
import { HttpUtilsService } from '../utils/http-utils.service';
import { CustomerModel } from '../models/customer.model';
import { QueryParamsModel } from '../models/query-models/query-params.model';
import { QueryResultsModel } from '../models/query-models/query-results.model';

const API_CUSTOMERS_URL = 'api/customers';

@Injectable()
export class CustomersService {
  constructor(private http: HttpClient, private httpUtils: HttpUtilsService) { }

  // CREATE =>  POST: add a new customer to the server
  createCustomer(customer: CustomerModel): Observable {
    return this.http.post(API_CUSTOMERS_URL, customer, this.httpUtils.getHTTPHeader());
  }

  // UPDATE => PUT: update the customer on the server
  updateCustomer(customer: CustomerModel): Observable {
    return this.http.put(API_CUSTOMERS_URL, customer, this.httpUtils.getHTTPHeader());
  }
}


I run a team of 20 product managers, developers, QA and UX resources. Previously we designed everything ourselves. For our newest platform we tried out Metronic. I cannot overestimate the impact Metronic has had. It's accelerated development 3x and reduced QA issues by 50%. If you add up the reduced need for design time/resources, the increase in dev speed and the reduction in QA, it's probably saved us $100,000 on this project alone, and I plan to use it for all platforms moving forward.
The flexibility of the design has also allowed us to put out a better looking & working platform and reduced my headaches by 90%. Thank you KeenThemes! Jonathan Bartlett, Metronic Customer

Powerful Framework

Everything within Metronic is customizable globally to provide limitless unique styled projects

Multi Demo

Choose a perfect design for your next project among hundreds of demos

Limitless Components

A huge collection of components to power your application with the latest UI/UX trands

Angular 6 Support

Enterprise ready Angular 6 integration with built-in authentication module and many more

Bootstrap 4

Metronic deeply customizes Bootstrap with native look and feel

Exclusive Datatable Plugin

Our super sleek and intuitive Datatable comes packed with all advanced CRUD features

60,000+ Strong

Metronic is the only theme trusted by over 60,000 developers world wide

Continuous Updates

Lifetime updates with new demos and features is guaranteed

Quality Code

Metronic is writer with a code structure that all developers will be able to pick up easily and fall in love

The Ultimate Bootstrap Admin Theme Trusted By Over 60,000 Developers World Wide