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

@@ -1,29 +1,46 @@
<pngx-page-header title="Dashboard" [subTitle]="subtitle" i18n-title>
<pngx-logo extra_classes="d-none d-md-block"></pngx-logo>
<pngx-logo extra_classes="d-none d-md-block mt-n2 me-1" height="3.5rem"></pngx-logo>
</pngx-page-header>
<div class="row">
<div class="col-lg-8">
<div tourAnchor="tour.dashboard">
<ng-container *ngIf="savedViewService.loading">
<div class="col-auto col-lg-8 col-xl-9 mb-4">
<div class="row row-cols-1 g-4" tourAnchor="tour.dashboard"
dndDropzone
[dndDisableIf]="settingsService.globalDropzoneActive"
dndEffectAllowed="move"
(dndDrop)="onDrop($event)"
>
<div *ngIf="savedViewService.loading" class="col">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
<ng-container i18n>Loading...</ng-container>
</div>
<div *ngIf="settingsService.offerTour()" class="col">
<pngx-welcome-widget (dismiss)="completeTour()"></pngx-welcome-widget>
</div>
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
<div *ngFor="let v of dashboardViews" class="col">
<pngx-saved-view-widget
[savedView]="v"
(dndStart)="onDragStart($event)"
(dndMoved)="onDragged(v)"
(dndEnd)="onDragEnd($event)"
>
</pngx-saved-view-widget>
</div>
</ng-container>
<pngx-welcome-widget *ngIf="settingsService.offerTour()" (dismiss)="completeTour()"></pngx-welcome-widget>
<div *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
<ng-container *ngFor="let v of savedViewService.dashboardViews; first as isFirst">
<pngx-saved-view-widget [savedView]="v"></pngx-saved-view-widget>
</ng-container>
<div class="p-1" dndPlaceholderRef></div>
</div>
</div>
<div class="col-auto col-lg-4 col-xl-3">
<div class="row row-cols-1 g-4">
<div class="col">
<pngx-statistics-widget></pngx-statistics-widget>
</div>
<div class="col">
<pngx-upload-file-widget></pngx-upload-file-widget>
</div>
</div>
</div>
<div class="col-lg-4">
<pngx-statistics-widget></pngx-statistics-widget>
<pngx-upload-file-widget></pngx-upload-file-widget>
</div>
</div>

View File

