mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-20 06:48:16 +00:00
Enhancement: add upload count to button, link to tray, reduce notifications (#14501)
This commit is contained in:
1 parent
f8e93a535a
commit
3374debd6a
10 files changed
+125
-18
No files matched your search
@@ -31,11 +31,18 @@
|
||||
[class.mobile-hidden]="mobileSearchHidden()">
|
||||
<div class="col-12 header-search mx-auto d-flex align-items-stretch gap-3">
|
||||
<pngx-global-search class="flex-grow-1"></pngx-global-search>
|
||||
<button *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Document }"
|
||||
class="btn btn-sm header-upload border-0 d-flex align-items-center justify-content-center flex-shrink-0 py-0 px-2" type="button"
|
||||
(click)="fileUpload.click()" aria-label="Upload documents" i18n-aria-label>
|
||||
<i-bs class="d-flex" width="1.1em" height="1.1em" name="file-earmark-arrow-up"></i-bs><span class="ms-2 d-sm-none d-md-inline" i18n>Upload</span>
|
||||
</button>
|
||||
<div *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Document }" class="btn-group header-upload-group flex-shrink-0">
|
||||
<button class="btn btn-sm header-upload border-0 d-flex align-items-center justify-content-center py-0 px-2" type="button"
|
||||
(click)="fileUpload.click()" aria-label="Upload documents" i18n-aria-label>
|
||||
<i-bs class="d-flex" width="1.1em" height="1.1em" name="file-earmark-arrow-up"></i-bs><span class="ms-2 d-sm-none d-md-inline" i18n>Upload</span>
|
||||
</button>
|
||||
@if (uploadsInProgress > 0) {
|
||||
<button class="btn btn-sm header-upload border-0 d-flex align-items-center py-0 px-2" type="button"
|
||||
(click)="showUploadStatus()" aria-label="Show document processing status" i18n-aria-label>
|
||||
<span class="spinner-border spinner-border-sm" aria-hidden="true"></span><span class="ms-2 small">{{ uploadsInProgress }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul ngbNav class="order-sm-3">
|
||||
|
||||
@@ -552,9 +552,22 @@ main {
|
||||
--bs-btn-active-bg: rgba(255, 255, 255, .3);
|
||||
color: var(--pngx-primary-text-contrast) !important; // .navbar .dropdown > .btn in styles.scss
|
||||
|
||||
&:not(:focus) {
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, .35), 0 1px 1px rgba(0, 0, 0, .2); // leave room for the .btn:focus ring
|
||||
& + & {
|
||||
border-left: 1px solid rgba(255, 255, 255, .2) !important;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, .5);
|
||||
}
|
||||
}
|
||||
|
||||
.header-upload-group {
|
||||
border-radius: var(--bs-border-radius-sm);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, .35), 0 1px 1px rgba(0, 0, 0, .2);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 575.98px) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from '@angular/common/http/testing'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { BrowserModule } from '@angular/platform-browser'
|
||||
import { BrowserModule, By } from '@angular/platform-browser'
|
||||
import { ActivatedRoute, Router } from '@angular/router'
|
||||
import { RouterTestingModule } from '@angular/router/testing'
|
||||
import { NgbModal, NgbModalModule, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
@@ -34,8 +34,10 @@ import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { TasksService } from 'src/app/services/tasks.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
|
||||
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
||||
import { UploadStatusTrayComponent } from '../common/upload-status-tray/upload-status-tray.component'
|
||||
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
||||
import { AppFrameComponent } from './app-frame.component'
|
||||
import { GlobalSearchComponent } from './global-search/global-search.component'
|
||||
@@ -474,10 +476,26 @@ describe('AppFrameComponent', () => {
|
||||
jest.spyOn(input, 'files', 'get').mockReturnValue([file] as any)
|
||||
|
||||
component.onFileSelected({ target: input } as unknown as Event)
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
expect(toastSpy).not.toHaveBeenCalled()
|
||||
expect(uploadSpy).toHaveBeenCalledWith([file])
|
||||
})
|
||||
|
||||
it('should show in-progress uploads and expand the status tray', () => {
|
||||
const websocketStatusService = TestBed.inject(WebsocketStatusService)
|
||||
expect(component.uploadsInProgress).toEqual(0)
|
||||
websocketStatusService.newFileUpload('file.pdf')
|
||||
expect(component.uploadsInProgress).toEqual(1)
|
||||
|
||||
fixture.detectChanges()
|
||||
const tray = fixture.debugElement.query(
|
||||
By.directive(UploadStatusTrayComponent)
|
||||
).componentInstance as UploadStatusTrayComponent
|
||||
tray.toggleCollapsed()
|
||||
expect(tray.collapsed()).toBe(true)
|
||||
component.showUploadStatus()
|
||||
expect(tray.collapsed()).toBe(false)
|
||||
})
|
||||
|
||||
it('should not upload when no files are selected from the navbar', () => {
|
||||
const uploadDocumentsService = TestBed.inject(UploadDocumentsService)
|
||||
const uploadSpy = jest.spyOn(uploadDocumentsService, 'uploadFiles')
|
||||
|
||||
@@ -6,7 +6,14 @@ import {
|
||||
moveItemInArray,
|
||||
} from '@angular/cdk/drag-drop'
|
||||
import { NgClass } from '@angular/common'
|
||||
import { Component, HostListener, inject, OnInit, signal } from '@angular/core'
|
||||
import {
|
||||
Component,
|
||||
HostListener,
|
||||
inject,
|
||||
OnInit,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
|
||||
import {
|
||||
@@ -49,6 +56,7 @@ import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { TasksService } from 'src/app/services/tasks.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
|
||||
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { ChatComponent } from '../chat/chat/chat.component'
|
||||
import { SwitchComponent } from '../common/input/switch/switch.component'
|
||||
@@ -105,6 +113,9 @@ export class AppFrameComponent
|
||||
permissionsService = inject(PermissionsService)
|
||||
private djangoMessagesService = inject(DjangoMessagesService)
|
||||
private uploadDocumentsService = inject(UploadDocumentsService)
|
||||
private websocketStatusService = inject(WebsocketStatusService)
|
||||
|
||||
private readonly uploadStatusTray = viewChild(UploadStatusTrayComponent)
|
||||
|
||||
readonly appRemoteVersion = signal<AppRemoteVersion>(null)
|
||||
readonly isMenuCollapsed = signal(true)
|
||||
@@ -522,10 +533,17 @@ export class AppFrameComponent
|
||||
const files = Array.from(input.files ?? [])
|
||||
input.value = ''
|
||||
if (!files.length) return
|
||||
this.toastService.showInfo($localize`Initiating upload...`, 3000)
|
||||
this.uploadDocumentsService.uploadFiles(files)
|
||||
}
|
||||
|
||||
get uploadsInProgress(): number {
|
||||
return this.websocketStatusService.getConsumerStatusNotCompleted().length
|
||||
}
|
||||
|
||||
showUploadStatus() {
|
||||
this.uploadStatusTray()?.expand()
|
||||
}
|
||||
|
||||
onLogout() {
|
||||
this.openDocumentsService.closeAll()
|
||||
}
|
||||
|
||||
+21
-1
@@ -1,12 +1,12 @@
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { RouterTestingModule } from '@angular/router/testing'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { routes } from 'src/app/app-routing.module'
|
||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
|
||||
import {
|
||||
FileStatus,
|
||||
FileStatusPhase,
|
||||
@@ -105,6 +105,26 @@ describe('UploadStatusTrayComponent', () => {
|
||||
expect(component.collapsed()).toBe(false)
|
||||
})
|
||||
|
||||
it('should expand on request', () => {
|
||||
component.toggleCollapsed()
|
||||
expect(component.collapsed()).toBe(true)
|
||||
component.expand()
|
||||
expect(component.collapsed()).toBe(false)
|
||||
})
|
||||
|
||||
it('should expand when new uploads start', () => {
|
||||
const uploadDocumentsService = TestBed.inject(UploadDocumentsService)
|
||||
jest
|
||||
.spyOn(uploadDocumentsService, 'uploadFile')
|
||||
.mockImplementation(() => {})
|
||||
websocketStatusService.newFileUpload('invoice.pdf')
|
||||
fixture.detectChanges()
|
||||
component.toggleCollapsed()
|
||||
expect(component.collapsed()).toBe(true)
|
||||
uploadDocumentsService.uploadFiles([new File(['one'], 'one.pdf')])
|
||||
expect(component.collapsed()).toBe(false)
|
||||
})
|
||||
|
||||
it('should generate stats summary', () => {
|
||||
mockConsumerStatuses(websocketStatusService)
|
||||
expect(component.getStatusSummary()).toEqual(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NgClass, NgTemplateOutlet } from '@angular/common'
|
||||
import { Component, effect, inject, signal } from '@angular/core'
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import {
|
||||
NgbAlertModule,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
PermissionType,
|
||||
} from 'src/app/services/permissions.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
|
||||
import {
|
||||
FileStatus,
|
||||
FileStatusPhase,
|
||||
@@ -40,6 +42,7 @@ export class UploadStatusTrayComponent extends ComponentWithPermissions {
|
||||
private websocketStatusService = inject(WebsocketStatusService)
|
||||
private settingsService = inject(SettingsService)
|
||||
private permissionsService = inject(PermissionsService)
|
||||
private uploadDocumentsService = inject(UploadDocumentsService)
|
||||
|
||||
readonly collapsed = signal(false)
|
||||
|
||||
@@ -49,6 +52,10 @@ export class UploadStatusTrayComponent extends ComponentWithPermissions {
|
||||
effect(() => {
|
||||
if (this.getStatus().length === 0) this.collapsed.set(false)
|
||||
})
|
||||
this.uploadDocumentsService
|
||||
.onUploadsStarted()
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe(() => this.expand())
|
||||
}
|
||||
|
||||
get canAddDocuments(): boolean {
|
||||
@@ -138,6 +145,10 @@ export class UploadStatusTrayComponent extends ComponentWithPermissions {
|
||||
this.collapsed.update((collapsed) => !collapsed)
|
||||
}
|
||||
|
||||
expand() {
|
||||
this.collapsed.set(false)
|
||||
}
|
||||
|
||||
dismiss(status: FileStatus) {
|
||||
this.websocketStatusService.dismiss(status)
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ describe('FileDropComponent', () => {
|
||||
}
|
||||
component.onDrop(dragEvent as DragEvent)
|
||||
advanceTimers(3000)
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
expect(toastSpy).not.toHaveBeenCalled()
|
||||
expect(uploadSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -159,7 +159,7 @@ describe('FileDropComponent', () => {
|
||||
component.onDrop(dragEvent as DragEvent)
|
||||
await flushPromises()
|
||||
advanceTimers(3000)
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
expect(toastSpy).not.toHaveBeenCalled()
|
||||
expect(uploadSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -227,7 +227,7 @@ describe('FileDropComponent', () => {
|
||||
}
|
||||
component.onDrop(dragEvent as DragEvent)
|
||||
advanceTimers(3000)
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
expect(toastSpy).not.toHaveBeenCalled()
|
||||
expect(uploadSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -134,7 +134,6 @@ export class FileDropComponent {
|
||||
Promise.all(promises)
|
||||
.then((results) => {
|
||||
files.push(...[].concat(...results))
|
||||
this.toastService.showInfo($localize`Initiating upload...`, 3000)
|
||||
this.uploadDocumentsService.uploadFiles(files)
|
||||
})
|
||||
.catch((e) => {
|
||||
@@ -143,7 +142,6 @@ export class FileDropComponent {
|
||||
)
|
||||
})
|
||||
} else if (files.length) {
|
||||
this.toastService.showInfo($localize`Initiating upload...`, 3000)
|
||||
this.uploadDocumentsService.uploadFiles(files)
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,19 @@ describe('UploadDocumentsService', () => {
|
||||
.forEach((req) => req.flush('123-456'))
|
||||
})
|
||||
|
||||
it('emits when uploads start', () => {
|
||||
const startedSpy = jest.fn()
|
||||
uploadDocumentsService.onUploadsStarted().subscribe(startedSpy)
|
||||
uploadDocumentsService.uploadFiles([])
|
||||
expect(startedSpy).not.toHaveBeenCalled()
|
||||
const file = new File(['one'], 'one.pdf')
|
||||
uploadDocumentsService.uploadFiles([file])
|
||||
expect(startedSpy).toHaveBeenCalledWith([file])
|
||||
httpTestingController
|
||||
.match(`${environment.apiBaseUrl}documents/post_document/`)
|
||||
.forEach((req) => req.flush('123-456'))
|
||||
})
|
||||
|
||||
it('updates progress during upload and failure', () => {
|
||||
const file = new File(
|
||||
[new Blob(['testing'], { type: 'application/pdf' })],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { HttpEventType } from '@angular/common/http'
|
||||
import { Injectable, inject } from '@angular/core'
|
||||
import { Subscription } from 'rxjs'
|
||||
import { Subject, Subscription } from 'rxjs'
|
||||
import { DocumentService } from './rest/document.service'
|
||||
import {
|
||||
FileStatusPhase,
|
||||
@@ -16,10 +16,19 @@ export class UploadDocumentsService {
|
||||
|
||||
private uploadSubscriptions: Array<Subscription> = []
|
||||
|
||||
private readonly uploadsStartedSubject = new Subject<File[]>()
|
||||
|
||||
public uploadFiles(files: Iterable<File>) {
|
||||
const started: File[] = []
|
||||
for (const file of files) {
|
||||
this.uploadFile(file)
|
||||
started.push(file)
|
||||
}
|
||||
if (started.length) this.uploadsStartedSubject.next(started)
|
||||
}
|
||||
|
||||
public onUploadsStarted() {
|
||||
return this.uploadsStartedSubject.asObservable()
|
||||
}
|
||||
|
||||
public uploadFile(file: File) {
|
||||
|
||||
Reference in new issue
Block a user