In this part we will add the UI components and module in Angular to allow use to add the CRUD functionality to the app. We will create an admin module for this purpose although we do not (yet!) have an admin account to secure this with – this will come later.

We will use the Anguar CLI to create the new admin module along with its root component. Run the following commands from inside the client/src/app folder:

ng g m admin
cd admin
ng g m admin-routing --flat
ng g c admin --flat --skip-tests
ng g s admin --skip-tests
ng g c edit-product --skip-tests

This should give us the following folder structure:

As we did in the course we will then configure the routes in the admin-routing.module.ts:

import { NgModule } from '@angular/core';
import {RouterModule, Routes} from '@angular/router';
import {AdminComponent} from './admin.component';
import {EditProductComponent} from './edit-product/edit-product.component';

const routes: Routes = [
  {path: '', component: AdminComponent},
  {path: 'create', component: EditProductComponent, data: {breadcrumb: 'Create'}},
  {path: 'edit/:id', component: EditProductComponent, data: {breadcrumb: 'Edit'}}
];

@NgModule({
  declarations: [],
  imports: [
    RouterModule.forChild(routes)
  ],
  exports: [RouterModule]
})
export class AdminRoutingModule { }

Then update the admin.module.ts as follows:

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AdminComponent } from './admin.component';
import { EditProductComponent } from './edit-product/edit-product.component';
import {SharedModule} from '../shared/shared.module';
import {AdminRoutingModule} from './admin-routing.module';

@NgModule({
  declarations: [AdminComponent, EditProductComponent],
  imports: [
    CommonModule,
    SharedModule,
    AdminRoutingModule
  ]
})
export class AdminModule { }

In the app-routing.module.ts add the new admin route as a lazy loaded module:

  {
    path: 'admin',
    loadChildren: () => import('./admin/admin.module')
      .then(mod => mod.AdminModule), data: { breadcrumb: 'Admin' }
  },
  { path: '**', redirectTo: 'not-found', pathMatch: 'full' }

For now so that we can easily access the component we will just add the admin link to the top nav bar so update the nav.component.html to add this:

  <nav class="my-2 my-md-0 mr-md-3 text-uppercase" style="font-size: larger;">
    <a class="p-2" [routerLink]="['/']" routerLinkActive="active"
      [routerLinkActiveOptions]="{exact: true}" >Home</a>
    <a class="p-2" routerLink="/shop" routerLinkActive="active">Shop</a>
    <a class="p-2" routerLink="/test-error" routerLinkActive="active">Errors</a>
    <a class="p-2" routerLink="/admin" routerLinkActive="active">Admin</a>
  </nav>

Once you restart the app you should have the admin link at the top and browsing to the admin component you should see the following:

Now we have our component lets start to add the CRUD functionality. First lets create a class that we can use for type safety and also so that we can initialise the form once we create it. Open the product.ts and add the following interface and class to store the form values:

export interface IProduct {
    id: number;
    name: string;
    description: string;
    price: number;
    pictureUrl: string;
    productType: string;
    productBrand: string;
}

export interface IProductToCreate {
  name: string;
  description: string;
  price: number;
  pictureUrl: string;
  productTypeId: number;
  productBrandId: number;
}

export class ProductFormValues implements IProductToCreate {
  name = '';
  description = '';
  price = 0;
  pictureUrl = '';
  productBrandId: number;
  productTypeId: number;

  constructor(init?: ProductFormValues) {
    Object.assign(this, init);
  }
}

Then we can add the methods in the admin.service.ts that will support creating, updating and deleting the products:

import { Injectable } from '@angular/core';
import {environment} from '../../environments/environment';
import {ProductFormValues} from '../shared/models/product';
import {HttpClient} from '@angular/common/http';

@Injectable({
  providedIn: 'root'
})
export class AdminService {
  baseUrl = environment.apiUrl;

  constructor(private http: HttpClient) { }

  createProduct(product: ProductFormValues) {
    return this.http.post(this.baseUrl + 'products', product);
  }

  updateProduct(product: ProductFormValues, id: number) {
    return this.http.put(this.baseUrl + 'products/' + id, product);
  }

  deleteProduct(id: number) {
    return this.http.delete(this.baseUrl + 'products/' + id);
  }
}

Then in the admin.component.ts lets add a couple of methods to get the products and delete a product. We will make use of the pagination we created earlier as well so we will include the shop.service.ts as well as the shopParams that store the pagination information:

import { Component, OnInit } from '@angular/core';
import {ShopService} from '../shop/shop.service';
import {AdminService} from './admin.service';
import {IProduct} from '../shared/models/product';
import {ShopParams} from '../shared/models/shopParams';

@Component({
  selector: 'app-admin',
  templateUrl: './admin.component.html',
  styleUrls: ['./admin.component.scss']
})
export class AdminComponent implements OnInit {
  products: IProduct[];
  totalCount: number;
  shopParams: ShopParams;

