UNPKG

@ngx-file-upload/core

Version:

Angular 19 file upload core package for async file uploads in angular containing validation, upload queue and async uploading.

917 lines (900 loc) 30.6 kB
import * as i0 from '@angular/core'; import { NgModule, InjectionToken, inject } from '@angular/core'; import { provideHttpClient, withInterceptorsFromDi, HttpErrorResponse, HttpHeaders, HttpEventType, HttpClient } from '@angular/common/http'; import { BehaviorSubject, map, Subject, of, isObservable, from, concat, merge, ReplaySubject, timer } from 'rxjs'; import { takeUntil, distinctUntilChanged, filter, bufferCount, map as map$1, tap, take, distinctUntilKeyChanged, switchMap } from 'rxjs/operators'; class NgxFileUploadCoreModule { /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.5", ngImport: i0, type: NgxFileUploadCoreModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); } /** @nocollapse */ static { this.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "19.0.5", ngImport: i0, type: NgxFileUploadCoreModule }); } /** @nocollapse */ static { this.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "19.0.5", ngImport: i0, type: NgxFileUploadCoreModule, providers: [provideHttpClient(withInterceptorsFromDi())] }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.5", ngImport: i0, type: NgxFileUploadCoreModule, decorators: [{ type: NgModule, args: [{ imports: [], providers: [provideHttpClient(withInterceptorsFromDi())] }] }] }); var NgxFileUploadState; (function (NgxFileUploadState) { NgxFileUploadState[NgxFileUploadState["INVALID"] = 0] = "INVALID"; NgxFileUploadState[NgxFileUploadState["IDLE"] = 1] = "IDLE"; NgxFileUploadState[NgxFileUploadState["START"] = 2] = "START"; NgxFileUploadState[NgxFileUploadState["PENDING"] = 3] = "PENDING"; NgxFileUploadState[NgxFileUploadState["CONNECT"] = 4] = "CONNECT"; NgxFileUploadState[NgxFileUploadState["PROGRESS"] = 5] = "PROGRESS"; NgxFileUploadState[NgxFileUploadState["CANCELED"] = 6] = "CANCELED"; NgxFileUploadState[NgxFileUploadState["COMPLETED"] = 7] = "COMPLETED"; })(NgxFileUploadState || (NgxFileUploadState = {})); class NgxFileUploadForm { get errors() { return this.formErrors.getValue(); } constructor(request) { this.request = request; this.formControls = new Map(); this.formErrors = new BehaviorSubject(null); this.validationState = this.formErrors.pipe(map((errors) => (errors === null ? 'VALID' : 'INVALID'))); } /** * @description */ addControl(name, ctrl) { if (this.request.state > NgxFileUploadState.IDLE) { return; } const control = { errors: null, valid: true, dirty: true, send: true, ...ctrl }; this.formControls.set(name, control); this.validate(); } controls() { return this.formControls.entries(); } /** * @description */ getControl(ctrl) { return this.formControls.get(ctrl); } /** * @description */ getRawValue() { const raw = {}; for (const [name, ctrl] of this.formControls.entries()) { raw[name] = ctrl.value; } return raw; } /** * @description */ removeControl(key) { if (this.request.state > NgxFileUploadState.IDLE) { return; } this.formControls.delete(key); this.validate(); } /** * @description * @param formCtrlName * @param validator * @returns */ addValidator(formCtrlName, validator) { const control = this.formControls.get(formCtrlName); if (!control) { return; } const updated = { ...control, dirty: true, validator }; this.formControls.set(formCtrlName, updated); this.validate(); } // removeValidator(formCtrlName: string, validator: NgxFileUploadValidation<NgxFileuploadFormControl>) {} /** * @description */ setValue(ctrl, value) { const control = this.formControls.get(ctrl); if (!control) { return; } const updated = { ...control, dirty: true, value }; this.formControls.set(ctrl, updated); this.validate(); } /** * @description */ validate() { let formErrors = {}; for (const [name, ctrl] of this.formControls.entries()) { const validator = ctrl.validator; if (!validator) continue; let errors = ctrl.errors; if (ctrl.dirty === true) { errors = 'validate' in validator ? validator.validate(ctrl) : validator(ctrl); this.formControls.set(name, { ...ctrl, dirty: false, errors }); } if (errors !== null) { formErrors = { ...formErrors, ...(errors !== null ? { [name]: errors } : {}), }; } } this.formErrors.next(Object.keys(formErrors).length > 0 ? formErrors : null); } } /** * Represents an upload request, and store the data inside */ class NgxFileUploadRequestModel { constructor(filesToUpload) { this.filesToUpload = filesToUpload; this.errors = null; this.response = null; this.state = NgxFileUploadState.IDLE; this.uploaded = 0; this.progress = 0; this.hasError = false; } get files() { return Array.isArray(this.filesToUpload) ? this.filesToUpload : [this.filesToUpload]; } get name() { return this.files.map((file) => file.name); } get size() { return this.files.reduce((size, file) => size + file.size, 0); } get validationErrors() { return this.errors; } toJson() { return { files: this.files, hasError: this.hasError, name: this.name, progress: this.progress, response: this.response, size: this.size, state: this.state, uploaded: this.uploaded, validationErrors: this.validationErrors, }; } } class NgxFileUploadRequest { get change() { return this.change$.asObservable(); } get destroyed() { return this.destroyed$.asObservable(); } get data() { return this.model.toJson(); } set state(state) { const prevState = this.state; this.model.state = state; if (prevState !== state) { this.notifyObservers(); } } get state() { return this.model.state; } get form() { return this.uploadForm; } set url(url) { if (this.state <= NgxFileUploadState.IDLE) { this.uploadUrl = url; } } get url() { return this.uploadUrl; } constructor(http, files, options) { this.http = http; this.cancel$ = new Subject(); this.change$ = new Subject(); this.destroyed$ = new Subject(); this.totalSize = -1; this.options = { url: '', withCredentials: false, transferMethod: 'formdata', formControlNameToKebabCase: false, }; this.hooks = { beforeStart: [] }; this.headers = {}; this.uploadUrl = ''; this.requestId = ''; // additional form data hinzufuegen muessen this.options = { ...this.options, ...options }; this.model = new NgxFileUploadRequestModel(files); this.uploadForm = this.createForm(); this.state = this.isInvalid() ? NgxFileUploadState.INVALID : NgxFileUploadState.IDLE; this.headers = structuredClone(this.options.headers ?? {}); this.url = this.options.url; /** * subscribe to validation state change for form, if state changed update validility for uploda request */ this.uploadForm.validationState.pipe(takeUntil(this.destroyed$), distinctUntilChanged()).subscribe(() => this.updateValidility()); } getHeader(name) { return this.headers[name]; } /** * @description add additional headers to request */ addHeader(name, value) { if (this.state !== NgxFileUploadState.IDLE) { return; } this.headers[name] = value; } /** * @description remove additional headers from request */ removeHeader(name) { if (this.state !== NgxFileUploadState.IDLE) { return; } delete this.headers[name]; } beforeStart(hook) { this.hooks.beforeStart = [...this.hooks.beforeStart, hook]; } /** * cancel current file upload, this will complete change subject */ cancel() { if (this.isProgress() || this.isPending()) { this.model.state = NgxFileUploadState.CANCELED; this.notifyObservers(); this.cancel$.next(true); } } destroy() { this.finalizeUpload(); this.destroyed$.next(true); this.destroyed$.complete(); } /** * return true if upload was not completed since the server * sends back an error response */ hasError() { return this.model.response instanceof HttpErrorResponse; } isCompleted(ignoreError = false) { let isCompleted = this.isRequestCompleted(); isCompleted = isCompleted && (ignoreError || !this.hasError()); isCompleted = isCompleted || this.model.state === NgxFileUploadState.CANCELED; return isCompleted; } isCanceled() { return this.model.state === NgxFileUploadState.CANCELED; } isInvalid() { return this.state === NgxFileUploadState.INVALID || this.model.validationErrors !== null; } isProgress() { return this.state === NgxFileUploadState.PROGRESS || this.state === NgxFileUploadState.START; } isPending() { return this.state === NgxFileUploadState.PENDING; } isIdle() { return this.state === NgxFileUploadState.IDLE; } isRequestCompleted() { return this.state === NgxFileUploadState.COMPLETED; } /** * restart download again * reset state, and reset errors */ retry() { if ((this.isRequestCompleted() && this.hasError()) || this.isCanceled()) { this.model = new NgxFileUploadRequestModel(this.model.files); this.start(); } } /** * start file upload */ start() { if (!this.isIdle() && !this.isPending()) { return; } this.beforeStartHook$.pipe(filter((isAllowedToStart) => isAllowedToStart)).subscribe(() => { this.state = NgxFileUploadState.START; }); } /** * @description internal use only, before start hooks can become async if they return simple * boolean value or a promise. A boolean Value will turned into a Promise into an observable. */ run() { this.startUploadRequest().subscribe({ next: (event) => this.handleHttpEvent(event), error: (error) => this.handleError(error), }); } removeInvalidFiles() { if (this.state !== NgxFileUploadState.INVALID) { return; } const files = this.data.files.filter((file) => file.validationErrors === null); if (files.length) { this.model = new NgxFileUploadRequestModel(files); this.state = NgxFileUploadState.IDLE; } else { this.destroy(); } } /** * @description updates validility for upload request */ updateValidility() { if (this.state > NgxFileUploadState.IDLE) { return; } let validationErrors = {}; // validation errors file validationErrors = this.model.files.reduce((errors, file) => { if (file.validationErrors) { return { ...errors, [file.name]: { ...file.validationErrors }, }; } return errors; }, {}); // validation errors form if (this.form.errors !== null) { validationErrors = { ...validationErrors, ...this.form.errors }; } const errors = Object.keys(validationErrors).length ? validationErrors : null; if (errors === null) { this.state = NgxFileUploadState.IDLE; return; } this.state = NgxFileUploadState.INVALID; } /** * call hooks in order, see playground * @see https://rxviz.com/v/58GkkYv8 */ get beforeStartHook$() { const initialState = this.model.state; let hook$ = of(true); if (this.hooks.beforeStart.length) { const hooks = this.hooks.beforeStart.map((beforeStartFn) => { let hook = beforeStartFn(this); if (!isObservable(hook)) { hook = from(Promise.resolve(hook)); } return hook; }); // push hooks into a stream so everyone is called hook$ = concat(...hooks).pipe(bufferCount(this.hooks.beforeStart.length), map$1((result) => result.every((isAllowed) => isAllowed)), tap(() => (this.model.state !== initialState ? this.notifyObservers() : void 0))); } return hook$; } createForm() { const form = new NgxFileUploadForm(this); for (const [name, ctrlOptions] of Object.entries(this.options.formControls ?? {})) { form.addControl(name, ctrlOptions); } return form; } /** * build form data and send request to server */ startUploadRequest() { const uploadBody = this.createUploadBody(); const headers = this.createUploadHeaders(); /** * save size on start so we do not call it every time * since this running a reduce loop, and the size will not change * anymore after we start it */ this.totalSize = this.model.size; return this.http .post(this.url, uploadBody, { reportProgress: true, withCredentials: this.options.withCredentials, observe: 'events', headers, }) .pipe(takeUntil(merge(this.cancel$, this.destroyed$))); } /** * create upload body which will should be send */ createUploadBody() { if (this.options.transferMethod === 'formdata') { const formData = new FormData(); this.model.files.forEach((file) => { formData.append('files', file.raw, file.name); }); // add form controls for (const [name, ctrl] of this.form.controls()) { if (ctrl.send === false) { continue; } formData.append(name, JSON.stringify(ctrl.value)); } return formData; } // we have to add header how the file is named // since transfer method is body we can only upload 1 file return this.model.files[0].raw; } /** * create upload request headers */ createUploadHeaders() { let headers = new HttpHeaders(); /** if transfer method is body send all form data through headers */ if (this.options.transferMethod === 'body') { for (const [name, ctrl] of this.form.controls()) { if (ctrl.send === false) { continue; } let headerName = name; if (this.options.formControlNameToKebabCase) { headerName = name // transform fooBar to Foo-Bar- .replace(/(?:(\w)([^A-Z]+))/g, (_full, first, rest) => { return first.toUpperCase().concat(rest, '-'); }) // remove trailing - .slice(0, -1); } headers = headers.append(headerName, JSON.stringify(ctrl.value)); } } if (this.headers.authorization) { headers = this.createAuthroizationHeader(headers); } /** add additional headers which should send */ Object.keys(this.headers) .filter((header) => header !== 'authorization') .forEach((header) => (headers = headers.append(header, this.headers[header]))); return headers; } /** * create authorization header which will send */ createAuthroizationHeader(headers) { const authHeader = this.headers.authorization; if (authHeader) { if (typeof authHeader === 'string') { headers = headers.append('Authorization', `Bearer ${authHeader}`); } else { headers = headers.append('Authorization', `${authHeader.key ?? 'Bearer'} ${authHeader.token}`); } } return headers; } /** * request responds with an error */ handleError(response) { this.model.state = NgxFileUploadState.COMPLETED; this.model.response = response; this.model.hasError = true; this.notifyObservers(); } /** * handle all http events */ handleHttpEvent(event) { switch (event.type) { case HttpEventType.UploadProgress: this.handleProgress(event); break; case HttpEventType.Response: this.handleResponse(event); break; } } /** * handle http progress event */ handleProgress(event) { const loaded = event.loaded; const progress = (loaded * 100) / this.totalSize; this.model.state = NgxFileUploadState.PROGRESS; /** * for some reason the upload is sometimes a bit bigger then the files, * pretty sure this happens because of headers which are send makes the request a bit * bigger */ this.model.uploaded = Math.min(loaded, this.totalSize); this.model.progress = Math.min(Math.round(progress), 100); this.notifyObservers(); } /** * upload completed with an success */ handleResponse(res) { this.model.response = res; this.model.state = NgxFileUploadState.COMPLETED; this.notifyObservers(); this.finalizeUpload(); } /** * send notification to observers */ notifyObservers() { this.change$.next(this.data); } /** * upload has been completed, canceled or destroyed */ finalizeUpload() { this.change$.complete(); this.cancel$.complete(); } } class NgxFileUploadFile { constructor(file) { this.validationErrors = null; this.raw = file; this.size = file.size; this.type = file.type; this.name = file.name; } } /** * Factory to create upload requests */ class Factory { /** * construct upload factory */ constructor(httpClient) { this.httpClient = httpClient; } createUploadRequest(file, options, validator) { const files = Array.isArray(file) ? file : [file]; if (files.length) { const fileModels = files.map((file) => { const model = new NgxFileUploadFile(file); if (validator) { model.validationErrors = 'validate' in validator ? validator.validate(file) : validator(file); } return model; }); // * create one requests which holds all files return new NgxFileUploadRequest(this.httpClient, fileModels, options); } return null; } } /** * InjectionToken for NgxFileuploadFactory */ const NgxFileUploadFactory = new InjectionToken('Ngx Fileupload Factory', { providedIn: 'root', factory: () => new Factory(inject(HttpClient)), }); class NgxFileUploadQueue { constructor() { this.active = 0; this.queuedUploads = []; this.concurrentCount = -1; this.observedUploads = new WeakSet(); this.queue$ = new Subject(); this.queue$.subscribe((request) => { request.state = NgxFileUploadState.PENDING; this.writeToQueue(request); }); } set concurrent(count) { this.concurrentCount = count; } register(upload) { this.registerUploadChange(upload); } /** * register to upload change */ registerUploadChange(request) { if (!this.observedUploads.has(request)) { this.observedUploads.add(request); const change$ = request.change; /** register for changes which make request complete */ const uploadComplete$ = change$.pipe(filter(() => request.isCompleted(true)), take(1)); change$ .pipe(takeUntil(merge(request.destroyed, uploadComplete$)), distinctUntilChanged(), filter((data) => data.state === NgxFileUploadState.START)) .subscribe({ next: () => this.queue$.next(request), complete: () => this.requestCompleted(request), }); } } writeToQueue(request) { if (this.active < this.concurrentCount || this.concurrentCount === -1) { this.runRequest(request); return; } this.queuedUploads = [...this.queuedUploads, request]; } runRequest(request) { this.active += 1; request.state = NgxFileUploadState.CONNECT; request.run(); } /** * requests gets completed, this means request is pending or was progressing and the user * cancel request, remove it or even destroys them */ requestCompleted(request) { if (this.isInUploadQueue(request)) { this.removeFromQueue(request); } else { this.startNextInQueue(); } this.observedUploads.delete(request); } /** * checks upload is in queue */ isInUploadQueue(request) { return this.queuedUploads.indexOf(request) > -1; } /** * remove upload request from queued uploads */ removeFromQueue(request) { this.queuedUploads = this.queuedUploads.filter((upload) => upload !== request); } startNextInQueue() { this.active = Math.max(this.active - 1, 0); if (this.queuedUploads.length > 0) { const nextUpload = this.queuedUploads.shift(); this.runRequest(nextUpload); } } } const defaultStoreConfig = { concurrentUploads: 5, autoStart: false, }; class NgxFileUploadStorage { constructor(config) { this.uploads = new Map(); this.destroyed$ = new Subject(); this.bulkProcess = []; this.change$ = new ReplaySubject(1); this.uploadQueue = new NgxFileUploadQueue(); this.storeConfig = { ...defaultStoreConfig, ...(config || {}) }; this.uploadQueue.concurrent = this.storeConfig.concurrentUploads; } /** * submits if any upload changes his state, uploads * gets removed or added */ change() { return this.change$; } /** * add new upload to store */ add(upload) { const requests = Array.isArray(upload) ? upload : [upload]; requests.forEach((request) => { if (request.requestId && this.uploads.has(request.requestId)) { return; } request.requestId = request.requestId || this.generateUniqeRequestId(); this.uploads.set(request.requestId, request); // @todo better interface we only want run method but we do not want publish it // better strategy for this ? this.registerUploadEvents(request); }); this.afterUploadsAdd(requests); this.notifyObserver(); } destroy() { /** remove from all subscriptions */ this.destroyed$.next(true); /** stop all downloads */ this.stopAll(); /** destroy change stream */ this.destroyed$.complete(); this.change$.complete(); } remove(upload) { const id = typeof upload === 'string' ? upload : upload.requestId; const request = this.uploads.get(id); request?.destroy(); } purge() { let notify = false; this.uploads.forEach((request) => { if (request.isCompleted() || request.isInvalid()) { this.bulkProcess.push(request.requestId); notify = true; request.destroy(); } }); if (notify) { this.notifyObserver(); } } startAll() { this.uploads.forEach((upload) => { // zu schnell if (upload.isIdle()) { this.bulkProcess.push(upload.requestId); upload.start(); } }); this.notifyObserver(); } stopAll() { this.uploads.forEach((upload) => { this.bulkProcess.push(upload.requestId); upload.destroy(); }); this.notifyObserver(); } removeInvalid() { this.uploads.forEach((upload) => { if (upload.isInvalid()) { this.bulkProcess.push(upload.requestId); upload.destroy(); } }); this.notifyObserver(); } registerUploadEvents(request) { if (!request.isInvalid()) { this.queueRequest(request); } else { request.change .pipe(filter((data) => data.state === NgxFileUploadState.IDLE), take(1), takeUntil(request.destroyed)) .subscribe(() => { this.queueRequest(request); this.notifyObserver(); }); } request.destroyed .pipe(tap(() => this.uploads.delete(request.requestId)), take(1)) .subscribe(() => (this.isBulkProcess(request) ? this.removeBulkProcess(request) : this.notifyObserver())); } queueRequest(request) { this.uploadQueue.register(request); this.handleRequestChange(request); } /** * @description register to request change events, this will notify all observers * if state from upload state has been changed, this will not notify * if amount of uploaded size has been changed */ handleRequestChange(request) { const isAutoRemove = !!(this.storeConfig.removeCompleted ?? 0); request.change .pipe(distinctUntilKeyChanged('state'), tap(() => /** do not notify if bulk process */ { this.isBulkProcess(request) ? this.removeBulkProcess(request) : this.notifyObserver(); }), /* only continue if completed with no errors and autoremove is enabled */ filter(() => request.data.state === NgxFileUploadState.COMPLETED && !request.hasError() && isAutoRemove), /** wait for given amount of time before we remove item */ switchMap(() => timer(this.storeConfig.removeCompleted ?? 0)), /* automatically unsubscribe if request gets destroyed */ takeUntil(request.destroyed)) .subscribe(() => this.remove(request)); } afterUploadsAdd(requests) { if (this.storeConfig.autoStart) { requests.forEach((uploadRequest) => uploadRequest.start()); } } generateUniqeRequestId() { let reqId; do { reqId = Array.from({ length: 4 }, () => Math.random().toString(32).slice(2)).join('-'); } while (this.uploads.has(reqId)); return reqId; } notifyObserver() { this.change$.next(Array.from(this.uploads.values())); } removeBulkProcess(request) { this.bulkProcess = this.bulkProcess.filter((id) => request.requestId !== id); } isBulkProcess(request) { return this.bulkProcess.indexOf(request.requestId) > -1; } } class NgxFileUploadGroupedvalidator { constructor(validators) { this.validators = Array.isArray(validators) ? validators : []; } /** * add validators */ add(...validators) { this.validators = this.validators.concat(validators); } /** * clean up all validators */ clean() { this.validators = []; } /** * executes validator and returns validation result */ execValidator(validator, file) { /** we handle a validator class directly */ if ("validate" in validator) { return validator.validate(file); } /** we handle a validation function */ return validator(file); } } class NgxFileUploadAndValidator extends NgxFileUploadGroupedvalidator { validate(file) { const validationResult = {}; let hasErrors = false; for (const validator of this.validators) { const result = this.execValidator(validator, file); if (result !== null) { Object.assign(validationResult, result); hasErrors = true; } } return hasErrors ? validationResult : null; } } class NgxFileUploadOrValidator extends NgxFileUploadGroupedvalidator { validate(file) { let validationResult = {}; for (const validator of this.validators) { const result = this.execValidator(validator, file); if (result === null) { validationResult = null; break; } Object.assign(validationResult, result); } return validationResult; } } class NgxFileUploadValidationBuilder { static and(...validators) { return new NgxFileUploadAndValidator(validators); } static or(...validators) { return new NgxFileUploadOrValidator(validators); } } /* * Public API Surface of core */ /** * Generated bundle index. Do not edit. */ export { NgxFileUploadAndValidator, NgxFileUploadCoreModule, NgxFileUploadFactory, NgxFileUploadFile, NgxFileUploadForm, NgxFileUploadGroupedvalidator, NgxFileUploadOrValidator, NgxFileUploadQueue, NgxFileUploadRequest, NgxFileUploadState, NgxFileUploadStorage, NgxFileUploadValidationBuilder }; //# sourceMappingURL=ngx-file-upload-core.mjs.map