Angular Drag & Drop upload form

Angular Drag & Drop upload form
Difficulty

Any file Angular upload form now wants its own drag & drop system, to be able to insert files by dragging them over the affected area.
To do this with Angular we define a directive that manages the drop on a particular area of the page

// ./directives/dnd.directive.ts
import {
  Directive,
  Output,
  EventEmitter,
  HostBinding,
  HostListener,
} from '@angular/core';

@Directive({
  selector: '[appDnd]',
})
export class DndDirective {
  @HostBinding('class.fileover') fileOver: boolean;
  @Output() fileDropped = new EventEmitter<any>();

  // Dragover listener
  @HostListener('dragover', ['$event']) onDragOver(evt: DragEvent) {
    evt.preventDefault();
    evt.stopPropagation();
    this.fileOver = true;
  }

  // Dragleave listener
  @HostListener('dragleave', ['$event']) public onDragLeave(evt: DragEvent) {
    evt.preventDefault();
    evt.stopPropagation();
    this.fileOver = false;
  }

  // Drop listener
  @HostListener('drop', ['$event']) public onDrop(evt: DragEvent) {
    evt.preventDefault();
    evt.stopPropagation();
    this.fileOver = false;
    let files = evt.dataTransfer.files;
    if (files.length > 0) {
      this.fileDropped.emit(files);
    }
  }
}


Immediately after create the component that will be included in the page and that will take the files, shooting them through EventEmitter on the page. Let’s start with the typescript part.
The component has also been added a management of the same files (you cannot upload the same file twice), error handling and the possibility of recovering the list of files already loaded, in the case of a multi-step form (via the filesUploaded property). In the case of a file list already loaded, it will not be possible to delete the single file. We will delete all the files in bulk.

// box-file/box-file.component.ts
import { Component, OnInit, Output, EventEmitter, Input } from '@angular/core';
import {
  UPLOAD_ACCEPTED_FILE_EXTENSIONS,
  UPLOAD_MAX_SIZE,
} from '../app.constants';

@Component({
  selector: 'app-box-file',
  templateUrl: './box-file.component.html',
  styleUrls: ['./box-file.component.scss'],
})
export class BoxFileComponent implements OnInit {
  files: any[] = [];
  @Input() fileNames: string[] = [];
  @Input() filesMinLimit: number = 1; // Files min limit.
  @Input() filesMaxLimit: number = 2; // Files max limit.
  @Output() filesEvent = new EventEmitter<any[]>();
  filesSize: number;
  isLocaleDelete: boolean = true; // Check if files are already loaded.
  error: string;
  ACCEPTED_FILE_EXTENSIONS = UPLOAD_ACCEPTED_FILE_EXTENSIONS.join(', ');
  private errorFormatSize = `Verify format (.jpg, .png e .pdf) and size (max 5 MB).`;
  getFileExtension = (fileName: string) =>
    fileName.split('.').pop() === 'pdf' ? 'pdf' : 'img'; // For icon class.

  constructor() {}

  ngOnInit() {
    if (this.fileNames?.length > 0) {
      this.isLocaleDelete = false;
    }
  }

  /**
   * On file drop handler.
   */
  onFileDropped(files: unknown) {
    this.prepareFilesList(files as any[]);
  }

  /**
   * Handle file from browsing.
   */
  fileBrowseHandler(target: any) {
    console.debug('Browse handler: ', target);
    if (target) {
      this.prepareFilesList(target.files);
    }
  }

  /**
   * Delete file from files list.
   * @param index (File index)
   */
  deleteFile(index: number) {
    this.files.splice(index, 1);
    this.fileNames.splice(index, 1);
    this.filesEvent.emit(this.files);
  }

  /**
   * Delete all files.
   */
  deleteAll() {
    this.isLocaleDelete = true;
    this.files = [];
    this.fileNames = [];
    this.filesEvent.emit(null);
  }

