mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-08-10 00:18:57 +00:00
Enhancement: dashboard improvements, drag-n-drop reorder dashboard views (#4252)
* Updated dashboard * Make entire screen dropzone on dashboard too * Floating upload widget status alerts * Visual tweaks: spacing, borders * Better empty view widget * Support drag + drop reorder of dashboard saved views * Update messages.xlf * Disable dashbaord dnd if global dnd active * Remove ngx-file-drop dep, rebuild file-drop & upload files widget * Revert custom file drop implementation * Try patch-package fix * Simplify dropzone transitions to make more reliable * Update messages.xlf * Update dashboard.spec.ts * Fix coverage
This commit is contained in:
14
src-ui/src/app/components/file-drop/file-drop.component.html
Normal file
14
src-ui/src/app/components/file-drop/file-drop.component.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<div [class.pe-none]="fileIsOver">
|
||||
<ng-content select="[content]"></ng-content>
|
||||
</div>
|
||||
|
||||
<div class="global-dropzone-overlay position-fixed top-0 start-0 bottom-0 end-0 text-center pe-none fade" [class.show]="fileIsOver" [class.hide]="hidden">
|
||||
<h2 class="pe-none position-absolute top-50 start-50 translate-middle" i18n>Drop files to begin upload</h2>
|
||||
</div>
|
||||
|
||||
<ngx-file-drop
|
||||
dropZoneClassName="visually-hidden"
|
||||
contentClassName="visually-hidden"
|
||||
(onFileDrop)="dropped($event)"
|
||||
#ngxFileDrop>
|
||||
</ngx-file-drop>
|
@@ -0,0 +1,8 @@
|
||||
.global-dropzone-overlay {
|
||||
background-color: hsla(var(--pngx-primary), var(--pngx-primary-lightness), .8);
|
||||
z-index: 1200;
|
||||
|
||||
h2 {
|
||||
color: var(--pngx-primary-text-contrast)
|
||||
}
|
||||
}
|
177
src-ui/src/app/components/file-drop/file-drop.component.spec.ts
Normal file
177
src-ui/src/app/components/file-drop/file-drop.component.spec.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
||||
import {
|
||||
ComponentFixture,
|
||||
TestBed,
|
||||
discardPeriodicTasks,
|
||||
fakeAsync,
|
||||
flush,
|
||||
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'
|
||||
import { NgxFileDropEntry, NgxFileDropModule } from 'ngx-file-drop'
|
||||
|
||||
describe('FileDropComponent', () => {
|
||||
let component: FileDropComponent
|
||||
let fixture: ComponentFixture<FileDropComponent>
|
||||
let permissionsService: PermissionsService
|
||||
let toastService: ToastService
|
||||
let settingsService: SettingsService
|
||||
let uploadDocumentsService: UploadDocumentsService
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [FileDropComponent, ToastsComponent],
|
||||
providers: [],
|
||||
imports: [HttpClientTestingModule, NgxFileDropModule],
|
||||
}).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.prototype as any,
|
||||
'uploadFile'
|
||||
)
|
||||
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)
|
||||
component.dropped([
|
||||
{
|
||||
fileEntry: {
|
||||
isFile: true,
|
||||
file: (callback) => {
|
||||
callback(
|
||||
new File(
|
||||
[new Blob(['testing'], { type: 'application/pdf' })],
|
||||
'file.pdf'
|
||||
)
|
||||
)
|
||||
},
|
||||
},
|
||||
} as unknown as NgxFileDropEntry,
|
||||
])
|
||||
tick(3000)
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
expect(uploadSpy).toHaveBeenCalled()
|
||||
discardPeriodicTasks()
|
||||
}))
|
||||
|
||||
it('should ignore events if disabled', fakeAsync(() => {
|
||||
settingsService.globalDropzoneEnabled = false
|
||||
expect(settingsService.globalDropzoneActive).toBeFalsy()
|
||||
component.onDragOver(new Event('dragover') as DragEvent)
|
||||
expect(settingsService.globalDropzoneActive).toBeFalsy()
|
||||
settingsService.globalDropzoneActive = true
|
||||
component.onDragLeave(new Event('dragleave') as DragEvent)
|
||||
expect(settingsService.globalDropzoneActive).toBeTruthy()
|
||||
component.onDrop(new Event('drop') as DragEvent)
|
||||
expect(settingsService.globalDropzoneActive).toBeTruthy()
|
||||
}))
|
||||
|
||||
it('should hide if app loses focus', fakeAsync(() => {
|
||||
const leaveSpy = jest.spyOn(component, 'onDragLeave')
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
settingsService.globalDropzoneEnabled = true
|
||||
component.onDragOver(new Event('dragover') as DragEvent)
|
||||
tick(1)
|
||||
expect(component.hidden).toBeFalsy()
|
||||
expect(component.fileIsOver).toBeTruthy()
|
||||
jest.spyOn(document, 'hidden', 'get').mockReturnValue(true)
|
||||
component.onVisibilityChange()
|
||||
expect(leaveSpy).toHaveBeenCalled()
|
||||
flush()
|
||||
}))
|
||||
|
||||
it('should hide on window blur', fakeAsync(() => {
|
||||
const leaveSpy = jest.spyOn(component, 'onDragLeave')
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
settingsService.globalDropzoneEnabled = true
|
||||
component.onDragOver(new Event('dragover') as DragEvent)
|
||||
tick(1)
|
||||
expect(component.hidden).toBeFalsy()
|
||||
expect(component.fileIsOver).toBeTruthy()
|
||||
jest.spyOn(document, 'hidden', 'get').mockReturnValue(true)
|
||||
component.onWindowBlur()
|
||||
expect(leaveSpy).toHaveBeenCalled()
|
||||
flush()
|
||||
}))
|
||||
})
|
98
src-ui/src/app/components/file-drop/file-drop.component.ts
Normal file
98
src-ui/src/app/components/file-drop/file-drop.component.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Component, HostListener, ViewChild } from '@angular/core'
|
||||
import { NgxFileDropComponent, NgxFileDropEntry } from 'ngx-file-drop'
|
||||
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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@ViewChild('ngxFileDrop') ngxFileDrop: NgxFileDropComponent
|
||||
|
||||
@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()
|
||||
// pass event onto ngx-file-drop to handle files
|
||||
this.ngxFileDrop.dropFiles(event)
|
||||
this.onDragLeave(event, true)
|
||||
}
|
||||
|
||||
public dropped(files: NgxFileDropEntry[]) {
|
||||
this.uploadDocumentsService.onNgxFileDrop(files)
|
||||
if (files.length > 0)
|
||||
this.toastService.showInfo($localize`Initiating upload...`, 3000)
|
||||
}
|
||||
|
||||
@HostListener('window:blur', ['$event']) public onWindowBlur() {
|
||||
if (this.fileIsOver) this.onDragLeave(null)
|
||||
}
|
||||
|
||||
@HostListener('document:visibilitychange', ['$event'])
|
||||
public onVisibilityChange() {
|
||||
if (document.hidden && this.fileIsOver) this.onDragLeave(null)
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user