]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Enhancement: support select all for management lists (#11889)
authorshamoon <4887959+shamoon@users.noreply.github.com>
Mon, 26 Jan 2026 17:49:16 +0000 (09:49 -0800)
committerGitHub <noreply@github.com>
Mon, 26 Jan 2026 17:49:16 +0000 (09:49 -0800)
src-ui/src/app/components/manage/correspondent-list/correspondent-list.component.ts
src-ui/src/app/components/manage/document-type-list/document-type-list.component.ts
src-ui/src/app/components/manage/management-list/management-list.component.html
src-ui/src/app/components/manage/management-list/management-list.component.spec.ts
src-ui/src/app/components/manage/management-list/management-list.component.ts
src-ui/src/app/components/manage/storage-path-list/storage-path-list.component.ts
src-ui/src/app/components/manage/tag-list/tag-list.component.spec.ts
src-ui/src/app/components/manage/tag-list/tag-list.component.ts

index 957371e084ad8a7db5748a93a7718fee6640a75b..4a3a82b9c5be6f8bac28ee38f2b34d9f2921141a 100644 (file)
@@ -14,6 +14,7 @@ import { SortableDirective } from 'src/app/directives/sortable.directive'
 import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
 import { PermissionType } from 'src/app/services/permissions.service'
 import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
+import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component'
 import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
 import { PageHeaderComponent } from '../../common/page-header/page-header.component'
 import { ManagementListComponent } from '../management-list/management-list.component'
@@ -36,6 +37,7 @@ import { ManagementListComponent } from '../management-list/management-list.comp
     NgbDropdownModule,
     NgbPaginationModule,
     NgxBootstrapIconsModule,
+    ClearableBadgeComponent,
   ],
 })
 export class CorrespondentListComponent extends ManagementListComponent<Correspondent> {
index b561af2d1c2831812429218d02a062b78cf04758..dc3a020bed518475a052a5b8862a4c3d459facdb 100644 (file)
@@ -13,6 +13,7 @@ import { IfPermissionsDirective } from 'src/app/directives/if-permissions.direct
 import { SortableDirective } from 'src/app/directives/sortable.directive'
 import { PermissionType } from 'src/app/services/permissions.service'
 import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
+import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component'
 import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
 import { PageHeaderComponent } from '../../common/page-header/page-header.component'
 import { ManagementListComponent } from '../management-list/management-list.component'
@@ -34,6 +35,7 @@ import { ManagementListComponent } from '../management-list/management-list.comp
     NgbDropdownModule,
     NgbPaginationModule,
     NgxBootstrapIconsModule,
+    ClearableBadgeComponent,
   ],
 })
 export class DocumentTypeListComponent extends ManagementListComponent<DocumentType> {
index 1cfb3aa0dba1a5afa9dd136b8c6ff8b8dd5cd9b4..697a053c632256e2f033f86f0071c7eff8cdf1b9 100644 (file)
@@ -1,17 +1,48 @@
 <pngx-page-header title="{{ typeNamePlural | titlecase }}" info="View, add, edit and delete {{ typeNamePlural }}." infoLink="usage/#terms-and-definitions">
 
-  <button class="btn btn-sm btn-outline-secondary" (click)="clearSelection()" [hidden]="selectedObjects.size === 0">
-    <i-bs  name="x"></i-bs>&nbsp;<ng-container i18n>Clear selection</ng-container>
-    </button>
-    <button type="button" class="btn btn-sm btn-outline-primary" (click)="setPermissions()" [disabled]="!userCanBulkEdit(PermissionAction.Change) || selectedObjects.size === 0">
-      <i-bs name="person-fill-lock"></i-bs>&nbsp;<ng-container i18n>Permissions</ng-container>
-    </button>
-    <button type="button" class="btn btn-sm btn-outline-danger" (click)="delete()" [disabled]="!userCanBulkEdit(PermissionAction.Delete) || selectedObjects.size === 0">
-      <i-bs name="trash"></i-bs>&nbsp;<ng-container i18n>Delete</ng-container>
-    </button>
-    <button type="button" class="btn btn-sm btn-outline-primary ms-md-5" (click)="openCreateDialog()" *pngxIfPermissions="{ action: PermissionAction.Add, type: permissionType }">
-      <i-bs name="plus-circle"></i-bs>&nbsp;<ng-container i18n>Create</ng-container>
+  <div ngbDropdown class="btn-group flex-fill d-sm-none">
+    <button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle>
+      <i-bs name="text-indent-left"></i-bs>
+      <div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Select</ng-container></div>
+      @if (selectedObjects.size > 0) {
+        <pngx-clearable-badge [selected]="selectedObjects.size > 0" [number]="selectedObjects.size" (cleared)="selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
+      }
     </button>
+    <div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow">
+      <button ngbDropdownItem (click)="selectNone()" i18n>Select none</button>
+      <button ngbDropdownItem (click)="selectPage(true)" i18n>Select page</button>
+      <button ngbDropdownItem (click)="selectAll()" i18n>Select all</button>
+    </div>
+  </div>
+
+  <div class="d-none d-sm-flex flex-fill me-3">
+    <div class="input-group input-group-sm">
+      <span class="input-group-text border-0" i18n>Select:</span>
+    </div>
+    <div class="btn-group btn-group-sm flex-nowrap">
+      @if (selectedObjects.size > 0) {
+        <button class="btn btn-sm btn-outline-secondary" (click)="selectNone()">
+          <i-bs name="slash-circle"></i-bs>&nbsp;<ng-container i18n>None</ng-container>
+        </button>
+      }
+      <button class="btn btn-sm btn-outline-primary" (click)="selectPage(true)">
+        <i-bs name="file-earmark-check"></i-bs>&nbsp;<ng-container i18n>Page</ng-container>
+      </button>
+      <button class="btn btn-sm btn-outline-primary" (click)="selectAll()">
+        <i-bs name="check-all"></i-bs>&nbsp;<ng-container i18n>All</ng-container>
+      </button>
+    </div>
+  </div>
+
+  <button type="button" class="btn btn-sm btn-outline-primary" (click)="setPermissions()" [disabled]="!userCanBulkEdit(PermissionAction.Change) || selectedObjects.size === 0">
+    <i-bs name="person-fill-lock"></i-bs>&nbsp;<ng-container i18n>Permissions</ng-container>
+  </button>
+  <button type="button" class="btn btn-sm btn-outline-danger" (click)="delete()" [disabled]="!userCanBulkEdit(PermissionAction.Delete) || selectedObjects.size === 0">
+    <i-bs name="trash"></i-bs>&nbsp;<ng-container i18n>Delete</ng-container>
+  </button>
+  <button type="button" class="btn btn-sm btn-outline-primary ms-md-5" (click)="openCreateDialog()" *pngxIfPermissions="{ action: PermissionAction.Add, type: permissionType }">
+    <i-bs name="plus-circle"></i-bs>&nbsp;<ng-container i18n>Create</ng-container>
+  </button>
 </pngx-page-header>
 
 <div class="row mb-3">
@@ -31,7 +62,7 @@
       <tr>
         <th scope="col">
           <div class="form-check m-0 ms-2 me-n2">
-            <input type="checkbox" class="form-check-input" id="all-objects" [(ngModel)]="togggleAll" [disabled]="data.length === 0" (click)="toggleAll($event); $event.stopPropagation();">
+            <input type="checkbox" class="form-check-input" id="all-objects" [(ngModel)]="togggleAll" [disabled]="data.length === 0" (change)="selectPage($event.target.checked); $event.stopPropagation();">
             <label class="form-check-label" for="all-objects"></label>
           </div>
         </th>
index c5a742f4de30e0338ec363ea89352b00860713c2..dca1bb2c972543bd33179573aa5c4236680dd2dc 100644 (file)
@@ -163,8 +163,7 @@ describe('ManagementListComponent', () => {
     const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
     const reloadSpy = jest.spyOn(component, 'reloadData')
 
-    const createButton = fixture.debugElement.queryAll(By.css('button'))[4]
-    createButton.triggerEventHandler('click')
+    component.openCreateDialog()
 
     expect(modal).not.toBeUndefined()
     const editDialog = modal.componentInstance as EditDialogComponent<Tag>
@@ -187,8 +186,7 @@ describe('ManagementListComponent', () => {
     const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
     const reloadSpy = jest.spyOn(component, 'reloadData')
 
-    const editButton = fixture.debugElement.queryAll(By.css('button'))[7]
-    editButton.triggerEventHandler('click')
+    component.openEditDialog(tags[0])
 
     expect(modal).not.toBeUndefined()
     const editDialog = modal.componentInstance as EditDialogComponent<Tag>
@@ -212,8 +210,7 @@ describe('ManagementListComponent', () => {
     const deleteSpy = jest.spyOn(tagService, 'delete')
     const reloadSpy = jest.spyOn(component, 'reloadData')
 
-    const deleteButton = fixture.debugElement.queryAll(By.css('button'))[8]
-    deleteButton.triggerEventHandler('click')
+    component.openDeleteDialog(tags[0])
 
     expect(modal).not.toBeUndefined()
     const editDialog = modal.componentInstance as ConfirmDialogComponent
@@ -279,19 +276,84 @@ describe('ManagementListComponent', () => {
     expect(component.page).toEqual(1)
   })
 
-  it('should support toggle all items in view', () => {
+  it('should support toggle select page in vew', () => {
     expect(component.selectedObjects.size).toEqual(0)
-    const toggleAllSpy = jest.spyOn(component, 'toggleAll')
+    const selectPageSpy = jest.spyOn(component, 'selectPage')
     const checkButton = fixture.debugElement.queryAll(
       By.css('input.form-check-input')
     )[0]
-    checkButton.nativeElement.dispatchEvent(new Event('click'))
+    checkButton.nativeElement.dispatchEvent(new Event('change'))
     checkButton.nativeElement.checked = true
-    checkButton.nativeElement.dispatchEvent(new Event('click'))
-    expect(toggleAllSpy).toHaveBeenCalled()
+    checkButton.nativeElement.dispatchEvent(new Event('change'))
+    expect(selectPageSpy).toHaveBeenCalled()
     expect(component.selectedObjects.size).toEqual(tags.length)
   })
 
+  it('selectNone should clear selection and reset toggle flag', () => {
+    component.selectedObjects = new Set([tags[0].id, tags[1].id])
+    component.togggleAll = true
+
+    component.selectNone()
+
+    expect(component.selectedObjects.size).toBe(0)
+    expect(component.togggleAll).toBe(false)
+  })
+
+  it('selectPage should select current page items or clear selection', () => {
+    component.selectPage(true)
+    expect(component.selectedObjects).toEqual(new Set(tags.map((t) => t.id)))
+    expect(component.togggleAll).toBe(true)
+
+    component.togggleAll = true
+    component.selectPage(false)
+    expect(component.selectedObjects.size).toBe(0)
+    expect(component.togggleAll).toBe(false)
+  })
+
+  it('selectAll should use all IDs when collection size exists', () => {
+    ;(component as any).allIDs = [1, 2, 3, 4]
+    component.collectionSize = 4
+
+    component.selectAll()
+
+    expect(component.selectedObjects).toEqual(new Set([1, 2, 3, 4]))
+    expect(component.togggleAll).toBe(true)
+  })
+
+  it('selectAll should clear selection when collection size is zero', () => {
+    component.selectedObjects = new Set([1])
+    component.collectionSize = 0
+    component.togggleAll = true
+
+    component.selectAll()
+
+    expect(component.selectedObjects.size).toBe(0)
+    expect(component.togggleAll).toBe(false)
+  })
+
+  it('toggleSelected should toggle object selection and update toggle state', () => {
+    component.toggleSelected(tags[0])
+    expect(component.selectedObjects.has(tags[0].id)).toBe(true)
+    expect(component.togggleAll).toBe(false)
+
+    component.toggleSelected(tags[1])
+    component.toggleSelected(tags[2])
+    expect(component.togggleAll).toBe(true)
+
+    component.toggleSelected(tags[1])
+    expect(component.selectedObjects.has(tags[1].id)).toBe(false)
+    expect(component.togggleAll).toBe(false)
+  })
+
+  it('areAllPageItemsSelected should return false when page has no selectable items', () => {
+    component.data = []
+    component.selectedObjects.clear()
+
+    expect((component as any).areAllPageItemsSelected()).toBe(false)
+
+    component.data = tags
+  })
+
   it('should support bulk edit permissions', () => {
     const bulkEditPermsSpy = jest.spyOn(tagService, 'bulk_edit_objects')
     component.toggleSelected(tags[0])
index 29d6f3b38e67ad123a2ffe09100fefdd348fa86f..daa6a0ea0e740aa773a797f4d104d941e0bc26f2 100644 (file)
@@ -84,6 +84,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
 
   public data: T[] = []
   private unfilteredData: T[] = []
+  private allIDs: number[] = []
 
   public page = 1
 
@@ -172,6 +173,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
           this.unfilteredData = c.results
           this.data = this.filterData(c.results)
           this.collectionSize = c.all?.length ?? c.count
+          this.allIDs = c.all
         }),
         delay(100)
       )
@@ -300,16 +302,6 @@ export abstract class ManagementListComponent<T extends MatchingModel>
     return ownsAll
   }
 
-  toggleAll(event: PointerEvent) {
-    const checked = (event.target as HTMLInputElement).checked
-    this.togggleAll = checked
-    if (checked) {
-      this.selectedObjects = new Set(this.getSelectableIDs(this.data))
-    } else {
-      this.clearSelection()
-    }
-  }
-
   protected getSelectableIDs(objects: T[]): number[] {
     return objects.map((o) => o.id)
   }
@@ -319,10 +311,38 @@ export abstract class ManagementListComponent<T extends MatchingModel>
     this.selectedObjects.clear()
   }
 
+  selectNone() {
+    this.clearSelection()
+  }
+
+  selectPage(select: boolean) {
+    if (select) {
+      this.selectedObjects = new Set(this.getSelectableIDs(this.data))
+      this.togggleAll = this.areAllPageItemsSelected()
+    } else {
+      this.clearSelection()
+    }
+  }
+
+  selectAll() {
+    if (!this.collectionSize) {
+      this.clearSelection()
+      return
+    }
+    this.selectedObjects = new Set(this.allIDs)
+    this.togggleAll = this.areAllPageItemsSelected()
+  }
+
   toggleSelected(object) {
     this.selectedObjects.has(object.id)
       ? this.selectedObjects.delete(object.id)
       : this.selectedObjects.add(object.id)
+    this.togggleAll = this.areAllPageItemsSelected()
+  }
+
+  protected areAllPageItemsSelected(): boolean {
+    const ids = this.getSelectableIDs(this.data)
+    return ids.length > 0 && ids.every((id) => this.selectedObjects.has(id))
   }
 
   setPermissions() {
index cac8637d7429eb025fc25da23d2d6615029f204b..3ab9405216d5b3754be855c0b2ff8f37406da05e 100644 (file)
@@ -13,6 +13,7 @@ import { IfPermissionsDirective } from 'src/app/directives/if-permissions.direct
 import { SortableDirective } from 'src/app/directives/sortable.directive'
 import { PermissionType } from 'src/app/services/permissions.service'
 import { StoragePathService } from 'src/app/services/rest/storage-path.service'
+import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component'
 import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
 import { PageHeaderComponent } from '../../common/page-header/page-header.component'
 import { ManagementListComponent } from '../management-list/management-list.component'
@@ -34,6 +35,7 @@ import { ManagementListComponent } from '../management-list/management-list.comp
     NgbDropdownModule,
     NgbPaginationModule,
     NgxBootstrapIconsModule,
+    ClearableBadgeComponent,
   ],
 })
 export class StoragePathListComponent extends ManagementListComponent<StoragePath> {
index 9b1923e43237baaccacf1fa074a2f784e06660eb..51403379de8dd621807e7f49295ac753c3ad3aa3 100644 (file)
@@ -138,16 +138,12 @@ describe('TagListComponent', () => {
     }
 
     component.data = [parent as any]
-    const selectEvent = { target: { checked: true } } as unknown as PointerEvent
-    component.toggleAll(selectEvent)
+    component.selectPage(true)
 
     expect(component.selectedObjects.has(10)).toBe(true)
     expect(component.selectedObjects.has(11)).toBe(true)
 
-    const deselectEvent = {
-      target: { checked: false },
-    } as unknown as PointerEvent
-    component.toggleAll(deselectEvent)
+    component.selectPage(false)
     expect(component.selectedObjects.size).toBe(0)
   })
 })
index 544e99b586c3209743e1f5c8cbdd34fa1ac1c9b1..87045a50a47478d6c94c2a08a9d4e3672fce47b5 100644 (file)
@@ -13,6 +13,7 @@ import { IfPermissionsDirective } from 'src/app/directives/if-permissions.direct
 import { SortableDirective } from 'src/app/directives/sortable.directive'
 import { PermissionType } from 'src/app/services/permissions.service'
 import { TagService } from 'src/app/services/rest/tag.service'
+import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component'
 import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
 import { PageHeaderComponent } from '../../common/page-header/page-header.component'
 import { ManagementListComponent } from '../management-list/management-list.component'
@@ -34,6 +35,7 @@ import { ManagementListComponent } from '../management-list/management-list.comp
     NgbDropdownModule,
     NgbPaginationModule,
     NgxBootstrapIconsModule,
+    ClearableBadgeComponent,
   ],
 })
 export class TagListComponent extends ManagementListComponent<Tag> {