  /**
   * Convert Files list to normal array list.
   * @param files (Files List)
   */
  prepareFilesList(files: Array<any>) {
    if (
      this.isLocaleDelete &&
      (!this.fileNames || this.fileNames?.length < 2)
    ) {
      this.error = null;
      this.isLocaleDelete = true;
      for (const file of files) {
        if (
          this.fileNames &&
          this.fileNames.some((f: string) => f === file.name)
        ) {
          this.error = "Can't load equal files.";
        } else if (
          UPLOAD_ACCEPTED_FILE_EXTENSIONS.some(
            (e: string) => file.name.lastIndexOf(e) >= 0
          )
        ) {
          file.progress = 0;
          this.files.push(file);
          this.fileNames.push(file.name);
        } else {
          this.error = this.errorFormatSize;
        }
      }
      this.filesSize = this.files
        .map((f: File) => f.size)
        .reduce((s1, s2) => s1 + s2, 0);
      if (this.filesSize > UPLOAD_MAX_SIZE) {
        this.error = this.errorFormatSize;
        this.deleteAll();
      }
      console.debug('Files: ', this.files);
      this.files = this.files.slice(0, this.filesMaxLimit);
      this.fileNames = this.fileNames.slice(0, this.filesMaxLimit);
      // Emitter for uploaded files (must be or output null).
      this.filesEvent.emit(
        this.files.length >= this.filesMinLimit ? this.files : null
      );
    } else {
      this.error = 'Files already loaded.';
    }
    // this.uploadFilesSimulator(0);
  }

  /**
   * Format bytes in right unit.
   * @param bytes (File size in bytes)
   * @param decimals (Decimals point)
   */
  formatBytes(bytes: number, decimals?: number) {
    if (bytes === 0) {
      return '0 Bytes';
    }
    const k = 1024;
    const dm = decimals <= 0 ? 0 : decimals || 2;
    const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
    const i = Math.floor(Math.log(bytes) / Math.log(k));
    return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
  }
}


Set in the application the maximum size that the file must have and the extensions accepted.

// app.constants.ts
export const UPLOAD_ACCEPTED_FILE_EXTENSIONS = ['.jpg', '.pdf', '.png'];
export const UPLOAD_MAX_SIZE = 5 * 1024 * 1024; // 5 MB


For the template create the box with the hidden input field that uses the directive from earlier. We have created them for the file drop.

<!-- ./box-file/box-file.component.html -->
<div class="row">
  <div class="col-sm-5 spacer-xs-bottom-20">
    <div
      class="docs-preview-container spacer-xs-bottom-05"
      appDnd
      (fileDropped)="onFileDropped($event)"
    >
      <div class="docs-preview equalize" style="min-height: 206px;">
        <div class="docs-preview-img">
          <svg
            _ngcontent-ikb-c0=""
            height="64"
            viewBox="0 0 63 64"
            width="63"
            xmlns="http://www.w3.org/2000/svg"
          >
            <g _ngcontent-ikb-c0="" fill="#3B454F" fill-rule="nonzero">
              <path
                _ngcontent-ikb-c0=""
                d="M42.656 15.135a1.953 1.953 0 0 1-1.391-.578L31.5 4.795l-9.765 9.762a1.97 1.97 0 1 1-2.785-2.785L30.106.616a1.97 1.97 0 0 1 2.785 0l11.157 11.156a1.97 1.97 0 0 1-1.392 3.363z"
              ></path>
              <path
                _ngcontent-ikb-c0=""
                d="M31.5 36.791a1.97 1.97 0 0 1-1.969-1.969V2.01a1.97 1.97 0 0 1 3.938 0v32.812a1.97 1.97 0 0 1-1.969 1.969z"
              ></path>
              <path
                _ngcontent-ikb-c0=""
                d="M55.781 63.041H7.22A7.225 7.225 0 0 1 0 55.822V41.385a4.599 4.599 0 0 1 4.594-4.594h7.234a4.567 4.567 0 0 1 4.402 3.276l2.814 9.382a.658.658 0 0 0 .628.467h23.656a.658.658 0 0 0 .628-.467l2.814-9.385a4.572 4.572 0 0 1 4.402-3.273h7.234A4.599 4.599 0 0 1 63 41.385v14.437a7.225 7.225 0 0 1-7.219 7.219zM4.594 40.729a.656.656 0 0 0-.657.656v14.437a3.286 3.286 0 0 0 3.282 3.282H55.78a3.286 3.286 0 0 0 3.282-3.282V41.385a.656.656 0 0 0-.657-.656h-7.234a.65.65 0 0 0-.628.467L47.73 50.58a4.628 4.628 0 0 1-4.402 3.274H19.672a4.567 4.567 0 0 1-4.402-3.276l-2.814-9.382a.65.65 0 0 0-.628-.467H4.594z"
              ></path>
            </g>
          </svg>
        </div>
        <div>
          <span class="text-light h6">Drag & Drop here</span>
          <p class="btn-container btn-container-center clearfix">
            <label for="fileDropRef" class="btn btn-xs btn-primary">
              Browse
            </label>
          </p>
        </div>
      </div>
      <input type="file"
        name="documents"
        style="display: none;"
        #fileDropRef
        id="fileDropRef"
        (change)="fileBrowseHandler($event.target)"
        [accept]="ACCEPTED_FILE_EXTENSIONS"
        class="form-control"
        multiple="multiple" />
    </div>
    <span class="invalid-feedback d-block">
      <span [innerHTML]="error"></span>
    </span>
  </div>

  <div class="col-sm-7">
    <ul class="list-file spacer-xs-top-05">
      <li class="list-file-{{ getFileExtension(fileName) }} spacer-xs-bottom-30"
        *ngFor="let fileName of fileNames; let i = index">
        <a href="javascript:void(0)" title="See the file">{{ fileName }}</a>
        <a class="file-remove spacer-xs-left-10"
          href="javascript:void(0)"
          (click)="deleteFile(i)"
          *ngIf="isLocaleDelete"
          title="Remove the file"
          >(X)</a>
      </li>
    </ul>
    <div (click)="deleteAll()" *ngIf="!isLocaleDelete && fileNames.length > 0">
      <a class="file-remove"
        href="javascript:void(0)"
        id="delete-all"
        title="Remove all documents">
        Remove all
      </a>
      <a href="javascript:void(0)" class="spacer-xs-left-10">Remove all files</a>
    </div>
  </div>