  constructor(private shopService: ShopService, private adminService: AdminService) {
    this.shopParams = this.shopService.getShopParams();
  }

  ngOnInit(): void {
    this.getProducts();
  }

  getProducts(useCache = false) {
    this.shopService.getProducts(useCache).subscribe(response => {
      this.products = response.data;
      this.totalCount = response.count;
    }, error => {
      console.log(error);
    });
  }

  onPageChanged(event: any) {
    const params = this.shopService.getShopParams();
    if (params.pageNumber !== event) {
      params.pageNumber = event;
      this.shopService.setShopParams(params);
      this.getProducts(true);
    }
  }

  deleteProduct(id: number) {
    this.adminService.deleteProduct(id).subscribe((response: any) => {
      this.products.splice(this.products.findIndex(p => p.id === id), 1);
      this.totalCount--;
    });
  }
}

Then we need the HTML so that we can display the products in a table. We will also make use of the shared pagination components we created earlier so that we can page through the results returned from the server:

<section class="admin-page" *ngIf="products">
  <div class="container">
    <div class="row">
      <div class="col-lg-12">
        <div class="d-flex justify-content-between my-3">
          <header class="h2">Product List</header>
          <button [routerLink]="['/admin/create']" class="btn btn-outline-success">Create new product</button>
        </div>

          <app-paging-header
            [totalCount]="totalCount"
            [pageSize]="this.shopParams.pageSize"
            [pageNumber]="this.shopParams.pageNumber"
          ></app-paging-header>

        <div class="table-responsive">
          <table class="table">
            <thead>
            <tr>
              <th scope="col">
                <div class="p-2">Id</div>
              </th>
              <th scope="col">
                <div class="p-2 text-uppercase">Product</div>
              </th>
              <th scope="col">
                <div class="py-2 text-uppercase">Name</div>
              </th>
              <th scope="col">
                <div class="p-2 px-3 text-uppercase">Price</div>
              </th>
              <th scope="col">
                <div class="p-2 px-3 text-uppercase">Edit</div>
              </th>
              <th scope="col">
                <div class="p-2 px-3 text-uppercase">Delete</div>
              </th>
            </tr>
            </thead>
            <tbody>
            <tr *ngFor="let product of products">
              <td class="align-middle">{{product.id}}</td>
              <td>
                <div class="p-2">
                  <img src="{{product.pictureUrl || '/assets/images/placeholder.png'}}" alt="{{product.name}}" class="img-fluid" style="max-height: 50px">
                </div>
              </td>
              <th class="align-middle"><h5>{{product.name}}</h5></th>
              <td class="align-middle">{{product.price | currency}}</td>
              <td class="align-middle"><button [routerLink]="['edit', product.id]" class="btn btn-warning">Edit</button></td>
              <td class="align-middle"><button (click)="deleteProduct(product.id)" class="btn btn-danger">Delete</button></td>
            </tr>
            </tbody>
          </table>
        </div>
        <div class="d-flex justify-content-center" *ngIf="totalCount > 0">
          <app-pager
            [pageSize]="shopParams.pageSize"
            [pageNumber]="shopParams.pageNumber"
            [totalCount]="totalCount"
            (pageChanged)="onPageChanged($event)"
          ></app-pager>
        </div>
      </div>
    </div>
  </div>
</section>

This should give us the following:

Test to make sure that you can page through the results. Clicking on the edit or create buttons should take us to the product edit component as we already have the routes in place here, but of course we only see the default html here for the component.

Now lets add the code so that we can use a form to create or edit a product. Unlike in the course we will use template forms for the edit product form rather than reactive forms that we mostly used in the course. There are 2 reasons for this:

  1. I was originally going to include the ability to edit/create/delete products as part of the main course but due to course length I decided just to focus on the shop front end. Since this code was already written and would come at a point in the course prior to the reactive forms content I decided to keep it as it was.
  2. The price input is a tricky input field to work with. In order that we can display it as a currency we need to use a currency input mask here to do so – we also need to ensure we send up the price as a decimal to 2 decimal places or our API will reject this.

First, lets take a look at the code we are using in the edit-product.component.ts and then I’ll explain each element. First the code in full:

import {Component, OnInit} from '@angular/core';
import {AdminService} from '../admin.service';
import {ShopService} from '../../shop/shop.service';
import {ActivatedRoute, Router} from '@angular/router';
import {ProductFormValues} from '../../shared/models/product';
import {IBrand} from '../../shared/models/brand';
import {IType} from '../../shared/models/productType';
import {forkJoin} from 'rxjs';

@Component({
  selector: 'app-edit-product',
  templateUrl: './edit-product.component.html',
  styleUrls: ['./edit-product.component.scss']
})
export class EditProductComponent implements OnInit {
  product: ProductFormValues;
  brands: IBrand[];
  types: IType[];

