Building an inventory system for the Skinet app in the .net and angular course – Part 3
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:
- 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.
- 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.
Tags In
11 Comments
Leave a Reply Cancel reply
Recent Posts
- Building an inventory system for the Skinet app in the .net and angular course – Part 9
- Building an inventory system for the Skinet app in the .net and angular course – Part 8
- Building an inventory system for the Skinet app in the .net and angular course – Part 7
- Building an inventory system for the Skinet app in the .net and angular course – Part 6
- Building an inventory system for the Skinet app in the .net and angular course – Part 5
Hi Neil,
At my side I’ve noticed the following:
– When a user logs out, the cart icon badge keeps showing the cart items number.
– When a user logs in he can see the previous user cart details.
– Sometimes “DeleteProduct” doesn’t work properly, and I’d suggest to add a confirmation message before the deletion.
Hi Anass,
We do not actually tie the basket to the user – the basket can be viewed by the user as id of the basket is in localstorage. There is no requirement for the user to actually login to see the contents of the basket that is stored in local storage. We only ask the user to login when they checkout.
And you can see the cart content even if Yoda says: “Authenticated, you are Not !” 🙂
Hi Neil,
“Delete Product” seems that does not work properly. After clicking Delete button, selected product is removed in Database and on Client side. But if I click on any page number in Paging, that item which was deleted is still existed.
But in case I click on Home or Shop or somewhere else then back to Admin, that case does not appear.
Hi Sheldon,
You have caching enabled on the server still. I’d suggest turning this off for the GetProducts and GetProduct method in the controller or you will see issues like this.
Hi Neil,
I have set comment // [Cached(600)] for both GetProducts and GetProduct functions in Product Controller. But it is still the same issue. (Redis-server has been started as well)
And there is another issue after creating new product. The new one appeared in 2nd and 3rd page from my 4 total pages.
Sheldon
Hi,
Restarting the redis-server does not clear the cache so it will take 5 minutes for this to clear. The products are sorted by name so where it appears depends on what you named it.
Hi Neil,
I tried again to delete a product and the Redis server is running without restarting. I also wait for over 5 minutes. But that product is still in the list.
I must jumped to another page then back to Admin page, it is really gone away now.
Sheldon
Hi Neil,
I found that if I changed to this.getProducts(false) in admin.component.ts file (cache = false). It works properly now.
Please correct me if I am wrong!
Sheldon
Hello Neil,
I have a question about this topic, I wanted to rebuild the formbuilder to a reactive form, with a new custom select dropdown. It used basically the same code as with the text-input, (controlValueAccesor) but the main problem is that the controlDir: NgControl seems to be empty. The valueAccessor is null even as the controlDir.control. I searched for an answer on the great internet but did not find the right solution for this.
I will reply to myself, I fixed the empty control part, the problem was a missing reference variable.
The next challenge for me is to update the form. That is the next thing that’s not working atm for my custom select.