</div>


Include styles for the box and for the file list.

/** BOX FILE */
.docs-preview-container {
  background-color: #f6f6f6;
  padding: 15px;
}
.col-sm-5 {
  width: 41.66666667%;
  float: left;
}
.docs-preview {
  border: 1px dashed #ccc;
  background-color: #f6f6f6;
  background-position: 0 0, 50px 50px;
  min-height: 206px;
  text-align: center;
}

/** FILE LIST */
.col-sm-7 {
  width: 58.33333333%;
  float: left;
}
.list-file li {
  text-align: left;
  color: black;
  padding: 3px 0 3px 3px;
  background-repeat: no-repeat;
  background-position: left 2px;
  list-style: none;
  margin-bottom: 5px;
  background-size: 24px;
}
.list-file li a {
  cursor: default;
  color: #222427;
}
.list-file li a,
.list-file li a:hover,
.list-file li a:focus {
  text-decoration: none;
  color: #00328e;
  outline: none;
}
.list-file li a.file-remove {
  cursor: pointer;
  display: inline-block;
  width: 12px;
  height: 100%;
  background-repeat: no-repeat;
  background-position: center;
  background-size: 12px;
  margin-left: 15px;
  /* text-indent: -9999px; */
}

/** ERRORS */
.invalid-feedback {
  font-size: 14px;
  color: #d12e2e;
  line-height: 24px;
}


The parent component will have to include both the app-box-file tag component and the method to retrieve the manage files in the parent form.

// parent component - app.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
  styleUrls: [ './app.component.css' ]
})
export class AppComponent  {
  filesToUpload: any[] = null;
  filesUploaded: string[] = [];

  /**
   * Add files, to upload, to parent component.
   * @param files
   */
   addFilesToUpload(files: unknown) {
    console.debug('DND files: ', files);
    this.filesToUpload = (files as any[]);
  }
}

<!-- parent component - app.component.html -->
<app-box-file
    [fileNames]="filesUploaded || []"
    (filesEvent)="addFilesToUpload($event)"></app-box-file>


If desired, it is possible to insert the maximum and minimum number of files that must be inserted.

Finally include everything inside the module.

// app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';

import { AppComponent } from './app.component';
import { BoxFileComponent } from './box-file/box-file.component';
import { DndDirective } from './directives/dnd.directive';

@NgModule({
  imports: [BrowserModule, FormsModule],
  declarations: [AppComponent, BoxFileComponent, DndDirective],
  bootstrap: [AppComponent],
})
export class AppModule {}


Below is the demo of the implementation.


That’s all for Angular drag & drop upload forms.
Try it at home!

1
1 person likes this.
Please wait...

Leave a Reply

Thanks for choosing to leave a comment.
Please keep in mind that all comments are moderated according to our comment policy, and your email address will NOT be published.
Please do NOT use keywords in the name field. Let's have a personal and meaningful conversation.