@@ -13,16 +13,59 @@ import { PermissionsService } from 'src/app/services/permissions.service'
import { By } from '@angular/platform-browser'
import { SavedViewWidgetComponent } from './widgets/saved-view-widget/saved-view-widget.component'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { NgxFileDropModule } from 'ngx-file-drop'
import { RouterTestingModule } from '@angular/router/testing'
import { TourNgBootstrapModule, TourService } from 'ngx-ui-tour-ng-bootstrap'
import { LogoComponent } from '../common/logo/logo.component'
import { of, throwError } from 'rxjs'
import { DndDropEvent, DndModule } from 'ngx-drag-drop'
import { ToastService } from 'src/app/services/toast.service'
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
const saved_views = [
{
name: 'Saved View 0',
id: 0,
show_on_dashboard: true,
show_in_sidebar: true,
sort_field: 'name',
sort_reverse: true,
filter_rules: [],
},
{
name: 'Saved View 1',
id: 1,
show_on_dashboard: false,
show_in_sidebar: false,
sort_field: 'name',
sort_reverse: true,
filter_rules: [],
},
{
name: 'Saved View 2',
id: 2,
show_on_dashboard: true,
show_in_sidebar: false,
sort_field: 'name',
sort_reverse: true,
filter_rules: [],
},
{
name: 'Saved View 3',
id: 3,
show_on_dashboard: true,
show_in_sidebar: false,
sort_field: 'name',
sort_reverse: true,
filter_rules: [],
},
]
describe('DashboardComponent', () => {
let component: DashboardComponent
let fixture: ComponentFixture<DashboardComponent>
let settingsService: SettingsService
let tourService: TourService
let toastService: ToastService
beforeEach(async () => {
TestBed.configureTestingModule({
@@ -47,33 +90,22 @@ describe('DashboardComponent', () => {
{
provide: SavedViewService,
useValue: {
dashboardViews: [
{
id: 1,
name: 'saved view 1',
show_on_dashboard: true,
sort_field: 'added',
sort_reverse: true,
filter_rules: [],
},
{
id: 2,
name: 'saved view 2',
show_on_dashboard: true,
sort_field: 'created',
sort_reverse: true,
filter_rules: [],
},
],
listAll: () =>
of({
all: [saved_views.map((v) => v.id)],
count: saved_views.length,
results: saved_views,
}),
dashboardViews: saved_views.filter((v) => v.show_on_dashboard),
},
},
],
imports: [
NgbAlertModule,
HttpClientTestingModule,
NgxFileDropModule,
RouterTestingModule,
TourNgBootstrapModule,
DndModule,
],
}).compileComponents()
@@ -82,7 +114,11 @@ describe('DashboardComponent', () => {
first_name: 'Foo',
last_name: 'Bar',
}
jest.spyOn(settingsService, 'get').mockImplementation((key) => {
if (key === SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER) return [0, 2, 3]
})
tourService = TestBed.inject(TourService)
toastService = TestBed.inject(ToastService)
fixture = TestBed.createComponent(DashboardComponent)
component = fixture.componentInstance
@@ -100,7 +136,7 @@ describe('DashboardComponent', () => {
it('should show dashboard widgets', () => {
expect(
fixture.debugElement.queryAll(By.directive(SavedViewWidgetComponent))
).toHaveLength(2)
).toHaveLength(saved_views.filter((v) => v.show_on_dashboard).length)
})
it('should end tour service if still running and welcome widget dismissed', () => {
@@ -116,4 +152,44 @@ describe('DashboardComponent', () => {
component.completeTour()
expect(settingsCompleteTourSpy).toHaveBeenCalled()
})
it('should disable global dropzone on start drag + drop, re-enable after', () => {
expect(settingsService.globalDropzoneEnabled).toBeTruthy()
component.onDragStart(null)
expect(settingsService.globalDropzoneEnabled).toBeFalsy()
component.onDragEnd(null)
expect(settingsService.globalDropzoneEnabled).toBeTruthy()
})
it('should update saved view sorting on drag + drop, show info', () => {
const settingsSpy = jest.spyOn(settingsService, 'updateDashboardViewsSort')
const toastSpy = jest.spyOn(toastService, 'showInfo')
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
component.onDrop({ index: 2, data: saved_views[0] } as DndDropEvent)
component.onDragged(saved_views[0])
expect(settingsSpy).toHaveBeenCalledWith([
saved_views[2],
saved_views[0],
saved_views[3],
])
expect(toastSpy).toHaveBeenCalled()
component.onDrop({ data: saved_views[3] } as DndDropEvent)
})
it('should update saved view sorting on drag + drop, show info2', () => {
jest.spyOn(settingsService, 'get').mockImplementation((key) => {
if (key === SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER) return []
})
fixture.destroy()
fixture = TestBed.createComponent(DashboardComponent)
component = fixture.componentInstance
fixture.detectChanges()
const toastSpy = jest.spyOn(toastService, 'showError')
jest
.spyOn(settingsService, 'storeSettings')
.mockReturnValue(throwError(() => new Error('unable to save')))
component.onDrop({ index: 2, data: saved_views[0] } as DndDropEvent)
component.onDragged(saved_views[0])
expect(toastSpy).toHaveBeenCalled()
})
})

View File

@@ -3,6 +3,10 @@ import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
import { TourService } from 'ngx-ui-tour-ng-bootstrap'
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'
import { DndDropEvent } from 'ngx-drag-drop'
import { ToastService } from 'src/app/services/toast.service'
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
@Component({
selector: 'pngx-dashboard',
@@ -10,12 +14,33 @@ import { TourService } from 'ngx-ui-tour-ng-bootstrap'
styleUrls: ['./dashboard.component.scss'],
})
export class DashboardComponent extends ComponentWithPermissions {
public dashboardViews: PaperlessSavedView[] = []
constructor(
public settingsService: SettingsService,
public savedViewService: SavedViewService,
private tourService: TourService
private tourService: TourService,
private toastService: ToastService
) {
super()
this.savedViewService.listAll().subscribe(() => {
const sorted: number[] = this.settingsService.get(
SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER
)
this.dashboardViews =
sorted?.length > 0
? sorted
.map((id) =>
this.savedViewService.dashboardViews.find((v) => v.id === id)
)
.concat(
this.savedViewService.dashboardViews.filter(
(v) => !sorted.includes(v.id)
)
)
.filter((v) => v)
: [...this.savedViewService.dashboardViews]
})
}
get subtitle() {
@@ -33,4 +58,35 @@ export class DashboardComponent extends ComponentWithPermissions {
this.settingsService.completeTour()
}
}
onDragStart(event: DragEvent) {
this.settingsService.globalDropzoneEnabled = false
}
onDragged(v: PaperlessSavedView) {
const index = this.dashboardViews.indexOf(v)
this.dashboardViews.splice(index, 1)
this.settingsService
.updateDashboardViewsSort(this.dashboardViews)
.subscribe({
next: () => {
this.toastService.showInfo($localize`Dashboard updated`)
},
error: (e) => {
this.toastService.showError($localize`Error updating dashboard`, e)
},
})
}
onDragEnd(event: DragEvent) {
this.settingsService.globalDropzoneEnabled = true
}
onDrop(event: DndDropEvent) {
if (typeof event.index === 'undefined') {
event.index = this.dashboardViews.length
}
this.dashboardViews.splice(event.index, 0, event.data)
}
}

View File

@@ -1,22 +1,38 @@
<pngx-widget-frame [title]="savedView.name" [loading]="loading">
<pngx-widget-frame
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"
[title]="savedView.name"
[loading]="loading"
[draggable]="savedView"
(dndStart)="dndStart.emit($event)"
(dndMoved)="dndMoved.emit($event)"
(dndCanceled)="dndCanceled.emit($event)"
(dndEnd)="dndEnd.emit($event)"
>
<a class="btn-link" header-buttons [routerLink]="[]" (click)="showAll()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }" i18n>Show all</a>
<a *ngIf="documents.length" class="btn-link" header-buttons [routerLink]="[]" (click)="showAll()" i18n>Show all</a>
<table content class="table table-sm table-hover table-borderless mb-0">
<table *ngIf="documents.length; else empty" content class="table table-hover mb-0 align-middle">
<thead>
<tr>
<th scope="col" i18n>Created</th>
<th scope="col" i18n>Title</th>
<th scope="col" class="d-none d-md-table-cell" i18n>Tags</th>
<th scope="col" class="d-none d-md-table-cell" i18n>Correspondent</th>
</tr>
</thead>
<tbody *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
<tbody>
<tr *ngFor="let doc of documents" (mouseleave)="mouseLeaveCard()">
<td><a routerLink="/documents/{{doc.id}}" class="d-block text-dark text-decoration-none">{{doc.created_date | customDate}}</a></td>
<td class="position-relative">
<a routerLink="/documents/{{doc.id}}" title="Edit" i18n-title class="d-block text-dark text-decoration-none">{{doc.title | documentTitle}}<pngx-tag [tag]="t" *ngFor="let t of doc.tags$ | async" class="ms-1" (click)="clickTag(t, $event)"></pngx-tag></a>
<td class="py-2 py-md-3"><a routerLink="/documents/{{doc.id}}" class="btn-link text-dark text-decoration-none">{{doc.created_date | customDate}}</a></td>
<td class="py-2 py-md-3">
<a routerLink="/documents/{{doc.id}}" title="Edit" i18n-title class="btn-link text-dark text-decoration-none">{{doc.title | documentTitle}}</a>
</td>
<td class="py-2 py-md-3 d-none d-md-table-cell">
<pngx-tag [tag]="t" *ngFor="let t of doc.tags$ | async" class="ms-1" (click)="clickTag(t, $event)"></pngx-tag>
</td>
<td class="position-relative py-2 py-md-3 d-none d-md-table-cell">
<a *ngIf="doc.correspondent !== null" class="btn-link" routerLink="/documents" [queryParams]="getCorrespondentQueryParams(doc.correspondent)">{{(doc.correspondent$ | async)?.name}}</a>
<div class="btn-group position-absolute top-50 end-0 translate-middle-y">
<a [href]="getPreviewUrl(doc)" title="View Preview" i18n-title target="_blank" class="btn btn-sm px-4 py-0 btn-dark border-dark-subtle"
<a [href]="getPreviewUrl(doc)" title="View Preview" i18n-title target="_blank" class="btn px-4 btn-dark border-dark-subtle"
[ngbPopover]="previewContent" [popoverTitle]="doc.title | documentTitle"
autoClose="true" popoverClass="shadow popover-preview" container="body" (mouseenter)="mouseEnterPreview(doc)" (mouseleave)="mouseLeavePreview()" #popover="ngbPopover">
<svg class="buttonicon-xs" fill="currentColor">
@@ -26,7 +42,7 @@
<ng-template #previewContent>
<object [data]="getPreviewUrl(doc) | safeUrl" class="preview" width="100%"></object>
</ng-template>
<a [href]="getDownloadUrl(doc)" class="btn btn-sm px-4 py-0 btn-dark border-dark-subtle" title="Download" i18n-title (click)="$event.stopPropagation()">
<a [href]="getDownloadUrl(doc)" class="btn px-4 btn-dark border-dark-subtle" title="Download" i18n-title (click)="$event.stopPropagation()">
<svg class="buttonicon-xs" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#download"/>
</svg>
@@ -37,4 +53,8 @@
</tbody>
</table>
<ng-template #empty>
<p i18n class="text-center text-muted mb-0 fst-italic">No documents</p>
</ng-template>
</pngx-widget-frame>

View File

@@ -5,9 +5,12 @@ table {
th:first-child {
width: 25%;
@media (min-width: 768px) {
width: 15%;
}
}
tbody app-tag {
tbody pngx-tag {
cursor: pointer;
}
@@ -22,3 +25,8 @@ tr:hover .btn-group {
opacity: 1;
pointer-events: all;
}
td.py-3 {
padding-top: 0.75em !important;
padding-bottom: 0.75em !important;
}

View File

@@ -28,6 +28,7 @@ import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
import { SavedViewWidgetComponent } from './saved-view-widget.component'
import { By } from '@angular/platform-browser'
import { SafeUrlPipe } from 'src/app/pipes/safeurl.pipe'
import { DndModule } from 'ngx-drag-drop'
const savedView: PaperlessSavedView = {
id: 1,
@@ -52,6 +53,7 @@ const documentResults = [
{
id: 3,
title: 'doc3',
correspondent: 0,
},
]
@@ -89,6 +91,7 @@ describe('SavedViewWidgetComponent', () => {
HttpClientTestingModule,
NgbModule,
RouterTestingModule.withRoutes(routes),
DndModule,
],
}).compileComponents()

View File

@@ -1,23 +1,29 @@
import {
Component,
EventEmitter,
Input,
OnDestroy,
OnInit,
Output,
QueryList,
ViewChildren,
} from '@angular/core'
import { Router } from '@angular/router'
import { Params, Router } from '@angular/router'
import { Subject, takeUntil } from 'rxjs'
import { PaperlessDocument } from 'src/app/data/paperless-document'
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'
import { ConsumerStatusService } from 'src/app/services/consumer-status.service'
import { DocumentService } from 'src/app/services/rest/document.service'
import { PaperlessTag } from 'src/app/data/paperless-tag'
import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type'
import {
FILTER_CORRESPONDENT,
FILTER_HAS_TAGS_ALL,
} from 'src/app/data/filter-rule-type'
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
@Component({
selector: 'pngx-saved-view-widget',
@@ -38,7 +44,8 @@ export class SavedViewWidgetComponent
private router: Router,
private list: DocumentListViewService,
private consumerStatusService: ConsumerStatusService,
public openDocumentsService: OpenDocumentsService
public openDocumentsService: OpenDocumentsService,
public documentListViewService: DocumentListViewService
) {
super()
}
@@ -46,6 +53,18 @@ export class SavedViewWidgetComponent
@Input()
savedView: PaperlessSavedView
@Output()
dndStart: EventEmitter<DragEvent> = new EventEmitter()
@Output()
dndMoved: EventEmitter<DragEvent> = new EventEmitter()
@Output()
dndCanceled: EventEmitter<DragEvent> = new EventEmitter()
@Output()
dndEnd: EventEmitter<DragEvent> = new EventEmitter()
documents: PaperlessDocument[] = []
unsubscribeNotifier: Subject<any> = new Subject()
@@ -141,4 +160,15 @@ export class SavedViewWidgetComponent
mouseLeaveCard() {
this.popover?.close()
}
getCorrespondentQueryParams(correspondentId: number): Params {
return correspondentId !== undefined
? queryParamsFromFilterRules([
{
rule_type: FILTER_CORRESPONDENT,
value: correspondentId.toString(),
},
])
: null
}
}

View File

@@ -42,5 +42,32 @@
</div>
</div>
</div>
<div class="list-group border-light mt-3">
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }">
<a *ngIf="statistics?.tag_count > 0" class="list-group-item d-flex justify-content-between align-items-center" routerLink="/tags/">
<ng-container i18n>Tags</ng-container>:
<span class="badge bg-secondary text-light rounded-pill">{{statistics?.tag_count | number}}</span>
</a>
</ng-container>
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
<a *ngIf="statistics?.correspondent_count > 0" class="list-group-item d-flex justify-content-between align-items-center" routerLink="/correspondents/">
<ng-container i18n>Correspondents</ng-container>:
<span class="badge bg-secondary text-light rounded-pill">{{statistics?.correspondent_count | number}}</span>
</a>
</ng-container>
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
<a *ngIf="statistics?.document_type_count > 0" class="list-group-item d-flex justify-content-between align-items-center" routerLink="/documenttypes/">
<ng-container i18n>Document Types</ng-container>:
<span class="badge bg-secondary text-light rounded-pill">{{statistics?.document_type_count | number}}</span>
</a>
</ng-container>
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
<a *ngIf="statistics?.storage_path_count > 0" class="list-group-item d-flex justify-content-between align-items-center" routerLink="/documenttypes/">
<ng-container i18n>Storage Paths</ng-container>:
<span class="badge bg-secondary text-light rounded-pill">{{statistics?.storage_path_count | number}}</span>
</a>
</ng-container>
</div>
</ng-container>
</pngx-widget-frame>

View File

@@ -11,24 +11,42 @@ import { environment } from 'src/environments/environment'
import { RouterTestingModule } from '@angular/router/testing'
import { routes } from 'src/app/app-routing.module'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { DndModule } from 'ngx-drag-drop'
import {
ConsumerStatusService,
FileStatus,
} from 'src/app/services/consumer-status.service'
import { Subject } from 'rxjs'
describe('StatisticsWidgetComponent', () => {
let component: StatisticsWidgetComponent
let fixture: ComponentFixture<StatisticsWidgetComponent>
let httpTestingController: HttpTestingController
let consumerStatusService: ConsumerStatusService
const fileStatusSubject = new Subject<FileStatus>()
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [StatisticsWidgetComponent, WidgetFrameComponent],
declarations: [
StatisticsWidgetComponent,
WidgetFrameComponent,
IfPermissionsDirective,
],
providers: [PermissionsGuard],
imports: [
HttpClientTestingModule,
NgbModule,
RouterTestingModule.withRoutes(routes),
DndModule,
],
}).compileComponents()
fixture = TestBed.createComponent(StatisticsWidgetComponent)
consumerStatusService = TestBed.inject(ConsumerStatusService)
jest
.spyOn(consumerStatusService, 'onDocumentConsumptionFinished')
.mockReturnValue(fileStatusSubject)
component = fixture.componentInstance
httpTestingController = TestBed.inject(HttpTestingController)
@@ -43,6 +61,12 @@ describe('StatisticsWidgetComponent', () => {
expect(req.request.method).toEqual('GET')
})
it('should reload after doc is consumed', () => {
const reloadSpy = jest.spyOn(component, 'reload')
fileStatusSubject.next(new FileStatus())
expect(reloadSpy).toHaveBeenCalled()
})
it('should display inbox link with count', () => {
const mockStats = {
documents_total: 200,
@@ -107,4 +131,62 @@ describe('StatisticsWidgetComponent', () => {
'CSV(10%)'
)
})
it('should limit mime types to 5 max', () => {
const mockStats = {
documents_total: 222,
documents_inbox: 18,
inbox_tag: 10,
document_file_type_counts: [
{
mime_type: 'application/pdf',
mime_type_count: 160,
},
{
mime_type: 'text/plain',
mime_type_count: 20,
},
{
mime_type: 'text/csv',
mime_type_count: 20,
},
{
mime_type: 'application/vnd.oasis.opendocument.text',
mime_type_count: 11,
},
{
mime_type: 'application/msword',
mime_type_count: 9,
},
{
mime_type: 'image/jpeg',
mime_type_count: 2,
},
],
character_count: 162312,
}
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}statistics/`
)
req.flush(mockStats)
fixture.detectChanges()
expect(fixture.nativeElement.textContent.replace(/\s/g, '')).toContain(
'PDF(72.1%)'
)
expect(fixture.nativeElement.textContent.replace(/\s/g, '')).toContain(
'TXT(9%)'
)
expect(fixture.nativeElement.textContent.replace(/\s/g, '')).toContain(
'CSV(9%)'
)
expect(fixture.nativeElement.textContent.replace(/\s/g, '')).toContain(
'ODT(5%)'
)
expect(fixture.nativeElement.textContent.replace(/\s/g, '')).toContain(
'Other(0.9%)'
)
})
})

View File

@@ -6,6 +6,7 @@ import { ConsumerStatusService } from 'src/app/services/consumer-status.service'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { environment } from 'src/environments/environment'
import * as mimeTypeNames from 'mime-names'
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
export interface Statistics {
documents_total?: number
@@ -13,6 +14,10 @@ export interface Statistics {
inbox_tag?: number
document_file_type_counts?: DocumentFileType[]
character_count?: number
tag_count?: number
correspondent_count?: number
document_type_count?: number
storage_path_count?: number
}
interface DocumentFileType {
@@ -25,14 +30,19 @@ interface DocumentFileType {
templateUrl: './statistics-widget.component.html',
styleUrls: ['./statistics-widget.component.scss'],
})
export class StatisticsWidgetComponent implements OnInit, OnDestroy {
export class StatisticsWidgetComponent
extends ComponentWithPermissions
implements OnInit, OnDestroy
{
loading: boolean = true
constructor(
private http: HttpClient,
private consumerStatusService: ConsumerStatusService,
private documentListViewService: DocumentListViewService
) {}
) {
super()
}
statistics: Statistics = {}
@@ -87,7 +97,7 @@ export class StatisticsWidgetComponent implements OnInit, OnDestroy {
this.reload()
this.subscription = this.consumerStatusService
.onDocumentConsumptionFinished()
.subscribe((status) => {
.subscribe(() => {
this.reload()
})
}

View File

@@ -1,24 +1,26 @@
<pngx-widget-frame title="Upload new documents" i18n-title *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Document }">
<div header-buttons>
<a *ngIf="getStatusSuccess().length > 0" (click)="dismissCompleted()" [routerLink]="[]" >
<span class="me-1" i18n="This button dismisses all status messages about processed documents on the dashboard (failed and successful)">Dismiss completed</span>
<svg xmlns="http://www.w3.org/2000/svg" width="1rem" height="1rem" fill="currentColor" class="bi bi-check2-all" viewBox="0 0 16 16">
<path d="M12.354 4.354a.5.5 0 0 0-.708-.708L5 10.293 1.854 7.146a.5.5 0 1 0-.708.708l3.5 3.5a.5.5 0 0 0 .708 0l7-7zm-4.208 7l-.896-.897.707-.707.543.543 6.646-6.647a.5.5 0 0 1 .708.708l-7 7a.5.5 0 0 1-.708 0z"/>
<path d="M5.354 7.146l.896.897-.707.707-.897-.896a.5.5 0 1 1 .708-.708z"/>
</svg>
</a>
</div>
<div content tourAnchor="tour.upload-widget">
<form>
<ngx-file-drop dropZoneLabel="Drop documents here or" browseBtnLabel="Browse files" (onFileDrop)="dropped($event)"
(onFileOver)="fileOver($event)" (onFileLeave)="fileLeave($event)" dropZoneClassName="bg-light card"
multiple="true" contentClassName="justify-content-center d-flex align-items-center py-5 px-2" [showBrowseBtn]=true
browseBtnClassName="btn btn-sm btn-outline-primary ms-2" i18n-dropZoneLabel i18n-browseBtnLabel>
</ngx-file-drop>
<form class="justify-content-center d-flex flex-column align-items-center py-3 px-2">
<span class="text-muted" i18n>Drop documents anywhere or</span>
<button class="btn btn-sm btn-outline-primary mt-3" (click)="fileUpload.click()" i18n>Browse files</button>
<input type="file" class="visually-hidden" (change)="onFileSelected($event)" multiple #fileUpload>
</form>
<p class="mt-3" *ngIf="getStatus().length > 0">{{getStatusSummary()}}</p>
<div *ngFor="let status of getStatus()">
<ng-container [ngTemplateOutlet]="consumerAlert" [ngTemplateOutletContext]="{ $implicit: status }"></ng-container>
<div class="fixed-bottom p-2 p-md-4" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-md-3 offset-lg-2'">
<div class="row d-flex justify-content-end">
<div class="col col-lg-4 col-xl-3 d-flex px-4 justify-content-between align-items-center">
<p class="m-0 small text-muted" *ngIf="getStatus().length > 0">{{getStatusSummary()}}</p>
<a *ngIf="getStatusCompleted().length > 0" class="btn-link" (click)="dismissCompleted()" [routerLink]="[]" >
<span class="me-1" i18n="This button dismisses all status messages about processed documents on the dashboard (failed and successful)">Dismiss completed</span>
<svg xmlns="http://www.w3.org/2000/svg" width="1rem" height="1rem" fill="currentColor" class="bi bi-check2-all" viewBox="0 0 16 16">
<path d="M12.354 4.354a.5.5 0 0 0-.708-.708L5 10.293 1.854 7.146a.5.5 0 1 0-.708.708l3.5 3.5a.5.5 0 0 0 .708 0l7-7zm-4.208 7l-.896-.897.707-.707.543.543 6.646-6.647a.5.5 0 0 1 .708.708l-7 7a.5.5 0 0 1-.708 0z"/>
<path d="M5.354 7.146l.896.897-.707.707-.897-.896a.5.5 0 1 1 .708-.708z"/>
</svg>
</a>
</div>
</div>
<div *ngFor="let status of getStatus()">
<ng-container [ngTemplateOutlet]="consumerAlert" [ngTemplateOutletContext]="{ $implicit: status }"></ng-container>
</div>
</div>
<div *ngIf="getStatusHidden().length" class="alerts-hidden">
<p *ngIf="!alertsExpanded" class="mt-3 mb-0 text-center">
@@ -36,19 +38,23 @@
</pngx-widget-frame>
<ng-template #consumerAlert let-status>
<ngb-alert type="secondary" class="mt-2 mb-0" [dismissible]="isFinished(status)" (closed)="dismiss(status)">
<h6 class="alert-heading">{{status.filename}}</h6>
<p class="mb-0 pb-1" *ngIf="!isFinished(status) || (isFinished(status) && !status.documentId)">{{status.message}}</p>
<ngb-progressbar [value]="status.getProgress()" [max]="1" [type]="getStatusColor(status)"></ngb-progressbar>
<div *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
<div *ngIf="isFinished(status)">
<button *ngIf="status.documentId" class="btn btn-sm btn-outline-primary btn-open" routerLink="/documents/{{status.documentId}}" (click)="dismiss(status)">
<small i18n>Open document</small>
<svg xmlns="http://www.w3.org/2000/svg" width="1rem" height="1rem" fill="currentColor" class="bi bi-arrow-right-short" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M4 8a.5.5 0 0 1 .5-.5h5.793L8.146 5.354a.5.5 0 1 1 .708-.708l3 3a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708-.708L10.293 8.5H4.5A.5.5 0 0 1 4 8z"/>
</svg>
</button>
</div>
<div class="row d-flex justify-content-end">
<div class="col col-lg-4 col-xl-3">
<ngb-alert type="secondary" class="mt-2 mb-0" [dismissible]="isFinished(status)" (closed)="dismiss(status)">
<h6 class="alert-heading">{{status.filename}}</h6>
<p class="mb-0 pb-1" *ngIf="!isFinished(status) || (isFinished(status) && !status.documentId)">{{status.message}}</p>
<ngb-progressbar [value]="status.getProgress()" [max]="1" [type]="getStatusColor(status)"></ngb-progressbar>
<div *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
<div *ngIf="isFinished(status)">
<button *ngIf="status.documentId" class="btn btn-sm btn-outline-primary btn-open" routerLink="/documents/{{status.documentId}}" (click)="dismiss(status)">
<small i18n>Open document</small>
<svg xmlns="http://www.w3.org/2000/svg" width="1rem" height="1rem" fill="currentColor" class="bi bi-arrow-right-short" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M4 8a.5.5 0 0 1 .5-.5h5.793L8.146 5.354a.5.5 0 1 1 .708-.708l3 3a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708-.708L10.293 8.5H4.5A.5.5 0 0 1 4 8z"/>
</svg>
</button>
</div>
</div>
</ngb-alert>
</div>
</ngb-alert>
</div>
</ng-template>

View File

@@ -1,5 +1,10 @@
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import { By } from '@angular/platform-browser'
import { RouterTestingModule } from '@angular/router/testing'
import {
@@ -8,7 +13,6 @@ import {
NgbAlert,
NgbCollapse,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxFileDropModule } from 'ngx-file-drop'
import { routes } from 'src/app/app-routing.module'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
@@ -21,6 +25,7 @@ import { PermissionsService } from 'src/app/services/permissions.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
import { UploadFileWidgetComponent } from './upload-file-widget.component'
import { DndModule } from 'ngx-drag-drop'
describe('UploadFileWidgetComponent', () => {
let component: UploadFileWidgetComponent
@@ -48,8 +53,8 @@ describe('UploadFileWidgetComponent', () => {
HttpClientTestingModule,
NgbModule,
RouterTestingModule.withRoutes(routes),
NgxFileDropModule,
NgbAlertModule,
DndModule,
],
}).compileComponents()
@@ -61,13 +66,21 @@ describe('UploadFileWidgetComponent', () => {
fixture.detectChanges()
})
it('should support drop files', () => {
it('should support browse files', () => {
const fileInput = fixture.debugElement.query(By.css('input'))
const clickSpy = jest.spyOn(fileInput.nativeElement, 'click')
fixture.debugElement
.query(By.css('button'))
.nativeElement.dispatchEvent(new Event('click'))
expect(clickSpy).toHaveBeenCalled()
})
it('should upload files', () => {
const uploadSpy = jest.spyOn(uploadDocumentsService, 'uploadFiles')
component.dropped([])
fixture.debugElement
.query(By.css('input'))
.nativeElement.dispatchEvent(new Event('change'))
expect(uploadSpy).toHaveBeenCalled()
// coverage
component.fileLeave(null)
component.fileOver(null)
})
it('should generate stats summary', () => {
@@ -114,11 +127,15 @@ describe('UploadFileWidgetComponent', () => {
expect(dismissSpy).toHaveBeenCalled()
})
it('should allow dismissing all alerts', () => {
const dismissSpy = jest.spyOn(consumerStatusService, 'dismissCompleted')
it('should allow dismissing all alerts', fakeAsync(() => {
mockConsumerStatuses(consumerStatusService)
fixture.detectChanges()
const dismissSpy = jest.spyOn(consumerStatusService, 'dismiss')
component.dismissCompleted()
expect(dismissSpy).toHaveBeenCalled()
})
tick(1000)
fixture.detectChanges()
expect(dismissSpy).toHaveBeenCalledTimes(6)
}))
})
function mockConsumerStatuses(consumerStatusService) {

View File

@@ -1,11 +1,13 @@
import { Component } from '@angular/core'
import { NgxFileDropEntry } from 'ngx-file-drop'
import { Component, QueryList, ViewChildren } from '@angular/core'
import { NgbAlert } from '@ng-bootstrap/ng-bootstrap'
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
import {
ConsumerStatusService,
FileStatus,
FileStatusPhase,
} from 'src/app/services/consumer-status.service'
import { SettingsService } from 'src/app/services/settings.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
const MAX_ALERTS = 5
@@ -18,9 +20,12 @@ const MAX_ALERTS = 5
export class UploadFileWidgetComponent extends ComponentWithPermissions {
alertsExpanded = false
@ViewChildren(NgbAlert) alerts: QueryList<NgbAlert>
constructor(
private consumerStatusService: ConsumerStatusService,
private uploadDocumentsService: UploadDocumentsService
private uploadDocumentsService: UploadDocumentsService,
public settingsService: SettingsService
) {
super()
}
@@ -69,6 +74,10 @@ export class UploadFileWidgetComponent extends ComponentWithPermissions {
return this.consumerStatusService.getConsumerStatus(FileStatusPhase.SUCCESS)
}
getStatusCompleted() {
return this.consumerStatusService.getConsumerStatusCompleted()
}
getTotalUploadProgress() {
let current = 0
let max = 0
@@ -106,14 +115,16 @@ export class UploadFileWidgetComponent extends ComponentWithPermissions {
}
dismissCompleted() {
this.consumerStatusService.dismissCompleted()
this.alerts.forEach((a) => a.close())
}
public fileOver(event) {}
public onFileSelected(event: Event) {
this.uploadDocumentsService.uploadFiles(
(event.target as HTMLInputElement).files
)
}
public fileLeave(event) {}
public dropped(files: NgxFileDropEntry[]) {
this.uploadDocumentsService.uploadFiles(files)
get slimSidebarEnabled(): boolean {
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
}
}

View File

@@ -1,7 +1,21 @@
<div class="card mb-3 shadow-sm bg-light">
<div class="card shadow-sm bg-light"
[dndDraggable]="draggable"
dndEffectAllowed="move"
[dndDisableIf]="!draggable"
(dndStart)="dndStart.emit($event)"
(dndMoved)="dndMoved.emit($event)"
(dndCanceled)="dndCanceled.emit($event)"
(dndEnd)="dndEnd.emit($event)">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">{{title}}</h5>
<div class="d-flex">
<div *ngIf="draggable" class="ms-n2 me-1" dndHandle>
<svg class="sidebaricon text-muted" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#grip-vertical"/>
</svg>
</div>
<h6 class="card-title mb-0">{{title}}</h6>
</div>
<ng-container *ngIf="loading">
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div>

View File

@@ -4,6 +4,7 @@ import { By } from '@angular/platform-browser'
import { NgbAlertModule, NgbAlert } from '@ng-bootstrap/ng-bootstrap'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { WidgetFrameComponent } from './widget-frame.component'
import { DndModule } from 'ngx-drag-drop'
@Component({
template: `
@@ -29,7 +30,7 @@ describe('WidgetFrameComponent', () => {
TestBed.configureTestingModule({
declarations: [WidgetFrameComponent, WidgetFrameComponent],
providers: [PermissionsGuard],
imports: [NgbAlertModule],
imports: [NgbAlertModule, DndModule],
}).compileComponents()
fixture = TestBed.createComponent(WidgetFrameComponent)

View File

@@ -1,4 +1,4 @@
import { Component, Input } from '@angular/core'
import { Component, EventEmitter, Input, Output } from '@angular/core'
@Component({
selector: 'pngx-widget-frame',
@@ -13,4 +13,19 @@ export class WidgetFrameComponent {
@Input()
loading: boolean = false
@Input()
draggable: any
@Output()
dndStart: EventEmitter<DragEvent> = new EventEmitter()
@Output()
dndMoved: EventEmitter<DragEvent> = new EventEmitter()
@Output()
dndCanceled: EventEmitter<DragEvent> = new EventEmitter()
@Output()
dndEnd: EventEmitter<DragEvent> = new EventEmitter()
}