diff --git a/src-ui/src/app/components/app-frame/app-frame.component.html b/src-ui/src/app/components/app-frame/app-frame.component.html
index 91fe83222..84b8dea3f 100644
--- a/src-ui/src/app/components/app-frame/app-frame.component.html
+++ b/src-ui/src/app/components/app-frame/app-frame.component.html
@@ -31,11 +31,18 @@
[class.mobile-hidden]="mobileSearchHidden()">
diff --git a/src-ui/src/app/components/app-frame/app-frame.component.scss b/src-ui/src/app/components/app-frame/app-frame.component.scss
index dc66b50af..933dd9374 100644
--- a/src-ui/src/app/components/app-frame/app-frame.component.scss
+++ b/src-ui/src/app/components/app-frame/app-frame.component.scss
@@ -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) {
diff --git a/src-ui/src/app/components/app-frame/app-frame.component.spec.ts b/src-ui/src/app/components/app-frame/app-frame.component.spec.ts
index 60fc6b18f..971a4ceeb 100644
--- a/src-ui/src/app/components/app-frame/app-frame.component.spec.ts
+++ b/src-ui/src/app/components/app-frame/app-frame.component.spec.ts
@@ -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')
diff --git a/src-ui/src/app/components/app-frame/app-frame.component.ts b/src-ui/src/app/components/app-frame/app-frame.component.ts
index f7fcc37f0..4f9a02f6e 100644
--- a/src-ui/src/app/components/app-frame/app-frame.component.ts
+++ b/src-ui/src/app/components/app-frame/app-frame.component.ts
@@ -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(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()
}
diff --git a/src-ui/src/app/components/common/upload-status-tray/upload-status-tray.component.spec.ts b/src-ui/src/app/components/common/upload-status-tray/upload-status-tray.component.spec.ts
index 3482df1e4..6780b154b 100644
--- a/src-ui/src/app/components/common/upload-status-tray/upload-status-tray.component.spec.ts
+++ b/src-ui/src/app/components/common/upload-status-tray/upload-status-tray.component.spec.ts
@@ -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(
diff --git a/src-ui/src/app/components/common/upload-status-tray/upload-status-tray.component.ts b/src-ui/src/app/components/common/upload-status-tray/upload-status-tray.component.ts
index 95549c7cf..c87cc31c8 100644
--- a/src-ui/src/app/components/common/upload-status-tray/upload-status-tray.component.ts
+++ b/src-ui/src/app/components/common/upload-status-tray/upload-status-tray.component.ts
@@ -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)
}
diff --git a/src-ui/src/app/components/file-drop/file-drop.component.spec.ts b/src-ui/src/app/components/file-drop/file-drop.component.spec.ts
index f6ea9572a..e64057643 100644
--- a/src-ui/src/app/components/file-drop/file-drop.component.spec.ts
+++ b/src-ui/src/app/components/file-drop/file-drop.component.spec.ts
@@ -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()
})
diff --git a/src-ui/src/app/components/file-drop/file-drop.component.ts b/src-ui/src/app/components/file-drop/file-drop.component.ts
index a1f1432d0..fdc035f23 100644
--- a/src-ui/src/app/components/file-drop/file-drop.component.ts
+++ b/src-ui/src/app/components/file-drop/file-drop.component.ts
@@ -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)
}
diff --git a/src-ui/src/app/services/upload-documents.service.spec.ts b/src-ui/src/app/services/upload-documents.service.spec.ts
index fbc6b8db6..3431d202d 100644
--- a/src-ui/src/app/services/upload-documents.service.spec.ts
+++ b/src-ui/src/app/services/upload-documents.service.spec.ts
@@ -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' })],
diff --git a/src-ui/src/app/services/upload-documents.service.ts b/src-ui/src/app/services/upload-documents.service.ts
index 1eb309846..d5c2bf52c 100644
--- a/src-ui/src/app/services/upload-documents.service.ts
+++ b/src-ui/src/app/services/upload-documents.service.ts
@@ -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 = []
+ private readonly uploadsStartedSubject = new Subject()
+
public uploadFiles(files: Iterable) {
+ 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) {