Uploading files is a common action that most users expect out of modern platforms. In this article we'll explore how to create a basic yet extensible file uploader using Angular, Angular Material and HotChocolate GraphQL. We'll be focusing on images but the same logic can be applied for any file type.
To start with we'll create a directive which will handle the drag & drop behaviour for files. This will then be incorporated into the component we'll create later.
import { Directive, EventEmitter, HostBinding, HostListener, Input, Output } from '@angular/core'; @Directive({ selector: '[appFileDropTarget]', exportAs: 'filedroptarget', standalone: true, }) export class FileDropTargetDirective { @Input() public disabled = false; @HostBinding('class.fileover') public fileOver = false; @Output() public fileDropped = new EventEmitter<FileList | null>(); @HostListener('dragover', ['$event']) onDragOver(evt: DragEvent) { evt.preventDefault(); evt.stopPropagation(); if (this.disabled) { return; } this.fileOver = true; } @HostListener('dragleave', ['$event']) public onDragLeave(evt: DragEvent) { evt.preventDefault(); evt.stopPropagation(); if (this.disabled) { return; } this.fileOver = false; } @HostListener('drop', ['$event']) public ondrop(evt: DragEvent) { evt.preventDefault(); evt.stopPropagation(); if (this.disabled) { return; } this.fileOver = false; const files = evt.dataTransfer?.files; if (files?.length) { this.fileDropped.emit(files); } } }
Lets now create the Angular component which will serve as the base reusable component for a file upload dialog to leverage.
<div appFileDropTarget class="file-upload-wrapper" (fileDropped)="upload($event)" [disabled]="disabled"> <svg class="svg-icon" version="1.1" xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 512 512" xmlns:xlink="http://www.w3.org/1999/xlink" > <g> <g fill="#231F20"> <path d="M148.4,167.8h58.2v157.8c0,5.8,4.7,10.4,10.4,10.4h78c5.8,0,10.4-4.7,10.4-10.4V167.8h58.2 c6.5,0.1,15.2-8.4,7.4-17.8L263.4,42.4c-2.2-2.2-8.3-5.7-14.7,0L141,150C132.8,159.3,142.3,168.4,148.4,167.8z M256,64.5 l82.4,82.4H295c-5.8,0-10.4,4.7-10.4,10.4v157.8h-57.2V157.4c0-5.8-4.7-10.4-10.4-10.4h-43.4L256,64.5z" /> <path d="m490.6,315.2h-61.3c-5.8,0-10.4,4.7-10.4,10.4v57.9h-325.8v-57.9c2.84217e-14-5.8-4.7-10.4-10.4-10.4h-61.3c-5.8,0-10.4,4.7-10.4,10.4v136.6c0,5.8 4.7,10.4 10.4,10.4h469.1c5.8,0 10.4-4.7 10.4-10.4v-136.6c0.1-5.7-4.6-10.4-10.3-10.4zm-10.5,136.6h-448.2v-115.7h40.4v57.9c0,5.8 4.7,10.4 10.4,10.4h346.6c5.8,0 10.4-4.7 10.4-10.4v-57.9h40.4v115.7z" /> </g> </g> </svg> <h3>{{ dropMessage }}</h3> <h3>{{ or }}</h3> <input type="file" #file (change)="upload(file.files)" style="display: none" [multiple]="allowMultiple" [attr.accept]="fileTypes" [disabled]="disabled" /> <button mat-raised-button color="secondary" (click)="file.click()" [disabled]="disabled"> {{ action }} </button> </div>
Now some basic styles
.file-upload-wrapper { display: flex; flex: 1 1 auto; flex-direction: column; align-items: center; &.fileover { svg.svg-icon { g { fill: #3ba245; } } } }
And lastly, the component itself.
import { CommonModule } from '@angular/common'; import { Component, ElementRef, EventEmitter, Input, Output, ViewChild } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { FileDropTargetDirective } from './file-drop-target.directive'; @Component({ standalone: true, selector: 'app-file-upload', templateUrl: './file-upload.component.html', styleUrl: './file-upload.component.scss', imports: [CommonModule, MatButtonModule, MatIconModule, FileDropTargetDirective], }) export class FileUploadComponent { @ViewChild('file') public file?: ElementRef; @Input() public disabled = false; @Input() allowMultiple: boolean = true; //TODO Handle this on drag&drop too. @Input() public fileTypes?: string; @Input() public action = 'Upload'; @Input() public dropMessage = 'Drag and drop file here'; @Input() public or = 'or'; @Output() public filesChange = new EventEmitter<FileList | null>(); public upload(files: FileList | null) { this.filesChange.emit(files); /* * Reset the file input * So that re-selecting the same file will re-trigger the change event. */ if (this.file && this.file.nativeElement) { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access this.file.nativeElement.value = null; } } }
Now that we have the base component defined, we can move onto the actual dialog.
To create a useful dialog, we'll be displaying the file upload section as well as a small preview section to allow the users to double check the image they have uploaded, and also to remove that image in case of an error.
<h1 mat-dialog-title>Upload File</h1> <mat-divider></mat-divider> <mat-dialog-content> <app-file-upload (filesChange)="upload($event)" action="Upload file" fileTypes=".png, .jpg, .jpeg" [disabled]="uploading" *ngIf="!fileSelected" ></app-file-upload> <div class="preview-wrapper" *ngIf="uploadedImage"> <div class="image-wrapper"> <mat-icon class="cancel-icon" (click)="onCancelImageClicked()">cancel</mat-icon> <img width="300px" alt="uploaded-file" [src]="uploadedImage" /> </div> </div> <div class="loading-overlay" *ngIf="saving"> <mat-spinner class="loading" [strokeWidth]="10"></mat-spinner> </div> </mat-dialog-content> <div class="footer" mat-dialog-actions> <button mat-raised-button (click)="onCancelClicked()" type="button">Cancel</button> <button mat-raised-button color="primary" [ngClass]="{ disabled: !fileSelected || saving }" (click)="onUploadClicked()" type="button" [disabled]="!fileSelected || saving" > <span *ngIf="!saving"> Upload </span> <span *ngIf="saving"> Uploading... </span> </button> </div>
Add some styles.
.preview-wrapper { display: flex; justify-content: center; margin-top: 20px; .image-wrapper { position: relative; .cancel-icon { cursor: pointer; position: absolute; right: -15px; top: -15px; } } } .loading-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 1000; background-color: lightgray; opacity: 0.5; display: flex; flex-direction: column; justify-content: center; align-items: center; .loading { margin-bottom: 10px; } } .footer { display: flex; flex-direction: row; justify-content: flex-end; align-items: center; background-color: white; box-shadow: inset 0px 1px 0px rgba(0, 0, 0, 0.1); button { height: 36px; margin: 10px; } .disabled { opacity: 0.4; } }
And now we can add our component logic.
import { Component, Inject } from '@angular/core'; import { MAT_DIALOG_DATA, MatDialogActions, MatDialogContent, MatDialogRef, MatDialogTitle, } from '@angular/material/dialog'; import { MatDividerModule } from '@angular/material/divider'; import { FileUploadComponent } from '../../../components/file-upload/file-upload.component'; import { FileDropTargetDirective } from '../../../components/file-upload/file-drop-target.directive'; import { CommonModule } from '@angular/common'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { AddProjectFloorplanGQL } from '../../../../gql/generated'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { finalize } from 'rxjs'; import { MatProgressSpinner } from '@angular/material/progress-spinner'; import { ApolloError } from '@apollo/client'; @Component({ standalone: true, templateUrl: './upload-floorplan-dialog.component.html', styleUrl: './upload-floorplan-dialog.component.scss', imports: [ MatDividerModule, MatDialogTitle, MatDialogActions, MatDialogContent, FileUploadComponent, FileDropTargetDirective, CommonModule, MatButtonModule, MatIconModule, MatSnackBarModule, MatProgressSpinner, ], }) export class UploadDialogComponent { protected uploading = false; protected uploadedImage: string | ArrayBuffer | null = null; protected fileSelected = false; protected selectedFile: File | null = null; protected saving = false; public constructor( private readonly dialogRef: MatDialogRef<UploadDialogComponent>, private readonly addFile: AddFileGQL, private readonly snackBar: MatSnackBar ) {} protected upload = (files: FileList | null) => { // TODO: If you want to allow multiple files, remove this check. if (!files || files.length !== 1) { return; } // TODO: If you want to restrict to a specific file type, uncomment this block. // if (files[0].type !== 'png') { // throw new Error('Only ".png" files can be uploaded.'); // } this.uploading = true; const reader = new FileReader(); reader.onload = () => { this.uploadedImage = reader.result; }; reader.readAsDataURL(files[0]); this.selectedFile = files[0]; this.fileSelected = true; this.uploading = false; }; protected onUploadClicked(): void { this.saving = true; // Send data to server. Code omitted for brevity. this.saving = false; } protected onCancelClicked(): void { this.dialogRef.close(); } protected onCancelImageClicked(): void { this.uploadedImage = null; this.fileSelected = false; this.selectedFile = null; } }