  constructor(private adminService: AdminService,
              private shopService: ShopService,
              private route: ActivatedRoute,
              private router: Router) {
    this.product = new ProductFormValues();
  }

  ngOnInit(): void {
    const brands = this.getBrands();
    const types = this.getTypes();

    forkJoin([types, brands]).subscribe(results => {
      this.types = results[0];
      this.brands = results[1];
    }, error => {
      console.log(error);
    }, () => {
      if (this.route.snapshot.url[0].path === 'edit') {
        this.loadProduct();
      }
    });
  }

  updatePrice(event: any) {
    this.product.price = event;
  }

  loadProduct() {
    this.shopService.getProduct(+this.route.snapshot.paramMap.get('id')).subscribe((response: any) => {
      const productBrandId = this.brands && this.brands.find(x => x.name === response.productBrand).id;
      const productTypeId = this.types && this.types.find(x => x.name === response.productType).id;
      this.product = {...response, productBrandId, productTypeId};
    });
  }

  getBrands() {
    return this.shopService.getBrands();
  }

  getTypes() {
    return this.shopService.getTypes();
  }

  onSubmit(product: ProductFormValues) {
    if (this.route.snapshot.url[0].path === 'edit') {
      const updatedProduct = {...this.product, ...product, price: +product.price};
      this.adminService.updateProduct(updatedProduct, +this.route.snapshot.paramMap.get('id')).subscribe((response: any) => {
        this.router.navigate(['/admin']);
      });
    } else {
      const newProduct = {...product, price: +product.price};
      this.adminService.createProduct(newProduct).subscribe((response: any) => {
        this.router.navigate(['/admin']);
      });
    }
  }
}

So there is a few things going on here so lets break it down. First of all when we initialize the component we want to make sure that we have the brands and the types available before we attempt to use the loadProduct method. Lets take a look at each part of this starting with the fetching of the brands and types:

  getBrands() {
    return this.shopService.getBrands();
  }

  getTypes() {
    return this.shopService.getTypes();
  }

So we have the getBrands() and getTypes() methods that call the corresponding methods in the shopService. Note that we do not subscribe to the observables here, we just return. This is because we want to use an rxjs operator that allows us to do something after both of these methods have completed – we can acheive this by using forkJoin. Lets take a look at the ngOnInit method:

  ngOnInit(): void {
    const brands = this.getBrands();
    const types = this.getTypes();

    forkJoin([types, brands]).subscribe(results => {
      this.types = results[0];
      this.brands = results[1];
    }, error => {
      console.log(error);
    }, () => {
      if (this.route.snapshot.url[0].path === 'edit') {
        this.loadProduct();
      }
    });
  }
  • First of all we assign the observables returned from the shop service to a brands and types variable.
  • We then pass these in an array to the forkJoin method and then subscribe.
  • This stores the results in an array that we can access using the index of the results array.
  • The types will be in results[0] as this is the first observable passed to the method, and the brands are in results[1].
  • If this method is successful we can then go and fetch the product in the ‘complete’ part of the subscribe method, and make use of the returned types so that we can get the brand id and type id and assign them to the product – we do this as the API is returning the type and brand as just the string of the name, but when we edit or create a product we want to use the id instead.
  • We also check here to make sure we are editing the product as we do not want to load a product if we are creating a new one so we add an if statement to check.

In order to use the price input field and format as a currency we will use an input mask to do so. For this we will need to install ng2-currency-mask which, despite it’s name, has been updated for use with Angular 9. Run the following command:

npm install ng2-currency-mask

Then we will add the currency mask module to the shared module so we can make use of it here:

Of course make sure you have this imported in the shared module as well:

import {CurrencyMaskModule} from 'ng2-currency-mask';

Now we have this let’s add the code for the edit-product.component.html – it’s quite verbose due to using template forms and adding the validation in the HTML itself:

