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.