diff --git a/src-ui/e2e/dashboard/dashboard.spec.ts b/src-ui/e2e/dashboard/dashboard.spec.ts index 6a06eacee..34bbc4949 100644 --- a/src-ui/e2e/dashboard/dashboard.spec.ts +++ b/src-ui/e2e/dashboard/dashboard.spec.ts @@ -29,7 +29,6 @@ test('dashboard saved view show all', async ({ page }) => { .locator('pngx-widget-frame') .filter({ hasText: 'Inbox' }) .getByRole('link', { name: 'Show all' }) - .first() .click() await expect(page).toHaveURL(/view\/7/) await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/) diff --git a/src-ui/package-lock.json b/src-ui/package-lock.json index 2784dc691..e64cadc44 100644 --- a/src-ui/package-lock.json +++ b/src-ui/package-lock.json @@ -28,6 +28,7 @@ "ngx-color": "^9.0.0", "ngx-cookie-service": "^16.0.1", "ngx-drag-drop": "^16.1.0", + "ngx-file-drop": "^16.0.0", "ngx-ui-tour-ng-bootstrap": "^13.0.4", "rxjs": "^7.8.1", "tslib": "^2.6.2", @@ -14073,6 +14074,22 @@ "@angular/core": "^16.0.0" } }, + "node_modules/ngx-file-drop": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/ngx-file-drop/-/ngx-file-drop-16.0.0.tgz", + "integrity": "sha512-33RPoZBAiMkV110Rzu3iOrzGcG5M20S4sAiwLzNylfJobu9qVw5XR83FhUelSeqJRoaDxXBRKAozYCSnUf2CNw==", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">= 14.5.0", + "npm": ">= 6.9.0" + }, + "peerDependencies": { + "@angular/common": ">=14.0.0", + "@angular/core": ">=14.0.0" + } + }, "node_modules/ngx-ui-tour-core": { "version": "11.0.4", "resolved": "https://registry.npmjs.org/ngx-ui-tour-core/-/ngx-ui-tour-core-11.0.4.tgz", diff --git a/src-ui/package.json b/src-ui/package.json index 6b55f2d33..2fd5d82ee 100644 --- a/src-ui/package.json +++ b/src-ui/package.json @@ -30,6 +30,7 @@ "ngx-color": "^9.0.0", "ngx-cookie-service": "^16.0.1", "ngx-drag-drop": "^16.1.0", + "ngx-file-drop": "^16.0.0", "ngx-ui-tour-ng-bootstrap": "^13.0.4", "rxjs": "^7.8.1", "tslib": "^2.6.2", diff --git a/src-ui/src/app/app.component.html b/src-ui/src/app/app.component.html index ef2addf2c..7fbb8dc4a 100644 --- a/src-ui/src/app/app.component.html +++ b/src-ui/src/app/app.component.html @@ -1,10 +1,16 @@ - - - - - + + +
+

Drop files to begin upload

