Building an inventory system for the Skinet app in the .net and angular course – Part 9
In this tutorial we will work on building a photo upload widget that will allow users to drop an image into a dropzone and crop the image so that we only send up square images to the API. We will also do a bit of housekeeping and move the product edit form into its own component.
First lets add a new component for the product-form. Run the following command from inside the src/app/admin folder:
ng g c edit-product-form --skip-tests
Cut and paste the entire form element from the edit-product.html into the edit-product-form.component.html:
<form class="mt-4" #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>
Then cut and paste the onSubmit code and the updatePrice code from the edit-product component, and also create Input props for the product, the types and the brands. The component code for the form should look like this (including the dependancies we need injected):
import {Component, Input, OnInit} from '@angular/core';
import {ProductFormValues} from '../../shared/models/product';
import {ActivatedRoute, Router} from '@angular/router';
import {AdminService} from '../admin.service';
import {IBrand} from '../../shared/models/brand';
import {IType} from '../../shared/models/productType';
@Component({
selector: 'app-product-form',
templateUrl: './product-form.component.html',
styleUrls: ['./product-form.component.scss']
})
export class ProductFormComponent implements OnInit {
@Input() product: ProductFormValues;
@Input() brands: IBrand[];
@Input() types: IType[];
constructor(private route: ActivatedRoute, private adminService: AdminService, private router: Router) { }
ngOnInit(): void {
}
updatePrice(event: any) {
this.product.price = event;
}
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']);
});
}
}
}
Also, in the edit-product.component.html we want to store the original product we get back from the API, as this contains the product.id, and keep the product form values separate so also update the edit-product.component.ts to store the product as well as productFormValues so adjust the class properties to the following:
export class EditProductComponent implements OnInit {
product: IProduct;
productFormValues: ProductFormValues;
brands: IBrand[];
types: IType[];
Then also update the loadProduct() method so we populate the product as well as the productFormValues when we call this method:
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;
this.productFormValues = {...response, productBrandId, productTypeId};
});
}
We will use tabs to separate the form from the photos for each product. For this we will make use of the tabs module from Ngx-bootstrap. Import this into the shared.module.ts just like anything else we have used from here:
import {PaginationModule, CarouselModule, BsDropdownModule, TabsModule} from 'ngx-bootstrap';
// other imports omitted
@NgModule({
declarations: [PagingHeaderComponent, PagerComponent, OrderTotalsComponent, TextInputComponent, StepperComponent, BasketSummaryComponent],
imports: [
// omitted
TabsModule.forRoot()
],
exports: [
// omitted
TabsModule
]
})
export class SharedModule { }
Then refactor the edit-product.component.html to the following:
<section class="product-edit mt-5">
<div class="container">
<div class="row">
<div class="col-12">
<div class="tab-panel">
<tabset class="product-tabset">
<tab heading="Edit Product">
<div class="col-lg-8">
<app-product-form [product]="productFormValues" [brands]="brands" [types]="types"></app-product-form>
</div>
</tab>
<tab heading="Edit Photos">
Photos will go here
</tab>
</tabset>
</div>
</div>
</div>
</div>
</section>
We will also add a little style to this as well – we need to do this at the styles.css level to style the tabs targetting the ‘product-tabset’ class we added in the HTML:
.product-tabset > .nav-tabs > li.open > a, .product-tabset > .nav-tabs > li:hover > a {
border: 0;
background: none !important;
color: #333333;
}
.product-tabset > .nav-tabs > li.open > a > i, .product-tabset > .nav-tabs > li:hover > a > i {
color: #a6a6a6;
}
.product-tabset > .nav-tabs > li.open .dropdown-menu, .product-tabset > .nav-tabs > li:hover .dropdown-menu {
margin-top: 0px;
}
.product-tabset > .nav-tabs > li.active {
border-bottom: 4px solid #E95420;
position: relative;
}
.product-tabset > .nav-tabs > li.active > a {
border: 0 !important;
color: #333333;
}
.product-tabset > .nav-tabs > li.active > a > i {
color: #404040;
}
.product-tabset > .tab-content {
margin-top: -3px;
background-color: #fff;
border: 0;
border-top: 1px solid #eee;
padding: 15px 0;
}
Our edit product page should now look like the following and we can switch between the edit product and the edit photo tabs:
Now lets add a new component that we will use for the photo-upload. Create another new component called edit-product-photos in the admin folder:
ng g c edit-product-photos --skip-tests
Then add this component into the edit-product.component.html:
<tab heading="Edit Photos">
<app-edit-product-photos></app-edit-product-photos>
</tab>
In the edit-photo.component.ts add an input property for the product:
export class EditProductPhotosComponent implements OnInit {
@Input() product: IProduct;
constructor() { }
Then pass the product to the app-edit-product-photos component:
<tab heading="Edit Photos">
<app-edit-product-photos [product]="product"></app-edit-product-photos>
</tab>
In the edit-photo.component.html we will show the existing product photos and use a bootstrap card for styling here, as well as add a button so that we can turn the photo-widget on and off (when we add it):
<div class="py-5">
<div class="container">
<ng-container *ngIf="!addPhotoMode">
<div class="d-flex justify-content-between mb-3">
<h3>Product Photos</h3>
<button class="btn btn-primary" (click)="addPhotoModeToggle()">
Add New Photo
</button>
</div>
<div class="row">
<div class="col-3" *ngFor="let photo of product?.photos">
<div class="card">
<img class="card-img-top" width="100%" height="225" src="{{photo.pictureUrl}}" alt="{{photo.fileName}}"/>
<div class="btn-group" style="width: 100%">
<button type="button"
[disabled]="photo.isMain"
class="{{photo.isMain ? 'btn btn-success' : 'btn btn-outline-success'}}"
style="width: 50%">Set Main
</button>
<button
type="button"
[disabled]="photo.isMain"
class="btn btn-outline-danger"
style="width: 50%"><i class="fa fa-trash"></i>
</button>
</div>
</div>
</div>
</div>
</ng-container>
<ng-container *ngIf="addPhotoMode">
<div class="d-flex justify-content-between mb-3">
<h3>Add new product image</h3>
<button class="btn btn-outline-secondary" (click)="addPhotoModeToggle()">Cancel</button>
</div>
<p>Photo widget will go here</p>
</ng-container>
</div>
</div>
This should give us the following:
Now we are ready to actually create a photo upload widget!
Add a new component called photo-widget in the shared/components folder:
ng g c photo-widget --skip-tests
We will make use of ngx-dropzone and ngx-image-cropper here as we want the user to be able to resize the image before uploading to the server. As per usual with any of my tutorials, we need to paper over the fact my css skills are limited and to do this we will enforce square images so we do not need to do any jiggery pokery with css here. Run the following command from the client folder:
npm install ngx-dropzone ngx-image-cropper
We will need to import/export these as per usual in the shared.module.ts and also export the photo-widget component here as well:
import {PhotoWidgetComponent} from './components/photo-widget/photo-widget.component';
import {ImageCropperModule} from 'ngx-image-cropper';
@NgModule({
declarations: [PagingHeaderComponent, PagerComponent, OrderTotalsComponent, TextInputComponent, StepperComponent,
BasketSummaryComponent, PhotoWidgetComponent],
imports: [
//omitted
NgxDropzoneModule,
ImageCropperModule
],
exports: [
//omitted
NgxDropzoneModule,
ImageCropperModule,
PhotoWidgetComponent
]
})
export class SharedModule {
}
So before we make use of these lets add the methods we need in the admin.service.ts so that we can add, remove a photo, as well as set it to the main photo. Add the following methods to the admin.service.ts:
uploadImage(file: File, id: number) {
const formData = new FormData();
formData.append('photo', file, 'image.png');
return this.http.put(this.baseUrl + 'products/' + id + '/photo', formData, {
reportProgress: true,
observe: 'events'
});
}
deleteProductPhoto(photoId: number, productId: number) {
return this.http.delete(this.baseUrl + 'products/' + productId + '/photo/' + photoId);
}
setMainPhoto(photoId: number, productId: number) {
return this.http.post(this.baseUrl + 'products/' + productId + '/photo/' + photoId, {});
}
The ‘uploadImage’ method is the interesting one here. We are using an http.put as technically we are editing a product here, and we are asking the http client to report the progress of the upload and we will be observing the events here – this will allow us to use the results of this in a progress bar in the widget. We are setting the filename to ‘image.png’ here as the image cropper here does not give us the ability to retain the filename, but since we are setting this on the API it doesn’t matter for our purposes.
Lets next add the dropzone into the photo-widget.component.html. You can check the documentation here for all it’s features, but our needs are fairly simple. We want to add a box where an image can be dropped into, then display a preview of this image in the component.
In the photo-widget.component.ts add a files property (the dropzone takes an array but we will limit photos to one at a time, as we also want the user to edit the photo before uploading) and also an ‘onSelect’ method, where we will reset the files array before adding a new file here:
export class PhotoWidgetComponent implements OnInit {
files: File[] = [];
constructor() { }
ngOnInit(): void {
}
onSelect(event) {
this.files = [];
this.files.push(...event.addedFiles);
}
}
Then in the photo-widget.component.html add the following html:
<div class="row">
<div class="col-4">
<h3>Step 1 - Add Photo</h3>
<div class="custom-dropzone" ngx-dropzone (change)="onSelect($event)">
<ngx-dropzone-label>
<i class="fa fa-upload fa-4x"></i>
<h4>Drop image here</h4>
</ngx-dropzone-label>
</div>
</div>
<div class="col-4 img-preview">
<h3>Step 2 - Resize image</h3>
<ngx-dropzone-image-preview ngProjectAs="ngx-dropzone-preview" *ngFor="let f of files" [file]="f"></ngx-dropzone-image-preview>
</div>
<div class="col-4">
<h3>Step 3 - Preview & Upload</h3>
</div>
</div>
Lets also add a little styling for the dropzone:
.custom-dropzone {
border: 3px dashed #eee;
border-radius: 5px;
padding-top: 30px;
text-align: center;
height: 200px;
}
.custom-dropzone.ngx-dz-hovered {
border: 3px solid green;
}
Then in the edit-product-photo.component.html let’s add our new photo widget:
<ng-container *ngIf="addPhotoMode">
<div class="d-flex justify-content-between mb-3">
<h3 class="text-primary">Add new product image</h3>
<button class="btn btn-outline-secondary" (click)="addPhotoModeToggle()">Cancel</button>
</div>
<app-photo-widget></app-photo-widget>
</ng-container>
If we take a look at our progress we should find that we can now drop an image and preview it on the page:
Ok so far so good. Now lets introduce the cropper component to this as well. The documentation for this can be found here, but once again our needs are quite simple. Other image croppers are available but this one is good enough to do the job.
Update the photo-widget.component.ts with a fileChangedEvent method, an imageCropped method, and an onUpload method. Here is the updated code for this component:
import { Component, OnInit } from '@angular/core';
import {ImageCroppedEvent, base64ToFile} from 'ngx-image-cropper';
@Component({
selector: 'app-photo-widget',
templateUrl: './photo-widget.component.html',
styleUrls: ['./photo-widget.component.scss']
})
export class PhotoWidgetComponent implements OnInit {
files: File[] = [];
imageChangedEvent: any = '';
croppedImage: any = '';
constructor() { }
ngOnInit(): void {
}
fileChangeEvent(event: any): void {
this.imageChangedEvent = event;
}
imageCropped(event: ImageCroppedEvent) {
this.croppedImage = event.base64;
}
onSelect(event) {
this.files = [];
this.files.push(...event.addedFiles);
this.fileChangeEvent(this.files[0]);
}
onUpload() {
console.log(base64ToFile(this.croppedImage));
}
}
Couple of points to note here. In the onSelect method for the dropzone we are passing the dropped file to the fileChangeEvent for the image cropper. We are also using a method from the image cropper to convert the data URL that the image cropper produces so that we can output our cropped image as a file which makes it easier to upload to the API as we do not need to convert it to a file on the API. Then we need to update the photo-widget.component.html:
<div class="row">
<div class="col-4">
<h3>Step 1 - Add Photo</h3>
<div class="custom-dropzone" ngx-dropzone (change)="onSelect($event)">
<ngx-dropzone-label>
<i class="fa fa-upload fa-4x"></i>
<h4>Drop image here</h4>
</ngx-dropzone-label>
</div>
</div>
<div class="col-4 img-preview">
<h3>Step 2 - Resize image</h3>
<image-cropper
class="img-fluid"
[imageChangedEvent]="imageChangedEvent"
[imageFile]="files[0]"
[maintainAspectRatio]="true"
[aspectRatio]="1"
format="png"
(imageCropped)="imageCropped($event)"
></image-cropper>
</div>
<div class="col-4">
<h3>Step 3 - Preview & Upload</h3>
<ng-container *ngIf="croppedImage">
<img [src]="croppedImage" class="img-fluid">
<button class="btn-block btn-primary" (click)="onUpload()">Upload Image</button>
</ng-container>
</div>
</div>
Here we just add the image-cropper component. We set the [imageFile] to the dropped file by using files[0]. With this in place we should be able to drop an image, crop an image and when we click the upload button we should see a file output which comes from the onUpload method in the component:
Progress! Next up we will work on the edit-product-photo.component.ts. We will need an upload photo method and our photo-widget will need an Output property so that it can call the upload method in the component. Add the following code to the edit-product-photo.component.ts:
uploadFile(file: File) {
this.adminService.uploadImage(file, this.product.id).subscribe((event: HttpEvent<any>) => {
switch (event.type) {
case HttpEventType.UploadProgress:
this.progress = Math.round(event.loaded / event.total * 100);
break;
case HttpEventType.Response:
this.product = event.body;
setTimeout(() => {
this.progress = 0;
this.addPhotoMode = false;
}, 1500);
}
}, error => {
if (error.errors) {
this.toast.error(error.errors[0]);
} else {
this.toast.error('Problem uploading image');
}
this.progress = 0;
});
}
deletePhoto(photoId: number) {
this.adminService.deleteProductPhoto(photoId, this.product.id).subscribe(() => {
const photoIndex = this.product.photos.findIndex(x => x.id === photoId);
this.product.photos.splice(photoIndex, 1);
}, error => {
this.toast.error('Problem deleting photo');
console.log(error);
});
}
setMainPhoto(photoId: number) {
this.adminService.setMainPhoto(photoId, this.product.id).subscribe((product: IProduct) => {
this.product = product;
});
}
Once again, the interesting code is in the uploadFile method. Here we are observing the http events and one of the events we are observing is the uploadProgress event. We use this to set a progress class property that we add. We need to add this property to our component, as well as add some dependancies here:
export class EditProductPhotosComponent implements OnInit {
@Input() product: IProduct;
progress = 0;
addPhotoMode = false;
constructor(private adminService: AdminService, private toast: ToastrService) { }
ngOnInit(): void {
}
We also need to add the progress bar above the photo widget here so add this html to the edit-product-photo.component.html:
<div class="progress form-group" *ngIf="progress > 0">
<div class="progress-bar progress-bar-striped bg-success" role="progressbar" [style.width.%]="progress">
{{progress}}%
</div>
</div>
<app-photo-widget></app-photo-widget>
We now need an Output property in the photo-widget.component.ts so that we can emit the file and send it to the uploadFile method in the edit-product-photo component. Add the following to the photo-widget.component.ts:
export class PhotoWidgetComponent implements OnInit {
@Output() addFile = new EventEmitter();
// omitted
onUpload() {
console.log(base64ToFile(this.croppedImage));
this.addFile.emit(base64ToFile(this.croppedImage));
}
Then in the edit-product-photos.component.html:
<app-photo-widget (addFile)="uploadFile($event)"></app-photo-widget>
Now, if we have hooked up everything correctly, we should now be able to actually test the upload here. If you want to see the progress bar in action you can set the network profile in chrome dev tools to ‘fast 3g’ for example:
Success! I am well aware that this is not a real product image, but this continues to be not a real store 🙂
Lets next hook up the delete and setMainPhoto functions. Add the following to the edit-product-photos.component.html:
<button type="button"
(click)="setMainPhoto(photo.id)"
[disabled]="photo.isMain"
class="{{photo.isMain ? 'btn btn-success' : 'btn btn-outline-success'}}"
style="width: 50%">Set Main
</button>
<button
(click)="deletePhoto(photo.id)"
type="button"
[disabled]="photo.isMain"
class="btn btn-outline-danger"
style="width: 50%"><i class="fa fa-trash"></i>
</button>
Now we should be able to set a photo as the ‘main’ photo and delete the other ones. Bear in mind though that we haven’t added the logic to prevent our original seed photos from being deleted from the server so you may not want to actually delete them as otherwise they will be deleted from the server if you drop and recreate the database with the seed data.
Just one more task to go before we wrap this up. If we delete a product from the server, we will end up leaving it’s images on the server disk as we are not doing anything to deal with this. Add the following code to the DeleteProduct method in the PhotosController:
[HttpDelete("{id}")]
[Authorize(Roles = "Admin")]
public async Task<ActionResult> DeleteProduct(int id)
{
var product = await _unitOfWork.Repository<Product>().GetByIdAsync(id);
foreach (var photo in product.Photos)
{
if (photo.Id > 18)
{
_photoService.DeleteFromDisk(photo);
}
}
_unitOfWork.Repository<Product>().Delete(product);
var result = await _unitOfWork.Complete();
if (result <= 0) return BadRequest(new ApiResponse(400, "Problem deleting product"));
return Ok();
}
This will ensure the photo is removed from disk when we delete a product and there is a little check in there to make sure we don’t delete any of our original images that we use for our seeded products.
So now we can upload images for our products. Woohoo! In the next part of this series we will take a look at product stock in our make believe store as currently we have an infinite amount of products in stock so we need to deal with that.
The commit for this section can be found here.
Tags In
15 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,
In my opinion, the code in DeleteProduct method in the PhotosController file should be likes below:
var spec = new ProductsWithTypesAndBrandsSpecification(id);
var product = await _unitOfWork.Repository().GetEntityWithSpec(spec);
Because there is no photo which were included in selected Product. If not, the selected Product would be deleted in Database but the physical images on hard disk would not.
Sheldon
Hi Neil,
In my opinion, the code in DeleteProduct method in the ProductsController file should be likes below:
var spec = new ProductsWithTypesAndBrandsSpecification(id);
var product = await _unitOfWork.Repository().GetEntityWithSpec(spec);
If not, there will be no physical images of selected Product on hard disk which should be deleted. Because there is no included Photo in selected Product ( product.Photos = null ).
Sheldon
Hi thanks for this admin part, I really like it !
I just want say that when we want create a new product, the upload photo doesn’t work, we need create a product then edit it to upload correctly an image. But it’s the only error I got
Yes I have the same issue here.
Hello Guys / Neil,
I have a question. I refactored the edit form (form builder) into a control form.
The edit form gets the product as an input property from his parent.
When I initially load the edit screen, the form controls are updated (filled with the right values).
I initialize and create the controls / formgroup inside my child (edit-form.component inside the ngOnInit).
After a reload (f5) they are empty. From what I can see in the dev console is that the initial product value is null (ngOnit – edit-product-form) but after a second I can see the values inside the dev resource screen, but the page won’t update. And because I update the field inside the ngOnit the values will never be updated.
So my child renders his page (F5) -> product is empty -> my parent seems te get triggered (edit-product getProduct function) -> there are products now, but my child won’t update his values because the ngOnit was already triggered.
I have 2 options:
1) load the product always inside my form-control file (edit-product-form), and remove it from edit-product.
2) Create, initialize, and populate the form inside edit-product component..
Or is there a better solution to this problem?
Link to my git:
In react, there is always a re-render after a prop or state would change but that seems not the case inside angular.
Thanks!
Kevin
I forgot to post my GitHub link: https://github.com/zolio/skinet
Hi Kevin,
The ngOnInit is only fired once, so if you rely on something that comes after this is fired then you see this problem. Please take a look at the other lifecycle events here and you may want to use a different hook here, such as ngOnChanges, which fires whenever the input properties get updated.
Hi Neil. I need a component to upload files (any file, not only images). Based on your experience, which component would you recommend?
Thanks
Hi Jose,
Whilst we only are concerned with images here the dropzone that we use can be used with any file. An image is just another type of file but it can be anything really. Just be careful with what you allow people to upload to your server if you are storing files there.
What will you add next ? 🙂
ps : I love your web site here !
Can’t wait for next chapter! 😊
I have a chance to revisit this section after 3 years. No more new chapters already since that time :'(
Hi Neil,
Any plan to complete this admin section of the project?
Thanks.
Hi Neil are we allowed to use the code for commercial use
Hi,
The code is published under an MIT licence so you can use it for your own needs.