import { Observable, Subscription } from "rxjs"; import { Component, ElementRef, Input, ViewChild, forwardRef, Provider, inject, } from "@angular/core"; import { HttpClient, HttpErrorResponse, HttpEventType, HttpHeaderResponse, HttpRequest, HttpEvent, } from "@angular/common/http"; import { DragState } from "../../directives/drag-drop/drag-drop.directive"; // import { HttpEvent } from '@angular/common/http/src/response'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; import { switchMap } from "rxjs/operators"; export type RequestGenerator = ( file: File ) => HttpRequest | Observable>; export type DeleteRequestGenerator = ( uf: UploadFile ) => HttpRequest | Observable>; export enum UploadStatus { Initial, Uploading, Done, Error, } export class Upload { public subscription: Subscription; public progress: number = 0.0; public status: UploadStatus = UploadStatus.Initial; public request: HttpRequest; public error?: string; } export class UploadFile { public file: File; public upload: Upload; } function toArray(list: FileList): File[] { return Array.prototype.slice.call(list); } const VALUE_ACCESSOR: Provider = { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => SamUploadComponent), multi: true, }; export interface UploadRequiredControlLike { value: UploadFile[] | null | undefined; } function uploadRequiredValidator(control: UploadRequiredControlLike) { const error = { required: "A file is required.", }; const model: UploadFile[] | null | undefined = control.value; if (!model || !model.length) { return error; } const atLeastOneDone = model.some((uf: UploadFile) => { return uf.upload.status === UploadStatus.Done; }); if (!atLeastOneDone) { return error; } } // eslint-disable-next-line @typescript-eslint/no-namespace -- keeps the public `UploadValidator.Required(...)` call shape consumers already use export namespace UploadValidator { export const Required = uploadRequiredValidator; } @Component({ selector: "sam-upload", providers: [VALUE_ACCESSOR], templateUrl: "upload.template.html", standalone: false, }) export class SamUploadComponent implements ControlValueAccessor { private httpClient = inject(HttpClient); /** * The request the gets called after a file has been selected for upload. * Report progress must be true if you want the progress bar. */ @Input() public uploadRequest: RequestGenerator; /** * The request that gets called when the user click the (x). If the upload * is in progress, the upload is simply canceled. If delete fails, the file * is removed from the list and no error is presented to the user. */ @Input() public deleteRequest: DeleteRequestGenerator; /** * Max number of files that can be uploaded. If a file is in the error status * it does not count. 0 for infinite */ @Input() public maxFiles = 0; /** * Uploads will not start until the host calls component.startUpload() */ @Input() public uploadDeferred: boolean = false; /** * "accept" can be any value which is valid for input[accept] * () */ @Input() public accept: string; /** * Show an error if the file does match the regular expression */ @Input() public pattern: RegExp; /** * If maxFileSizeInBytes is exceeded, then display a warning */ @Input() public maxFileSizeInBytes: number = 0; /* * Input 508 identifier */ @Input() public name = "upload"; public dragState: DragState = DragState.NotDragging; public showMaxFilesError: boolean = false; public disabled: boolean = false; public isAcceptableFileType = true; public shouldShowDropTarget = false; /* The list of visible files. Does not include deleted files. Does include files with errors */ public _model: Array = []; private onChange: (value: UploadFile[]) => void; private onTouched: () => void; /* The hidden file input dom element */ @ViewChild("file", { static: true }) private fileInput: ElementRef; onDragStateChange(dragState: DragState) { this.shouldShowDropTarget = dragState !== DragState.NotDragging; } registerOnChange(fn: (value: UploadFile[]) => void) { this.onChange = fn; } registerOnTouched(fn: () => void) { this.onTouched = fn; } setDisabledState(disabled: boolean) { this.disabled = disabled; } writeValue(value: null | undefined | UploadFile[]) { if (value && value.length) { this._model = value; } else { this._model = []; this._clearInput(); } } onFilesChange(files: FileList) { this.onTouched(); this.showMaxFilesError = false; // convert to array for the convience of the standard array functions const asArray = toArray(files); const wouldBeTotal = asArray.length + this._model.length; if (this.maxFiles > 0 && wouldBeTotal > this.maxFiles) { this.showMaxFilesError = true; return; } if (asArray.length === 0) { return; } this._checkAcceptableFileType(asArray); if (!this.isAcceptableFileType) { return; } const ufs = asArray.map((f) => { return { file: f, upload: new Upload(), }; }); this.validateFiles(ufs); // concat old items and new items this._model = [...this._model, ...ufs]; if (!this.uploadDeferred) { this.doUpload(ufs); } this.emit(); } startUpload() { this.doUpload(this._model); } validateFiles(ufs: UploadFile[]) { ufs.forEach((uf) => { const { file } = uf; if (this.maxFileSizeInBytes && file.size > this.maxFileSizeInBytes) { uf.upload.status = UploadStatus.Error; uf.upload.error = "File too large"; } if (this.pattern && !this.pattern.test(file.name)) { uf.upload.status = UploadStatus.Error; uf.upload.error = "File type not supported"; } }); } doUpload(ufs: UploadFile[]) { ufs.forEach((uf) => { const { upload } = uf; if (upload.status !== UploadStatus.Initial) { return; } upload.status = UploadStatus.Uploading; const httpEvent$ = this._getHttpEventSteam(uf); upload.subscription = httpEvent$.subscribe( (event: HttpEvent) => { if (event.type === HttpEventType.UploadProgress) { if (event.total) { upload.progress = event.loaded / event.total; } } else if (event instanceof HttpHeaderResponse) { upload.status = UploadStatus.Done; } else if (event instanceof HttpErrorResponse) { upload.status = UploadStatus.Error; } if ( event && typeof event === "object" && "ok" in event && (event as { ok?: boolean }).ok === false ) { upload.error = "Upload failed"; upload.status = UploadStatus.Error; this.emit(); } }, (error: unknown) => { console.error("upload error", error); let toJson: { statusText?: string; message?: string } = {}; try { toJson = JSON.parse(String(error)); } catch {} upload.error = toJson.statusText || toJson.message || "Upload failed"; upload.status = UploadStatus.Error; this.emit(); }, () => { upload.status = UploadStatus.Done; this.emit(); } ); }); } onCloseClick(uf: UploadFile) { const { upload } = uf; if (upload.subscription && upload.status === UploadStatus.Uploading) { upload.subscription.unsubscribe(); } else if (this.deleteRequest && upload.status === UploadStatus.Done) { this.deleteFile(uf); } this.removeFileFromList(uf); } deleteFile(uf: UploadFile) { const delete$ = this._getDeleteRequestForFile(uf); // errors are intentionally ignored. In the case of an // error, show it in the console, but don't annoy the user. // There may be an extra file on the server, but that's // not the user's problem delete$.subscribe(); } removeFileFromList(uf: UploadFile) { this._model = this._model.filter((_uf) => _uf !== uf); if (!this._model.length) { this._clearInput(); } this.emit(); } anyFiles() { return !!(this._model && this._model.length); } shouldShowProgressBar(uf: UploadFile) { return uf.upload.status === UploadStatus.Uploading; } shouldShowError(uf: UploadFile) { return uf.upload.status === UploadStatus.Error; } shouldAllowMoreFiles() { if (!this.maxFiles) { return true; } return this._numFilesValid() < this.maxFiles; } emit() { this.onChange(this._model); } maxFilesErrorMessage() { return `The maximum number of files is ${this.maxFiles}`; } _numFilesValid() { return this._model.filter((uf) => uf.upload.status !== UploadStatus.Error) .length; } _getDeleteRequestForFile(uf: UploadFile) { const request = this.deleteRequest(uf); if (request instanceof Observable) { return request.pipe(switchMap((req) => this.httpClient.request(req))); } else if (request instanceof HttpRequest) { return this.httpClient.request(request); } else { throw new Error( "Request must be an HttpRequest or Observerable" ); } } _getHttpEventSteam(uf: UploadFile): Observable> { const { file, upload } = uf; const request = this.uploadRequest(file); if (request instanceof Observable) { return request.pipe( switchMap((req: HttpRequest) => { upload.request = req; return this.httpClient.request(req); }) ); } else if (request instanceof HttpRequest) { upload.request = request; return this.httpClient.request(request); } else { throw new Error( "Request must be an HttpRequest or Observerable" ); } } _clearInput() { // clear the input's internal value, or it will not // emit the change event if we select a file, deselect that file, // and select the same file again this.fileInput.nativeElement.value = ""; } _checkAcceptableFileType(uploadFiles: File[]) { // restrict the file type // () uploadFiles.forEach((uf) => { if ( this.accept && !uf.type.startsWith(this.accept.split("/")[0]) && uf.name.indexOf(this.accept) < 0 ) { this.isAcceptableFileType = false; } else { this.isAcceptableFileType = true; } }); } }