+
+
+ +
+
+
diff --git a/src-ui/src/app/app.component.spec.ts b/src-ui/src/app/app.component.spec.ts index 965da047d..e7e41e135 100644 --- a/src-ui/src/app/app.component.spec.ts +++ b/src-ui/src/app/app.component.spec.ts @@ -5,8 +5,10 @@ import { fakeAsync, tick, } from '@angular/core/testing' +import { By } from '@angular/platform-browser' import { Router } from '@angular/router' import { RouterTestingModule } from '@angular/router/testing' +import { NgxFileDropEntry, NgxFileDropModule } from 'ngx-file-drop' import { TourService, TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap' import { Subject } from 'rxjs' import { routes } from './app-routing.module' @@ -18,8 +20,8 @@ import { } from './services/consumer-status.service' import { PermissionsService } from './services/permissions.service' import { ToastService, Toast } from './services/toast.service' +import { UploadDocumentsService } from './services/upload-documents.service' import { SettingsService } from './services/settings.service' -import { FileDropComponent } from './components/file-drop/file-drop.component' describe('AppComponent', () => { let component: AppComponent @@ -30,15 +32,17 @@ describe('AppComponent', () => { let toastService: ToastService let router: Router let settingsService: SettingsService + let uploadDocumentsService: UploadDocumentsService beforeEach(async () => { TestBed.configureTestingModule({ - declarations: [AppComponent, ToastsComponent, FileDropComponent], + declarations: [AppComponent, ToastsComponent], providers: [], imports: [ HttpClientTestingModule, TourNgBootstrapModule, RouterTestingModule.withRoutes(routes), + NgxFileDropModule, ], }).compileComponents() @@ -48,6 +52,7 @@ describe('AppComponent', () => { settingsService = TestBed.inject(SettingsService) toastService = TestBed.inject(ToastService) router = TestBed.inject(Router) + uploadDocumentsService = TestBed.inject(UploadDocumentsService) fixture = TestBed.createComponent(AppComponent) component = fixture.componentInstance }) @@ -132,4 +137,44 @@ describe('AppComponent', () => { fileStatusSubject.next(new FileStatus()) expect(toastSpy).toHaveBeenCalled() }) + + it('should enable drag-drop if user has permissions', () => { + jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) + expect(component.dragDropEnabled).toBeTruthy() + }) + + it('should disable drag-drop if user does not have permissions', () => { + jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(false) + expect(component.dragDropEnabled).toBeFalsy() + }) + + it('should support drag drop', fakeAsync(() => { + expect(component.fileIsOver).toBeFalsy() + component.fileOver() + tick(1) + fixture.detectChanges() + expect(component.fileIsOver).toBeTruthy() + const dropzone = fixture.debugElement.query( + By.css('.global-dropzone-overlay') + ) + expect(dropzone).not.toBeNull() + component.fileLeave() + tick(700) + fixture.detectChanges() + expect(dropzone.classes['hide']).toBeTruthy() + // drop + const toastSpy = jest.spyOn(toastService, 'show') + const uploadSpy = jest.spyOn(uploadDocumentsService, 'onNgxFileDrop') + component.dropped([ + { + fileEntry: { + isFile: true, + file: () => {}, + }, + } as unknown as NgxFileDropEntry, + ]) + tick(3000) + expect(toastSpy).toHaveBeenCalled() + expect(uploadSpy).toHaveBeenCalled() + })) }) diff --git a/src-ui/src/app/app.component.ts b/src-ui/src/app/app.component.ts index b25078af0..015ad1115 100644 --- a/src-ui/src/app/app.component.ts +++ b/src-ui/src/app/app.component.ts @@ -5,6 +5,7 @@ import { Router } from '@angular/router' import { Subscription, first } from 'rxjs' import { ConsumerStatusService } from './services/consumer-status.service' import { ToastService } from './services/toast.service' +import { NgxFileDropEntry } from 'ngx-file-drop' import { UploadDocumentsService } from './services/upload-documents.service' import { TasksService } from './services/tasks.service' import { TourService } from 'ngx-ui-tour-ng-bootstrap' @@ -24,11 +25,16 @@ export class AppComponent implements OnInit, OnDestroy { successSubscription: Subscription failedSubscription: Subscription + private fileLeaveTimeoutID: any + fileIsOver: boolean = false + hidden: boolean = true + constructor( private settings: SettingsService, private consumerStatusService: ConsumerStatusService, private toastService: ToastService, private router: Router, + private uploadDocumentsService: UploadDocumentsService, private tasksService: TasksService, public tourService: TourService, private renderer: Renderer2, @@ -244,4 +250,46 @@ export class AppComponent implements OnInit, OnDestroy { }) }) } + + public get dragDropEnabled(): boolean { + return ( + this.settings.globalDropzoneEnabled && + this.permissionsService.currentUserCan( + PermissionAction.Add, + PermissionType.Document + ) + ) + } + + public fileOver() { + this.settings.globalDropzoneActive = true + // allows transition + setTimeout(() => { + this.fileIsOver = true + }, 1) + this.hidden = false + // stop fileLeave timeout + clearTimeout(this.fileLeaveTimeoutID) + } + + public fileLeave(immediate: boolean = false) { + this.settings.globalDropzoneActive = false + const ms = immediate ? 0 : 500 + + this.fileLeaveTimeoutID = setTimeout(() => { + this.fileIsOver = false + // await transition completed + setTimeout(() => { + this.hidden = true + }, 150) + }, ms) + } + + public dropped(files: NgxFileDropEntry[]) { + this.settings.globalDropzoneActive = false + this.fileLeave(true) + this.uploadDocumentsService.onNgxFileDrop(files) + if (files.length > 0) + this.toastService.showInfo($localize`Initiating upload...`, 3000) + } } diff --git a/src-ui/src/app/app.module.ts b/src-ui/src/app/app.module.ts index e75da16cf..945dfe515 100644 --- a/src-ui/src/app/app.module.ts +++ b/src-ui/src/app/app.module.ts @@ -35,6 +35,7 @@ import { DateDropdownComponent } from './components/common/date-dropdown/date-dr import { DocumentCardLargeComponent } from './components/document-list/document-card-large/document-card-large.component' import { DocumentCardSmallComponent } from './components/document-list/document-card-small/document-card-small.component' import { BulkEditorComponent } from './components/document-list/bulk-editor/bulk-editor.component' +import { NgxFileDropModule } from 'ngx-file-drop' import { TextComponent } from './components/common/input/text/text.component' import { SelectComponent } from './components/common/input/select/select.component' import { CheckComponent } from './components/common/input/check/check.component' @@ -99,7 +100,6 @@ import { ConsumptionTemplateEditDialogComponent } from './components/common/edit import { MailComponent } from './components/manage/mail/mail.component' import { UsersAndGroupsComponent } from './components/admin/users-groups/users-groups.component' import { DndModule } from 'ngx-drag-drop' -import { FileDropComponent } from './components/file-drop/file-drop.component' import localeAf from '@angular/common/locales/af' import localeAr from '@angular/common/locales/ar' @@ -242,7 +242,6 @@ function initializeApp(settings: SettingsService) { ConsumptionTemplateEditDialogComponent, MailComponent, UsersAndGroupsComponent, - FileDropComponent, ], imports: [ BrowserModule, @@ -251,6 +250,7 @@ function initializeApp(settings: SettingsService) { HttpClientModule, FormsModule, ReactiveFormsModule, + NgxFileDropModule, PdfViewerModule, NgSelectModule, ColorSliderModule, diff --git a/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.html b/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.html index 605aa7e64..58b9800b8 100644 --- a/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.html +++ b/src-ui/src/app/components/dashboard/widgets/upload-file-widget/upload-file-widget.component.html @@ -3,7 +3,7 @@
Drop documents anywhere or - +
diff --git a/src-ui/src/app/components/file-drop/file-drop.component.html b/src-ui/src/app/components/file-drop/file-drop.component.html deleted file mode 100644 index 0c0f4ea39..000000000 --- a/src-ui/src/app/components/file-drop/file-drop.component.html +++ /dev/null @@ -1,7 +0,0 @@ -
- -
- -
-

Drop files to begin upload

-
diff --git a/src-ui/src/app/components/file-drop/file-drop.component.scss b/src-ui/src/app/components/file-drop/file-drop.component.scss deleted file mode 100644 index e69de29bb..000000000 diff --git a/src-ui/src/app/components/file-drop/file-drop.component.spec.ts b/src-ui/src/app/components/file-drop/file-drop.component.spec.ts deleted file mode 100644 index e174c18ad..000000000 --- a/src-ui/src/app/components/file-drop/file-drop.component.spec.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { HttpClientTestingModule } from '@angular/common/http/testing' -import { - ComponentFixture, - TestBed, - fakeAsync, - tick, -} from '@angular/core/testing' -import { By } from '@angular/platform-browser' -import { PermissionsService } from 'src/app/services/permissions.service' -import { SettingsService } from 'src/app/services/settings.service' -import { ToastService } from 'src/app/services/toast.service' -import { UploadDocumentsService } from 'src/app/services/upload-documents.service' -import { ToastsComponent } from '../common/toasts/toasts.component' -import { FileDropComponent } from './file-drop.component' - -describe('FileDropComponent', () => { - let component: FileDropComponent - let fixture: ComponentFixture - let permissionsService: PermissionsService - let toastService: ToastService - let settingsService: SettingsService - let uploadDocumentsService: UploadDocumentsService - - beforeEach(() => { - TestBed.configureTestingModule({ - declarations: [FileDropComponent, ToastsComponent], - providers: [], - imports: [HttpClientTestingModule], - }).compileComponents() - - permissionsService = TestBed.inject(PermissionsService) - settingsService = TestBed.inject(SettingsService) - toastService = TestBed.inject(ToastService) - uploadDocumentsService = TestBed.inject(UploadDocumentsService) - - fixture = TestBed.createComponent(FileDropComponent) - component = fixture.componentInstance - fixture.detectChanges() - }) - - it('should enable drag-drop if user has permissions', () => { - jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) - expect(component.dragDropEnabled).toBeTruthy() - }) - - it('should disable drag-drop if user does not have permissions', () => { - jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(false) - expect(component.dragDropEnabled).toBeFalsy() - }) - - it('should disable drag-drop if disabled in settings', fakeAsync(() => { - jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) - settingsService.globalDropzoneEnabled = false - expect(component.dragDropEnabled).toBeFalsy() - - component.onDragOver(new Event('dragover') as DragEvent) - tick(1) - fixture.detectChanges() - expect(component.fileIsOver).toBeFalsy() - const dropzone = fixture.debugElement.query( - By.css('.global-dropzone-overlay') - ) - expect(dropzone.classes['hide']).toBeTruthy() - component.onDragLeave(new Event('dragleave') as DragEvent) - tick(700) - fixture.detectChanges() - // drop - const uploadSpy = jest.spyOn(uploadDocumentsService, 'uploadFiles') - const dragEvent = new Event('drop') - dragEvent['dataTransfer'] = { - files: { - item: () => {}, - length: 0, - }, - } - component.onDrop(dragEvent as DragEvent) - tick(3000) - expect(uploadSpy).not.toHaveBeenCalled() - })) - - it('should support drag drop, initiate upload', fakeAsync(() => { - jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) - expect(component.fileIsOver).toBeFalsy() - component.onDragOver(new Event('dragover') as DragEvent) - tick(1) - fixture.detectChanges() - expect(component.fileIsOver).toBeTruthy() - const dropzone = fixture.debugElement.query( - By.css('.global-dropzone-overlay') - ) - component.onDragLeave(new Event('dragleave') as DragEvent) - tick(700) - fixture.detectChanges() - expect(dropzone.classes['hide']).toBeTruthy() - // drop - const toastSpy = jest.spyOn(toastService, 'show') - const uploadSpy = jest.spyOn(uploadDocumentsService, 'uploadFiles') - const dragEvent = new Event('drop') - dragEvent['dataTransfer'] = { - files: { - item: () => { - return new File( - [new Blob(['testing'], { type: 'application/pdf' })], - 'file.pdf' - ) - }, - length: 1, - } as unknown as FileList, - } - component.onDrop(dragEvent as DragEvent) - tick(3000) - expect(toastSpy).toHaveBeenCalled() - expect(uploadSpy).toHaveBeenCalled() - })) -}) diff --git a/src-ui/src/app/components/file-drop/file-drop.component.ts b/src-ui/src/app/components/file-drop/file-drop.component.ts deleted file mode 100644 index 8b74fb8bb..000000000 --- a/src-ui/src/app/components/file-drop/file-drop.component.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { Component, HostListener } from '@angular/core' -import { - PermissionsService, - PermissionAction, - PermissionType, -} from 'src/app/services/permissions.service' -import { SettingsService } from 'src/app/services/settings.service' -import { ToastService } from 'src/app/services/toast.service' -import { UploadDocumentsService } from 'src/app/services/upload-documents.service' - -@Component({ - selector: 'pngx-file-drop', - templateUrl: './file-drop.component.html', - styleUrls: ['./file-drop.component.scss'], -}) -export class FileDropComponent { - private fileLeaveTimeoutID: any - fileIsOver: boolean = false - hidden: boolean = true - - constructor( - private settings: SettingsService, - private toastService: ToastService, - private uploadDocumentsService: UploadDocumentsService, - private permissionsService: PermissionsService - ) {} - - public get dragDropEnabled(): boolean { - return ( - this.settings.globalDropzoneEnabled && - this.permissionsService.currentUserCan( - PermissionAction.Add, - PermissionType.Document - ) - ) - } - - @HostListener('dragover', ['$event ']) onDragOver(event: DragEvent) { - if (!this.dragDropEnabled) return - event.preventDefault() - event.stopImmediatePropagation() - this.settings.globalDropzoneActive = true - // allows transition - setTimeout(() => { - this.fileIsOver = true - }, 1) - this.hidden = false - // stop fileLeave timeout - clearTimeout(this.fileLeaveTimeoutID) - } - - @HostListener('dragleave', ['$event']) public onDragLeave( - event: DragEvent, - immediate: boolean = false - ) { - if (!this.dragDropEnabled) return - event.preventDefault() - event.stopImmediatePropagation() - this.settings.globalDropzoneActive = false - - const ms = immediate ? 0 : 500 - - this.fileLeaveTimeoutID = setTimeout(() => { - this.fileIsOver = false - // await transition completed - setTimeout(() => { - this.hidden = true - }, 150) - }, ms) - } - - @HostListener('drop', ['$event']) public onDrop(event: DragEvent) { - if (!this.dragDropEnabled) return - event.preventDefault() - event.stopImmediatePropagation() - this.onDragLeave(event, true) - this.uploadDocumentsService.uploadFiles(event.dataTransfer.files) - if (event.dataTransfer.files.length) - this.toastService.showInfo($localize`Initiating upload...`, 3000) - } -} diff --git a/src-ui/src/app/services/upload-documents.service.spec.ts b/src-ui/src/app/services/upload-documents.service.spec.ts index d00dd38db..f1e1ae8b5 100644 --- a/src-ui/src/app/services/upload-documents.service.spec.ts +++ b/src-ui/src/app/services/upload-documents.service.spec.ts @@ -134,4 +134,35 @@ describe('UploadDocumentsService', () => { consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED) ).toHaveLength(2) }) + + it('accepts files via drag and drop', () => { + const uploadSpy = jest.spyOn( + UploadDocumentsService.prototype as any, + 'uploadFile' + ) + const fileEntry = { + name: 'file.pdf', + isDirectory: false, + isFile: true, + file: (callback) => { + return callback( + new File( + [new Blob(['testing'], { type: 'application/pdf' })], + 'file.pdf' + ) + ) + }, + } + uploadDocumentsService.onNgxFileDrop([ + { + relativePath: 'path/to/file.pdf', + fileEntry, + }, + ]) + expect(uploadSpy).toHaveBeenCalled() + + let req = httpTestingController.match( + `${environment.apiBaseUrl}documents/post_document/` + ) + }) }) diff --git a/src-ui/src/app/services/upload-documents.service.ts b/src-ui/src/app/services/upload-documents.service.ts index a7188896e..6a086cd99 100644 --- a/src-ui/src/app/services/upload-documents.service.ts +++ b/src-ui/src/app/services/upload-documents.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@angular/core' import { HttpEventType } from '@angular/common/http' +import { FileSystemFileEntry, NgxFileDropEntry } from 'ngx-file-drop' import { ConsumerStatusService, FileStatusPhase, @@ -18,50 +19,61 @@ export class UploadDocumentsService { private consumerStatusService: ConsumerStatusService ) {} - uploadFiles(files: FileList) { - for (let index = 0; index < files.length; index++) { - const file = files.item(index) - - let formData = new FormData() - formData.append('document', file, file.name) - let status = this.consumerStatusService.newFileUpload(file.name) - - status.message = $localize`Connecting...` - - this.uploadSubscriptions[file.name] = this.documentService - .uploadDocument(formData) - .subscribe({ - next: (event) => { - if (event.type == HttpEventType.UploadProgress) { - status.updateProgress( - FileStatusPhase.UPLOADING, - event.loaded, - event.total - ) - status.message = $localize`Uploading...` - } else if (event.type == HttpEventType.Response) { - status.taskId = event.body['task_id'] - status.message = $localize`Upload complete, waiting...` - this.uploadSubscriptions[file.name]?.complete() - } - }, - error: (error) => { - switch (error.status) { - case 400: { - this.consumerStatusService.fail(status, error.error.document) - break - } - default: { - this.consumerStatusService.fail( - status, - $localize`HTTP error: ${error.status} ${error.statusText}` - ) - break - } - } - this.uploadSubscriptions[file.name]?.complete() - }, - }) + onNgxFileDrop(files: NgxFileDropEntry[]) { + for (const droppedFile of files) { + if (droppedFile.fileEntry.isFile) { + const fileEntry = droppedFile.fileEntry as FileSystemFileEntry + fileEntry.file((file: File) => this.uploadFile(file)) + } } } + + uploadFiles(files: FileList) { + for (let index = 0; index < files.length; index++) { + this.uploadFile(files.item(index)) + } + } + + private uploadFile(file: File) { + let formData = new FormData() + formData.append('document', file, file.name) + let status = this.consumerStatusService.newFileUpload(file.name) + + status.message = $localize`Connecting...` + + this.uploadSubscriptions[file.name] = this.documentService + .uploadDocument(formData) + .subscribe({ + next: (event) => { + if (event.type == HttpEventType.UploadProgress) { + status.updateProgress( + FileStatusPhase.UPLOADING, + event.loaded, + event.total + ) + status.message = $localize`Uploading...` + } else if (event.type == HttpEventType.Response) { + status.taskId = event.body['task_id'] + status.message = $localize`Upload complete, waiting...` + this.uploadSubscriptions[file.name]?.complete() + } + }, + error: (error) => { + switch (error.status) { + case 400: { + this.consumerStatusService.fail(status, error.error.document) + break + } + default: { + this.consumerStatusService.fail( + status, + $localize`HTTP error: ${error.status} ${error.statusText}` + ) + break + } + } + this.uploadSubscriptions[file.name]?.complete() + }, + }) + } }