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 d9a60652ad
commit 182b4e6c72
45 changed files with 1715 additions and 534 deletions

View File

@@ -230,7 +230,10 @@ export class ConsumerStatusService {
dismissCompleted() {
this.consumerStatus = this.consumerStatus.filter(
(status) => status.phase != FileStatusPhase.SUCCESS
(status) =>
![FileStatusPhase.SUCCESS, FileStatusPhase.FAILED].includes(
status.phase
)
)
}

View File

@@ -15,6 +15,7 @@ import {
SETTINGS_KEYS,
} from '../data/paperless-uisettings'
import { SettingsService } from './settings.service'
import { PaperlessSavedView } from '../data/paperless-saved-view'
describe('SettingsService', () => {
let httpTestingController: HttpTestingController
@@ -277,4 +278,22 @@ describe('SettingsService', () => {
)[0]
expect(req.request.method).toEqual('POST')
})
it('should update saved view sorting', () => {
httpTestingController
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
.flush(ui_settings)
const setSpy = jest.spyOn(settingsService, 'set')
settingsService.updateDashboardViewsSort([
{ id: 1 } as PaperlessSavedView,
{ id: 4 } as PaperlessSavedView,
])
expect(setSpy).toHaveBeenCalledWith(
SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER,
[1, 4]
)
httpTestingController
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
.flush(ui_settings)
})
})

View File

@@ -26,6 +26,7 @@ import { PaperlessUser } from '../data/paperless-user'
import { PermissionsService } from './permissions.service'
import { SavedViewService } from './rest/saved-view.service'
import { ToastService } from './toast.service'
import { PaperlessSavedView } from '../data/paperless-saved-view'
export interface LanguageOption {
code: string
@@ -54,6 +55,9 @@ export class SettingsService {
return this._renderer
}
public globalDropzoneEnabled: boolean = true
public globalDropzoneActive: boolean = false
constructor(
rendererFactory: RendererFactory2,
@Inject(DOCUMENT) private document,
@@ -531,4 +535,13 @@ export class SettingsService {
})
}
}
updateDashboardViewsSort(
dashboardViews: PaperlessSavedView[]
): Observable<any> {
this.set(SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER, [
...new Set(dashboardViews.map((v) => v.id)),
])
return this.storeSettings()
}
}

View File

@@ -5,12 +5,39 @@ import {
HttpTestingController,
} from '@angular/common/http/testing'
import { environment } from 'src/environments/environment'
import { HttpEventType, HttpResponse } from '@angular/common/http'
import { HttpEventType } from '@angular/common/http'
import {
ConsumerStatusService,
FileStatusPhase,
} from './consumer-status.service'
const files = [
{
lastModified: 1693349892540,
lastModifiedDate: new Date(),
name: 'file1.pdf',
size: 386,
type: 'application/pdf',
},
{
lastModified: 1695618533892,
lastModifiedDate: new Date(),
name: 'file2.pdf',
size: 358265,
type: 'application/pdf',
},
]
const fileList = {
item: (x) => {
return new File(
[new Blob(['testing'], { type: files[x].type })],
files[x].name
)
},
length: files.length,
} as unknown as FileList
describe('UploadDocumentsService', () => {
let httpTestingController: HttpTestingController
let uploadDocumentsService: UploadDocumentsService
@@ -32,66 +59,30 @@ describe('UploadDocumentsService', () => {
})
it('calls post_document api endpoint on upload', () => {
const fileEntry = {
name: 'file.pdf',
isDirectory: false,
isFile: true,
file: (callback) => {
return callback(
new File(
[new Blob(['testing'], { type: 'application/pdf' })],
'file.pdf'
)
)
},
}
uploadDocumentsService.uploadFiles([
{
relativePath: 'path/to/file.pdf',
fileEntry,
},
])
const req = httpTestingController.expectOne(
uploadDocumentsService.uploadFiles(fileList)
const req = httpTestingController.match(
`${environment.apiBaseUrl}documents/post_document/`
)
expect(req.request.method).toEqual('POST')
expect(req[0].request.method).toEqual('POST')
req.flush('123-456')
req[0].flush('123-456')
})
it('updates progress during upload and failure', () => {
const fileEntry = {
name: 'file.pdf',
isDirectory: false,
isFile: true,
file: (callback) => {
return callback(
new File(
[new Blob(['testing'], { type: 'application/pdf' })],
'file.pdf'
)
)
},
}
uploadDocumentsService.uploadFiles([
{
relativePath: 'path/to/file.pdf',
fileEntry,
},
])
uploadDocumentsService.uploadFiles(fileList)
expect(consumerStatusService.getConsumerStatusNotCompleted()).toHaveLength(
1
2
)
expect(
consumerStatusService.getConsumerStatus(FileStatusPhase.UPLOADING)
).toHaveLength(0)
const req = httpTestingController.expectOne(
const req = httpTestingController.match(
`${environment.apiBaseUrl}documents/post_document/`
)
req.event({
req[0].event({
type: HttpEventType.UploadProgress,
loaded: 100,
total: 300,
@@ -103,6 +94,52 @@ describe('UploadDocumentsService', () => {
})
it('updates progress on failure', () => {
uploadDocumentsService.uploadFiles(fileList)
let req = httpTestingController.match(
`${environment.apiBaseUrl}documents/post_document/`
)
expect(
consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED)
).toHaveLength(0)
req[0].flush(
{},
{
status: 400,
statusText: 'failed',
}
)
expect(
consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED)
).toHaveLength(1)
uploadDocumentsService.uploadFiles(fileList)
req = httpTestingController.match(
`${environment.apiBaseUrl}documents/post_document/`
)
req[0].flush(
{},
{
status: 500,
statusText: 'failed',
}
)
expect(
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,
@@ -116,54 +153,16 @@ describe('UploadDocumentsService', () => {
)
},
}
uploadDocumentsService.uploadFiles([
uploadDocumentsService.onNgxFileDrop([
{
relativePath: 'path/to/file.pdf',
fileEntry,
},
])
expect(uploadSpy).toHaveBeenCalled()
let req = httpTestingController.expectOne(
let req = httpTestingController.match(
`${environment.apiBaseUrl}documents/post_document/`
)
expect(
consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED)
).toHaveLength(0)
req.flush(
{},
{
status: 400,
statusText: 'failed',
}
)
expect(
consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED)
).toHaveLength(1)
uploadDocumentsService.uploadFiles([
{
relativePath: 'path/to/file.pdf',
fileEntry,
},
])
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/post_document/`
)
req.flush(
{},
{
status: 500,
statusText: 'failed',
}
)
expect(
consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED)
).toHaveLength(2)
})
})

View File

@@ -19,56 +19,61 @@ export class UploadDocumentsService {
private consumerStatusService: ConsumerStatusService
) {}
uploadFiles(files: NgxFileDropEntry[]) {
onNgxFileDrop(files: NgxFileDropEntry[]) {
for (const droppedFile of files) {
if (droppedFile.fileEntry.isFile) {
const fileEntry = droppedFile.fileEntry as FileSystemFileEntry
fileEntry.file((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()
},
})
})
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()
},
})
}
}