<section class="product-edit mt-5">
  <div class="container">
    <div class="row">
      <div class="col-lg-8">
        <form #productForm="ngForm" (ngSubmit)="onSubmit(productForm.valid && productForm.value)">
          <div class="form-row">
            <div class="form-group col-md-6">
              <label for="name">Product Name</label>
              <input
                [ngClass]="{'is-invalid': name.invalid && (name.dirty || name.touched)}"
                required
                type="text"
                class="form-control"
                id="name"
                placeholder="Product Name"
                name="name"
                #name="ngModel"
                [(ngModel)]="product.name"
              >
              <div *ngIf="name.invalid && (name.dirty || name.touched)" class="invalid-feedback">
                <div *ngIf="name.errors.required">
                  Product Name is required
                </div>
              </div>
            </div>
            <div class="form-group col-md-6">
              <label for="price">Price</label>
              <input
                [ngClass]="{'is-invalid': price.invalid && (price.dirty || price.touched)}"
                required
                type="text"
                class="form-control"
                id="price"
                placeholder="Price"
                currencyMask
                name="price"
                #price="ngModel"
                pattern="^\$?([0-9]{1,3},([0-9]{3},)*[0-9]{3}|[0-9]+)(\.[0-9][0-9])?$"
                min="0.01"
                [ngModel]="+product.price | number: '1.2-2'"
                (ngModelChange)="updatePrice(+$event)"
              >
              <div *ngIf="price.invalid && (price.dirty || price.touched)" class="invalid-feedback">
                <div *ngIf="price.errors.required">
                  Product price is required
                </div>
                <div *ngIf="price.errors.pattern">
                  Product price needs to be decimal value
                </div>
                <div *ngIf="price.errors.min">
                  Product price must be greater than zero
                </div>
              </div>
            </div>
          </div>
          <div class="form-row">
            <div class="form-group col-md-12">
              <label for="description">Description</label>
              <textarea
                [ngClass]="{'is-invalid': description.invalid && (description.dirty || description.touched)}"
                required
                #description="ngModel"
                class="form-control"
                id="description"
                [(ngModel)]="product.description"
                name="description"
                rows="3"></textarea>
              <div *ngIf="description.invalid && (description.dirty || description.touched)" class="invalid-feedback">
                <div *ngIf="description.errors.required">
                  Product price is required
                </div>
              </div>
            </div>
          </div>
          <div class="form-row">
            <div class="form-group col-md-6">
              <label for="brand">Brand</label>
              <select id="brand" class="form-control" name="productBrandId" [(ngModel)]="product.productBrandId" required>
                <option *ngFor="let brand of brands"
                        [selected]="product.productBrandId === brand.id"
                        [ngValue]="brand.id">{{brand.name}}</option>
              </select>
            </div>
            <div class="form-group col-md-6">
              <label for="type">Type</label>
              <select id="type" class="form-control" name="productTypeId" [(ngModel)]="product.productTypeId" required>
                <option *ngFor="let type of types"
                        [selected]="product.productTypeId === type.id"
                        [ngValue]="type.id">{{type.name}}</option>
              </select>
            </div>
          </div>
          <button [disabled]="!productForm.valid" type="submit" class="btn btn-primary my-3 float-right">Submit</button>
        </form>
      </div>
    </div>
  </div>
</section>

For the most part this is standard code for a template form, but lets just take a look at the input field for the price which is slightly different – here is the html input:

<input
                [ngClass]="{'is-invalid': price.invalid && (price.dirty || price.touched)}"
                required
                type="text"
                class="form-control"
                id="price"
                placeholder="Price"
                currencyMask
                name="price"
                #price="ngModel"
                pattern="^\$?([0-9]{1,3},([0-9]{3},)*[0-9]{3}|[0-9]+)(\.[0-9][0-9])?$"
                min="0.01"
                [ngModel]="+product.price | number: '1.2-2'"
                (ngModelChange)="updatePrice(+$event)"
              >

Note that we are using the currencyMask directive here. This will ensure that the field is formatted as a currency. We are also using a pattern validator to make sure that the price is a decimal with 2 decimal places as well as a ‘min’ validator to ensure the price cannot be set as a negative or zero. The other difference is that instead of using the ‘banana in a box’ for the ngModel we have split this into the [ngModel] input, which is what we display as the value and using the number pipe to format this as a decimal. We then have the output method (ngModelChange) and use this to execute the updatePrice method in the component as follows:

  updatePrice(event: any) {
    this.product.price = event;
  }

We need to do this so the price is formatted correctly. If everything has gone okay we should find that we can edit/create a new product:

Now lets take a look at the submit method. In the HTML we pass the form values to the onSubmit method but we need to make sure we send this up to the API in the format it is expecting:

  onSubmit(product: ProductFormValues) {
    if (this.route.snapshot.url[0].path === 'edit') {
      const updatedProduct = {...this.product, ...product, price: +product.price};
      this.adminService.updateProduct(updatedProduct, +this.route.snapshot.paramMap.get('id')).subscribe((response: any) => {
        this.router.navigate(['/admin']);
      });
    } else {
      const newProduct = {...product, price: +product.price};
      this.adminService.createProduct(newProduct).subscribe((response: any) => {
        this.router.navigate(['/admin']);
      });
    }
  }

In this method we use the spread operator so we take the values from the form as it is, but then overwrite the price and convert this into a number otherwise it will be sent as a string.

The only other thing to test is the delete method in our admin component which is already hooked up:

Okay so quite a bit of work but we now have CRUD for our products (minus the images – we will add this later). The code for this commit can be found here.

In the next part, we will take a look at adding an admin role so that only admin users can access the admin functionality.