Metronic

下一代应用程序的终极Bootstrap和Angular 6管理主题框架,一次购买永久包更新,当前版本5.5.5。

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());
  }
}


我运营着一个由20个产品经理,开发人员,QA和UX资源组成的团队。以前我们自己设计了一切。对于我们最新的平台,我们试用了Metronic。我无法高估Metronic的影响力。它加速了3倍的开发,并将质量保证问题减少了50%。如果你减少了对设计时间/资源的需求,开发速度的提高和质量保证的减少,那么这个项目可能只为我们节省了10万美元,我计划将它用于所有平台。
设计的灵活性也使我们能够提供更好的外观和工作平台,并使我的头痛减少90%。谢谢KeenThemes! Jonathan Bartlett, Metronic 客户

强大的框架

Metronic的所有产品都可在全球范围内定制,以提供无限的独特风格项目

多演示

为数百个演示中的下一个项目选择一个完美的设计

无限组件

利用最新的UI / UX交易为您的应用程序提供大量组件的大量组件

Angular 6支持

企业级Angular 6集成了内置的身份验证模块等等

Bootstrap 4

Metronic深度定制Bootstrap,具有原生外观

独家数据库插件

我们超级时尚和直观的Datatable包含所有先进的CRUD功能

60,000+强

Metronic是全球60,000多名开发商信赖的唯一主题

持续更新

使用新演示和功能的终身更新得到保证

质量准则

Metronic是一个具有代码结构的作家,所有开发人员都可以轻松拿起并坠入爱河

全球超过60,000名开发人员信赖的Ultimate Bootstrap Admin主题