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:
shamoon
2023-09-28 10:18:12 -07:00
committed by GitHub
parent 96176589ca
commit 6973691cce
45 changed files with 1715 additions and 534 deletions

View 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>

View File

@@ -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)
}
}

View 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()
}))
})

View 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)
}
}