]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Enhancement: display saved view counts (#10246)
authorshamoon <4887959+shamoon@users.noreply.github.com>
Thu, 24 Jul 2025 05:07:13 +0000 (22:07 -0700)
committerGitHub <noreply@github.com>
Thu, 24 Jul 2025 05:07:13 +0000 (22:07 -0700)
15 files changed:
src-ui/src/app/components/admin/settings/settings.component.html
src-ui/src/app/components/admin/settings/settings.component.spec.ts
src-ui/src/app/components/admin/settings/settings.component.ts
src-ui/src/app/components/app-frame/app-frame.component.html
src-ui/src/app/components/app-frame/app-frame.component.spec.ts
src-ui/src/app/components/app-frame/app-frame.component.ts
src-ui/src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.html
src-ui/src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.ts
src-ui/src/app/components/dashboard/widgets/widget-frame/widget-frame.component.html
src-ui/src/app/components/dashboard/widgets/widget-frame/widget-frame.component.ts
src-ui/src/app/components/document-detail/document-detail.component.ts
src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts
src-ui/src/app/data/ui-settings.ts
src-ui/src/app/services/rest/saved-view.service.spec.ts
src-ui/src/app/services/rest/saved-view.service.ts

index 9d235a0f3819493687480390904ebd273b8f3aad..ccd3cc7e3843b3995b9ba96ff39593b7fcecce3f 100644 (file)
             <div class="row">
               <div class="col">
                 <pngx-input-check i18n-title title="Show warning when closing saved views with unsaved changes" formControlName="savedViewsWarnOnUnsavedChange"></pngx-input-check>
+                <pngx-input-check i18n-title title="Show document counts in sidebar saved views" formControlName="sidebarViewsShowCount"></pngx-input-check>
               </div>
             </div>
 
index c6eeaf896ccef8048219c6efedd8af4afc23c263..37908d139dad00edd7fe6751194b5c2e12e685ab 100644 (file)
@@ -31,6 +31,7 @@ import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
 import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
 import { PermissionsService } from 'src/app/services/permissions.service'
 import { GroupService } from 'src/app/services/rest/group.service'
+import { SavedViewService } from 'src/app/services/rest/saved-view.service'
 import { UserService } from 'src/app/services/rest/user.service'
 import { SettingsService } from 'src/app/services/settings.service'
 import { SystemStatusService } from 'src/app/services/system-status.service'
@@ -72,6 +73,7 @@ describe('SettingsComponent', () => {
   let groupService: GroupService
   let modalService: NgbModal
   let systemStatusService: SystemStatusService
+  let savedViewsService: SavedViewService
 
   beforeEach(async () => {
     TestBed.configureTestingModule({
@@ -122,6 +124,7 @@ describe('SettingsComponent', () => {
     permissionsService = TestBed.inject(PermissionsService)
     modalService = TestBed.inject(NgbModal)
     systemStatusService = TestBed.inject(SystemStatusService)
+    savedViewsService = TestBed.inject(SavedViewService)
     jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
     jest
       .spyOn(permissionsService, 'currentUserHasObjectPermissions')
@@ -212,7 +215,7 @@ describe('SettingsComponent', () => {
     expect(toastErrorSpy).toHaveBeenCalled()
     expect(storeSpy).toHaveBeenCalled()
     expect(appearanceSettingsSpy).not.toHaveBeenCalled()
-    expect(setSpy).toHaveBeenCalledTimes(29)
+    expect(setSpy).toHaveBeenCalledTimes(30)
 
     // succeed
     storeSpy.mockReturnValueOnce(of(true))
@@ -345,4 +348,14 @@ describe('SettingsComponent', () => {
     component.reset()
     expect(component.settingsForm.get('themeColor').value).toEqual('')
   })
+
+  it('should trigger maybeRefreshDocumentCounts on settings save', () => {
+    completeSetup()
+    const maybeRefreshSpy = jest.spyOn(
+      savedViewsService,
+      'maybeRefreshDocumentCounts'
+    )
+    settingsService.settingsSaved.emit(true)
+    expect(maybeRefreshSpy).toHaveBeenCalled()
+  })
 })
index 7cfe926ad96347526669013548b04e3400214c4e..26c0e1b88ee6bdec81f38d437ad8694f5f7c8694 100644 (file)
@@ -49,6 +49,7 @@ import {
   PermissionsService,
 } from 'src/app/services/permissions.service'
 import { GroupService } from 'src/app/services/rest/group.service'
+import { SavedViewService } from 'src/app/services/rest/saved-view.service'
 import { UserService } from 'src/app/services/rest/user.service'
 import {
   LanguageOption,
@@ -117,6 +118,7 @@ export class SettingsComponent
   permissionsService = inject(PermissionsService)
   private modalService = inject(NgbModal)
   private systemStatusService = inject(SystemStatusService)
+  private savedViewsService = inject(SavedViewService)
 
   activeNavID: number
 
@@ -152,6 +154,7 @@ export class SettingsComponent
     notificationsConsumerSuppressOnDashboard: new FormControl(null),
 
     savedViewsWarnOnUnsavedChange: new FormControl(null),
+    sidebarViewsShowCount: new FormControl(null),
   })
 
   SettingsNavIDs = SettingsNavIDs
@@ -197,6 +200,7 @@ export class SettingsComponent
     super()
     this.settings.settingsSaved.subscribe(() => {
       if (!this.savePending) this.initialize()
+      this.savedViewsService.maybeRefreshDocumentCounts()
     })
   }
 
@@ -308,6 +312,9 @@ export class SettingsComponent
       savedViewsWarnOnUnsavedChange: this.settings.get(
         SETTINGS_KEYS.SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE
       ),
+      sidebarViewsShowCount: this.settings.get(
+        SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT
+      ),
       defaultPermsOwner: this.settings.get(SETTINGS_KEYS.DEFAULT_PERMS_OWNER),
       defaultPermsViewUsers: this.settings.get(
         SETTINGS_KEYS.DEFAULT_PERMS_VIEW_USERS
@@ -485,6 +492,10 @@ export class SettingsComponent
       SETTINGS_KEYS.SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE,
       this.settingsForm.value.savedViewsWarnOnUnsavedChange
     )
+    this.settings.set(
+      SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT,
+      this.settingsForm.value.sidebarViewsShowCount
+    )
     this.settings.set(
       SETTINGS_KEYS.DEFAULT_PERMS_OWNER,
       this.settingsForm.value.defaultPermsOwner
index ff80288aa64c2695c2c72cb4f0eb129de62a542b..abf47d459b677e319b073f7603a536436a3f38a2 100644 (file)
                     routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name"
                     [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
                     popoverClass="popover-slim">
-                    <i-bs class="me-1" name="funnel"></i-bs><span>&nbsp;{{view.name}}</span>
+                    <i-bs class="me-1" name="funnel"></i-bs><span>&nbsp;{{view.name}}
+                      @if (showSidebarCounts && !slimSidebarEnabled) {
+                        <span><span class="badge bg-info text-dark ms-2 d-inline">{{ savedViewService.getDocumentCount(view) }}</span></span>
+                      }
+                    </span>
+                    @if (showSidebarCounts && slimSidebarEnabled) {
+                      <span class="badge bg-info text-dark position-absolute top-0 end-0 d-none d-md-block">{{ savedViewService.getDocumentCount(view) }}</span>
+                    }
                   </a>
                   @if (settingsService.organizingSidebarSavedViews) {
                     <div class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle>
index f1d54ba70e312e16b889aed92a56b71fee1d663f..0c1de78919e9bcc5dbc7537741f1286dcb3dfc45 100644 (file)
@@ -92,6 +92,7 @@ describe('AppFrameComponent', () => {
   let router: Router
   let savedViewSpy
   let modalService: NgbModal
+  let maybeRefreshSpy
 
   beforeEach(async () => {
     TestBed.configureTestingModule({
@@ -113,7 +114,11 @@ describe('AppFrameComponent', () => {
         {
           provide: SavedViewService,
           useValue: {
-            reload: () => {},
+            reload: (fn: any) => {
+              if (fn) {
+                fn()
+              }
+            },
             listAll: () =>
               of({
                 all: [saved_views.map((v) => v.id)],
@@ -121,6 +126,8 @@ describe('AppFrameComponent', () => {
                 results: saved_views,
               }),
             sidebarViews: saved_views.filter((v) => v.show_in_sidebar),
+            getDocumentCount: (view: SavedView) => 5,
+            maybeRefreshDocumentCounts: () => {},
           },
         },
         PermissionsService,
@@ -169,6 +176,7 @@ describe('AppFrameComponent', () => {
     jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
 
     savedViewSpy = jest.spyOn(savedViewService, 'reload')
+    maybeRefreshSpy = jest.spyOn(savedViewService, 'maybeRefreshDocumentCounts')
 
     fixture = TestBed.createComponent(AppFrameComponent)
     component = fixture.componentInstance
@@ -359,4 +367,8 @@ describe('AppFrameComponent', () => {
     expect(toastErrorSpy).toHaveBeenCalledTimes(2)
     expect(toastInfoSpy).toHaveBeenCalledTimes(3)
   })
+
+  it('should call maybeRefreshDocumentCounts after saved views reload', () => {
+    expect(maybeRefreshSpy).toHaveBeenCalled()
+  })
 })
index df3732969e4fdaedf529ddadfd0ab2f2c9af6faa..35b5b5bdc7ac48d398e17369b01b9357ccd0b0aa 100644 (file)
@@ -102,7 +102,9 @@ export class AppFrameComponent
         PermissionType.SavedView
       )
     ) {
-      this.savedViewService.reload()
+      this.savedViewService.reload(() => {
+        this.savedViewService.maybeRefreshDocumentCounts()
+      })
     }
   }
 
@@ -283,4 +285,8 @@ export class AppFrameComponent
   onLogout() {
     this.openDocumentsService.closeAll()
   }
+
+  get showSidebarCounts(): boolean {
+    return this.settingsService.get(SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT)
+  }
 }
index 53fa86dd36ecb8f574070dce275af32b6d7f95c7..ef82a96a33ea9a089b601fcb8be7134a1f385a86 100644 (file)
@@ -1,6 +1,7 @@
 <pngx-widget-frame
   *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"
   [title]="savedView.name"
+  [badge]="count"
   [loading]="loading"
   [draggable]="savedView"
   >
index 3a808bf9ae9d8ee578f993c995bd242c1ae36467..f24e988f4fdbd40d77991428546e8f724693814d 100644 (file)
@@ -118,6 +118,8 @@ export class SavedViewWidgetComponent
 
   displayFields: DisplayField[] = DEFAULT_DASHBOARD_DISPLAY_FIELDS
 
+  count: number
+
   ngOnInit(): void {
     this.reload()
     this.displayMode = this.savedView.display_mode ?? DisplayMode.TABLE
@@ -178,6 +180,7 @@ export class SavedViewWidgetComponent
         tap((result) => {
           this.show = true
           this.documents = result.results
+          this.count = result.count
         }),
         delay(500)
       )
index 101a489b9f0b5a5caab4aef91e34814f4d5aae81..45dcdf7d850d6bfa9e867ffc89f1c790b19d9130 100644 (file)
@@ -2,13 +2,16 @@
   <div class="card shadow-sm bg-light fade" [class.show]="show" cdkDrag [cdkDragDisabled]="!draggable" cdkDragPreviewContainer="parent">
     <div class="card-header">
       <div class="d-flex justify-content-between align-items-center">
-        <div class="d-flex">
+        <div class="d-flex align-items-center">
           @if (draggable) {
             <div class="ms-n2 me-1" cdkDragHandle>
               <i-bs name="grip-vertical"></i-bs>
             </div>
           }
           <h6 class="card-title mb-0">{{title}}</h6>
+          @if (badge) {
+            <span class="badge bg-info text-dark ms-2">{{badge}}</span>
+          }
         </div>
         @if (loading) {
           <div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
index 728787e9e6076cf1be6258713960d501b327bab7..a638cb52cb22b2901889af5c162e9a8a72fd54e4 100644 (file)
@@ -30,6 +30,9 @@ export class WidgetFrameComponent
   @Input()
   cardless: boolean = false
 
+  @Input()
+  badge: string
+
   ngAfterViewInit(): void {
     setTimeout(() => {
       this.show = true
index e8a05962c245e9dfa273649e8af52923bf41e044..3f51712ed53fefe472682fda43f2b50caf585b2c 100644 (file)
@@ -73,6 +73,7 @@ import { CorrespondentService } from 'src/app/services/rest/correspondent.servic
 import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
 import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
 import { DocumentService } from 'src/app/services/rest/document.service'
+import { SavedViewService } from 'src/app/services/rest/saved-view.service'
 import { StoragePathService } from 'src/app/services/rest/storage-path.service'
 import { UserService } from 'src/app/services/rest/user.service'
 import { SettingsService } from 'src/app/services/settings.service'
@@ -195,6 +196,7 @@ export class DocumentDetailComponent
   private hotKeyService = inject(HotKeyService)
   private componentRouterService = inject(ComponentRouterService)
   private deviceDetectorService = inject(DeviceDetectorService)
+  private savedViewService = inject(SavedViewService)
 
   @ViewChild('inputTitle')
   titleInput: TextComponent
@@ -841,6 +843,7 @@ export class DocumentDetailComponent
           } else {
             this.openDocumentService.refreshDocument(this.documentId)
           }
+          this.savedViewService.maybeRefreshDocumentCounts()
         },
         error: (error) => {
           this.networkActive = false
@@ -1188,6 +1191,7 @@ export class DocumentDetailComponent
   notesUpdated(notes: DocumentNote[]) {
     this.document.notes = notes
     this.openDocumentService.refreshDocument(this.documentId)
+    this.savedViewService.maybeRefreshDocumentCounts()
   }
 
   get userIsOwner(): boolean {
index 5d31eb1aae131486bcd27b9e954dea09f8361139..4e73801446c779f36a5766851b6d8d9e0f978886 100644 (file)
@@ -32,6 +32,7 @@ import {
   DocumentService,
   SelectionDataItem,
 } from 'src/app/services/rest/document.service'
+import { SavedViewService } from 'src/app/services/rest/saved-view.service'
 import { StoragePathService } from 'src/app/services/rest/storage-path.service'
 import { TagService } from 'src/app/services/rest/tag.service'
 import { SettingsService } from 'src/app/services/settings.service'
@@ -83,6 +84,7 @@ export class BulkEditorComponent
   private storagePathService = inject(StoragePathService)
   private customFieldService = inject(CustomFieldsService)
   private permissionService = inject(PermissionsService)
+  private savedViewService = inject(SavedViewService)
 
   tagSelectionModel = new FilterableDropdownSelectionModel(true)
   correspondentSelectionModel = new FilterableDropdownSelectionModel()
@@ -270,6 +272,7 @@ export class BulkEditorComponent
           this.list.selected.forEach((id) => {
             this.openDocumentService.refreshDocument(id)
           })
+          this.savedViewService.maybeRefreshDocumentCounts()
           if (modal) {
             modal.close()
           }
index e3cdeabae967739aa49ff0e375b138f80b6d0103..6ace7481067ae1da3f09f6f2d7b9fc83e2e49cd6 100644 (file)
@@ -58,6 +58,8 @@ export const SETTINGS_KEYS = {
     'general-settings:saved-views:dashboard-views-sort-order',
   SIDEBAR_VIEWS_SORT_ORDER:
     'general-settings:saved-views:sidebar-views-sort-order',
+  SIDEBAR_VIEWS_SHOW_COUNT:
+    'general-settings:saved-views:sidebar-views-show-count',
   TOUR_COMPLETE: 'general-settings:tour-complete',
   DEFAULT_PERMS_OWNER: 'general-settings:permissions:default-owner',
   DEFAULT_PERMS_VIEW_USERS: 'general-settings:permissions:default-view-users',
@@ -227,6 +229,11 @@ export const SETTINGS: UiSetting[] = [
     type: 'array',
     default: [],
   },
+  {
+    key: SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT,
+    type: 'boolean',
+    default: true,
+  },
   {
     key: SETTINGS_KEYS.APP_LOGO,
     type: 'string',
index cc206de08c2ee672799325ef781ab9a6804912c6..585425ecce7822e93dde2fd28233c4d0c388b488 100644 (file)
@@ -17,7 +17,7 @@ const saved_views = [
     id: 1,
     show_on_dashboard: true,
     show_in_sidebar: true,
-    sort_field: 'name',
+    sort_field: 'title',
     sort_reverse: true,
     filter_rules: [],
   },
@@ -26,7 +26,7 @@ const saved_views = [
     id: 2,
     show_on_dashboard: true,
     show_in_sidebar: true,
-    sort_field: 'name',
+    sort_field: 'created',
     sort_reverse: true,
     filter_rules: [],
   },
@@ -35,7 +35,7 @@ const saved_views = [
     id: 3,
     show_on_dashboard: true,
     show_in_sidebar: true,
-    sort_field: 'name',
+    sort_field: 'added',
     sort_reverse: true,
     filter_rules: [],
   },
@@ -44,7 +44,7 @@ const saved_views = [
     id: 4,
     show_on_dashboard: false,
     show_in_sidebar: false,
-    sort_field: 'name',
+    sort_field: 'owner',
     sort_reverse: true,
     filter_rules: [],
   },
@@ -222,6 +222,43 @@ describe(`Additional service tests for SavedViewService`, () => {
       })
   })
 
+  it('should accept a callback for reload', () => {
+    const reloadSpy = jest.fn()
+    service.reload(reloadSpy)
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000`
+    )
+    req.flush({
+      results: saved_views,
+    })
+    expect(reloadSpy).toHaveBeenCalled()
+  })
+
+  it('should support getting document counts for views', () => {
+    service.maybeRefreshDocumentCounts(saved_views)
+    saved_views.forEach((saved_view) => {
+      const req = httpTestingController.expectOne(
+        `${environment.apiBaseUrl}documents/?page=1&page_size=1&ordering=-${saved_view.sort_field}&fields=id&truncate_content=true`
+      )
+      req.flush({
+        all: [],
+        count: 1,
+        results: [{ id: 1 }],
+      })
+    })
+    expect(service.getDocumentCount(saved_views[0])).toEqual(1)
+  })
+
+  it('should not refresh document counts if setting is disabled', () => {
+    jest.spyOn(settingsService, 'get').mockImplementation((key) => {
+      if (key === SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT) return false
+    })
+    service.maybeRefreshDocumentCounts(saved_views)
+    httpTestingController.expectNone(
+      `${environment.apiBaseUrl}documents/?page=1&page_size=1&ordering=-${saved_views[0].sort_field}&fields=id&truncate_content=true`
+    )
+  })
+
   beforeEach(() => {
     // Dont need to setup again
 
index 11ebb6398b5b746ea6905e7659decf33f7bcc5dd..a8f4202551921d50c9c32266acede4d53128a0fe 100644 (file)
@@ -1,12 +1,13 @@
 import { HttpClient } from '@angular/common/http'
 import { inject, Injectable } from '@angular/core'
-import { combineLatest, Observable } from 'rxjs'
-import { tap } from 'rxjs/operators'
+import { combineLatest, Observable, Subject } from 'rxjs'
+import { takeUntil, tap } from 'rxjs/operators'
 import { Results } from 'src/app/data/results'
 import { SavedView } from 'src/app/data/saved-view'
 import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
 import { SettingsService } from '../settings.service'
 import { AbstractPaperlessService } from './abstract-paperless-service'
+import { DocumentService } from './document.service'
 
 @Injectable({
   providedIn: 'root',
@@ -14,9 +15,12 @@ import { AbstractPaperlessService } from './abstract-paperless-service'
 export class SavedViewService extends AbstractPaperlessService<SavedView> {
   protected http: HttpClient
   private settingsService = inject(SettingsService)
+  private documentService = inject(DocumentService)
 
   public loading: boolean = true
   private savedViews: SavedView[] = []
+  private savedViewDocumentCounts: Map<number, number> = new Map()
+  private unsubscribeNotifier: Subject<void> = new Subject<void>()
 
   constructor() {
     super()
@@ -46,8 +50,16 @@ export class SavedViewService extends AbstractPaperlessService<SavedView> {
     )
   }
 
-  public reload() {
-    this.listAll().subscribe()
+  public reload(callback: any = null) {
+    this.listAll()
+      .pipe(
+        tap((r) => {
+          if (callback) {
+            callback(r)
+          }
+        })
+      )
+      .subscribe()
   }
 
   get allViews() {
@@ -110,4 +122,30 @@ export class SavedViewService extends AbstractPaperlessService<SavedView> {
   delete(o: SavedView) {
     return super.delete(o).pipe(tap(() => this.reload()))
   }
+
+  public maybeRefreshDocumentCounts(views: SavedView[] = this.sidebarViews) {
+    if (!this.settingsService.get(SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT)) {
+      return
+    }
+    this.unsubscribeNotifier.next() // clear previous subscriptions
+    views.forEach((view) => {
+      this.documentService
+        .listFiltered(
+          1,
+          1,
+          view.sort_field,
+          view.sort_reverse,
+          view.filter_rules,
+          { fields: 'id', truncate_content: true }
+        )
+        .pipe(takeUntil(this.unsubscribeNotifier))
+        .subscribe((results: Results<Document>) => {
+          this.savedViewDocumentCounts.set(view.id, results.count)
+        })
+    })
+  }
+
+  public getDocumentCount(view: SavedView): number {
+    return this.savedViewDocumentCounts.get(view.id)
+  }
 }