File

src/app/shared/components/file-upload/file-upload.component.ts

Description

Component for loading a file from disk

Metadata

Index

Properties
Methods
Inputs
Outputs

Inputs

accept
Type : string
Required :  true

Accepted file types

loader
Type : Type<FileLoader<T, OptionsT>>
Required :  true

File loader

options
Type : OptionsT
Required :  true

File loader options

Outputs

loadCancelled
Type : void

Loading cancelled events

loadCompleted
Type : T[]

Loading completed events

loadErrored
Type : FileLoadError

Loading error events

loadStarted
Type : File

Loading start events

progress
Type : number

Progress events

Methods

cancelLoad
cancelLoad(error?: FileLoadError)

Cancels the currently loading file

Parameters :
Name Type Optional
error FileLoadError Yes
Returns : void
load
load(el: HTMLInputElement)

Loads a file

Parameters :
Name Type Optional Description
el HTMLInputElement No

Input element

Returns : void
reset
reset()

Resets subscriptions and uploaded file

Returns : void

Properties

Optional file
Type : File

Loaded file

import { ChangeDetectionStrategy, Component, inject, Injector, input, output, Type } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { HraCommonModule } from '@hra-ui/common';
import { FileLoader, FileLoaderEvent } from '@hra-ui/common/fs';
import { ButtonsModule } from '@hra-ui/design-system/buttons';
import { DeleteFileButtonComponent } from '@hra-ui/design-system/buttons/delete-file-button';
import { reduce, Subscription } from 'rxjs';

/**
 * Error when wrong file type is uploaded
 */
export interface FileTypeError {
  /** Error type */
  type: 'type-error';
  /** Expected file type */
  expected: string;
  /** Received file type */
  received?: string;
}

/**
 * Error encountered during file parsing
 */
export interface FileParseError {
  /** Error type */
  type: 'parse-error';
  /** Cause of error */
  cause: unknown;
}

/** Combined file load error type */
export type FileLoadError = FileTypeError | FileParseError;

/** Component for loading a file from disk */
@Component({
  selector: 'ccf-file-upload',
  imports: [HraCommonModule, MatIconModule, ButtonsModule, DeleteFileButtonComponent],
  templateUrl: './file-upload.component.html',
  styleUrl: './file-upload.component.scss',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FileUploadComponent<T, OptionsT> {
  /** Accepted file types */
  readonly accept = input.required<string>();
  /** File loader */
  readonly loader = input.required<Type<FileLoader<T, OptionsT>>>();
  /** File loader options */
  readonly options = input.required<OptionsT>();

  /** Progress events */
  // eslint-disable-next-line @angular-eslint/no-output-native
  readonly progress = output<number>();
  /** Loading start events */
  readonly loadStarted = output<File>();
  /** Loading cancelled events */
  readonly loadCancelled = output<void>();
  /** Loading error events */
  readonly loadErrored = output<FileLoadError>();
  /** Loading completed events */
  readonly loadCompleted = output<T[]>();

  /** Loaded file */
  file?: File;

  /** Reference to injector */
  private readonly injector = inject(Injector);

  /** Subscription for data observable */
  private subscription?: Subscription;

  /**
   * Resets subscriptions and uploaded file
   */
  reset(): void {
    this.subscription?.unsubscribe();
    this.file = undefined;
    this.subscription = undefined;
  }

  /**
   * Loads a file
   *
   * @param el Input element
   */
  load(el: HTMLInputElement): void {
    if (el.files === null) {
      return;
    } else if (this.subscription) {
      this.cancelLoad();
    }

    const { injector, loader, options } = this;
    const file = (this.file = el.files[0]);
    if (!this.hasAcceptableFileType(file)) {
      this.cancelLoad({
        type: 'type-error',
        expected: this.accept(),
        received: this.getFileSuffix(file).slice(1),
      });
      return;
    }

    const loaderInstance = injector.get(loader());
    const event$ = loaderInstance.load(file, options());
    const data$ = event$.pipe(reduce((acc, event) => this.handleLoadEvent(acc, event), [] as T[]));

    this.subscription = data$.subscribe({
      next: (data) => this.loadCompleted.emit(data),
      error: (error) =>
        this.cancelLoad({
          type: 'parse-error',
          cause: error,
        }),
    });
  }

  /**
   * Cancels the currently loading file
   */
  cancelLoad(error?: FileLoadError): void {
    this.reset();
    if (error) {
      this.loadErrored.emit(error);
    } else {
      this.loadCancelled.emit();
    }
  }

  /**
   * Handles a load event; returns data array or emits progress data
   */
  private handleLoadEvent(acc: T[], event: FileLoaderEvent<T>): T[] {
    if (event.type === 'data') {
      acc.push(event.data);
    } else if (event.type === 'progress' && event.total) {
      this.progress.emit(event.loaded / event.total);
    }

    return acc;
  }

  /**
   * Checks whether a file is one of the `accept()` file types.
   * This does not guarantee that the file has the correct content so the loader should
   * check for errors too.
   *
   * @param file File to check
   * @returns true if the file matches one of the accepted file types
   */
  private hasAcceptableFileType(file: File): boolean {
    const types = this.accept()
      .split(',')
      .map((type) => type.trim().toLowerCase());
    return types.includes(file.type.toLowerCase()) || types.includes(this.getFileSuffix(file));
  }

  /**
   * Gets file suffix including the leading '.'
   *
   * @param file File to extract suffix from
   * @returns The suffix part of the file name or an empty string if the file does not have a suffix
   */
  private getFileSuffix(file: File): string {
    return file.name.match(/\.[^.]+$/i)?.[0].toLowerCase() ?? '';
  }
}
@if (file) {
  <hra-delete-file-button hraFeature="delete-file" [fileName]="file.name" (cancelLoad)="cancelLoad()" />
} @else {
  <button
    hraFeature="upload-file"
    hraClickEvent
    mat-flat-button
    class="upload"
    type="button"
    hraPrimaryButton
    hraButtonSize="large"
    (click)="fileInput.click()"
  >
    <ng-content />
  </button>
  <input
    type="file"
    style="display: none"
    name="fileInput"
    [attr.accept]="accept()"
    (change)="load(fileInput)"
    #fileInput
  />
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""