]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Tweakhancement: reorganize some list & bulk editing buttons (#10944)
authorshamoon <4887959+shamoon@users.noreply.github.com>
Fri, 26 Sep 2025 20:47:24 +0000 (13:47 -0700)
committerGitHub <noreply@github.com>
Fri, 26 Sep 2025 20:47:24 +0000 (13:47 -0700)
src-ui/e2e/document-list/document-list.spec.ts
src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html
src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.scss
src-ui/src/app/components/document-list/document-list.component.html
src-ui/src/app/components/document-list/document-list.component.ts

index 45857bb09ff1daa82ade7520b56855820ef44c1e..0a7b54fcbd8e43ce37e66ef7da3236cdb9bc8860 100644 (file)
@@ -174,7 +174,7 @@ test('bulk edit', async ({ page }) => {
   await expect(page.locator('pngx-document-list')).toHaveText(
     /Selected 61 of 61 documents/i
   )
-  await page.getByRole('button', { name: 'Cancel' }).click()
+  await page.getByRole('button', { name: 'None' }).click()
 
   await page.locator('pngx-document-card-small').nth(1).click()
   await page.locator('pngx-document-card-small').nth(2).click()
index 0eb655a21d00345248731d8f5f2edcfa33b1750e..7e499dfd0f1762a4c26d10391f67c1b2dfeda957 100644 (file)
 <div class="d-flex flex-wrap gap-4">
-  <div class="d-flex align-items-center" role="group" aria-label="Select">
-    <button class="btn btn-sm btn-outline-secondary" (click)="list.selectNone()">
-      <i-bs name="slash-circle"></i-bs>&nbsp;<ng-container i18n>Cancel</ng-container>
+  <div class="d-flex flex-wrap align-items-center gap-2" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
+    <label class="me-2" i18n>Edit:</label>
+    @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
+      <pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title
+        filterPlaceholder="Filter tags" i18n-filterPlaceholder
+        [disabled]="!userCanEditAll || disabled"
+        [editing]="true"
+        [applyOnClose]="applyOnClose"
+        [createRef]="createTag.bind(this)"
+        (opened)="openTagsDropdown()"
+        [(selectionModel)]="tagSelectionModel"
+        [documentCounts]="tagDocumentCounts"
+        (apply)="setTags($event)"
+        shortcutKey="t">
+      </pngx-filterable-dropdown>
+    }
+    @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
+      <pngx-filterable-dropdown title="Correspondent" icon="person-fill" i18n-title
+        filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
+        [disabled]="!userCanEditAll || disabled"
+        [editing]="true"
+        [applyOnClose]="applyOnClose"
+        [createRef]="createCorrespondent.bind(this)"
+        (opened)="openCorrespondentDropdown()"
+        [(selectionModel)]="correspondentSelectionModel"
+        [documentCounts]="correspondentDocumentCounts"
+        (apply)="setCorrespondents($event)"
+        shortcutKey="y">
+      </pngx-filterable-dropdown>
+    }
+    @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) {
+      <pngx-filterable-dropdown title="Document type" icon="file-earmark-fill" i18n-title
+        filterPlaceholder="Filter document types" i18n-filterPlaceholder
+        [disabled]="!userCanEditAll || disabled"
+        [editing]="true"
+        [applyOnClose]="applyOnClose"
+        [createRef]="createDocumentType.bind(this)"
+        (opened)="openDocumentTypeDropdown()"
+        [(selectionModel)]="documentTypeSelectionModel"
+        [documentCounts]="documentTypeDocumentCounts"
+        (apply)="setDocumentTypes($event)"
+        shortcutKey="u">
+      </pngx-filterable-dropdown>
+    }
+    @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) {
+      <pngx-filterable-dropdown title="Storage path" icon="folder-fill" i18n-title
+        filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
+        [disabled]="!userCanEditAll || disabled"
+        [editing]="true"
+        [applyOnClose]="applyOnClose"
+        [createRef]="createStoragePath.bind(this)"
+        (opened)="openStoragePathDropdown()"
+        [(selectionModel)]="storagePathsSelectionModel"
+        [documentCounts]="storagePathDocumentCounts"
+        (apply)="setStoragePaths($event)"
+        shortcutKey="i">
+      </pngx-filterable-dropdown>
+    }
+    @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.CustomField)) {
+      <pngx-filterable-dropdown title="Custom fields" icon="ui-radios" i18n-title
+        filterPlaceholder="Filter custom fields" i18n-filterPlaceholder
+        [disabled]="!userCanEditAll"
+        [editing]="true"
+        [applyOnClose]="applyOnClose"
+        [createRef]="createCustomField.bind(this)"
+        (opened)="openCustomFieldsDropdown()"
+        [(selectionModel)]="customFieldsSelectionModel"
+        [documentCounts]="customFieldDocumentCounts"
+        extraButtonTitle="Set values"
+        i18n-extraButtonTitle
+        (extraButton)="setCustomFieldValues($event)"
+        (apply)="setCustomFields($event)">
+      </pngx-filterable-dropdown>
+    }
+    <div class="btn-group">
+      <button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="setPermissions()" [disabled]="!userOwnsAll || !userCanEditAll">
+        <i-bs name="person-fill-lock"></i-bs><div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Permissions</ng-container></div>
       </button>
     </div>
-    <div class="d-flex align-items-center gap-2" role="group" aria-label="Select">
-      <label class="me-2" i18n>Select:</label>
-      <div class="btn-group">
-        <button class="btn btn-sm btn-outline-primary" (click)="list.selectPage()">
-          <i-bs name="file-earmark-check"></i-bs>&nbsp;<ng-container i18n>Page</ng-container>
+  </div>
+  <div class="d-flex align-items-center gap-2 ms-auto">
+    <div class="btn-toolbar">
+      <div ngbDropdown>
+        <button class="btn btn-sm btn-outline-primary" id="dropdownSelect" [disabled]="!userCanEdit && !userCanAdd" ngbDropdownToggle>
+          <i-bs name="three-dots"></i-bs>
+          <div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Actions</ng-container></div>
+        </button>
+        <div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
+          <button ngbDropdownItem (click)="reprocessSelected()" [disabled]="!userCanEditAll && !userCanEditAll">
+            <i-bs name="body-text"></i-bs>&nbsp;<ng-container i18n>Reprocess</ng-container>
+          </button>
+          <button ngbDropdownItem (click)="rotateSelected()" [disabled]="!userOwnsAll && !userCanEditAll">
+            <i-bs name="arrow-clockwise"></i-bs>&nbsp;<ng-container i18n>Rotate</ng-container>
+          </button>
+          <button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.selected.size < 2">
+            <i-bs name="journals"></i-bs>&nbsp;<ng-container i18n>Merge</ng-container>
           </button>
-          <button class="btn btn-sm btn-outline-primary" (click)="list.selectAll()">
-            <i-bs name="check-all"></i-bs>&nbsp;<ng-container i18n>All</ng-container>
-            </button>
-          </div>
-        </div>
-        <div class="d-flex align-items-center gap-2" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
-          <label class="me-2" i18n>Edit:</label>
-          @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
-            <pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title
-              filterPlaceholder="Filter tags" i18n-filterPlaceholder
-              [disabled]="!userCanEditAll || disabled"
-              [editing]="true"
-              [applyOnClose]="applyOnClose"
-              [createRef]="createTag.bind(this)"
-              (opened)="openTagsDropdown()"
-              [(selectionModel)]="tagSelectionModel"
-              [documentCounts]="tagDocumentCounts"
-              (apply)="setTags($event)"
-              shortcutKey="t">
-            </pngx-filterable-dropdown>
-          }
-          @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
-            <pngx-filterable-dropdown title="Correspondent" icon="person-fill" i18n-title
-              filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
-              [disabled]="!userCanEditAll || disabled"
-              [editing]="true"
-              [applyOnClose]="applyOnClose"
-              [createRef]="createCorrespondent.bind(this)"
-              (opened)="openCorrespondentDropdown()"
-              [(selectionModel)]="correspondentSelectionModel"
-              [documentCounts]="correspondentDocumentCounts"
-              (apply)="setCorrespondents($event)"
-              shortcutKey="y">
-            </pngx-filterable-dropdown>
-          }
-          @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) {
-            <pngx-filterable-dropdown title="Document type" icon="file-earmark-fill" i18n-title
-              filterPlaceholder="Filter document types" i18n-filterPlaceholder
-              [disabled]="!userCanEditAll || disabled"
-              [editing]="true"
-              [applyOnClose]="applyOnClose"
-              [createRef]="createDocumentType.bind(this)"
-              (opened)="openDocumentTypeDropdown()"
-              [(selectionModel)]="documentTypeSelectionModel"
-              [documentCounts]="documentTypeDocumentCounts"
-              (apply)="setDocumentTypes($event)"
-              shortcutKey="u">
-            </pngx-filterable-dropdown>
-          }
-          @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) {
-            <pngx-filterable-dropdown title="Storage path" icon="folder-fill" i18n-title
-              filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
-              [disabled]="!userCanEditAll || disabled"
-              [editing]="true"
-              [applyOnClose]="applyOnClose"
-              [createRef]="createStoragePath.bind(this)"
-              (opened)="openStoragePathDropdown()"
-              [(selectionModel)]="storagePathsSelectionModel"
-              [documentCounts]="storagePathDocumentCounts"
-              (apply)="setStoragePaths($event)"
-              shortcutKey="i">
-            </pngx-filterable-dropdown>
-          }
-          @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.CustomField)) {
-            <pngx-filterable-dropdown title="Custom fields" icon="ui-radios" i18n-title
-              filterPlaceholder="Filter custom fields" i18n-filterPlaceholder
-              [disabled]="!userCanEditAll"
-              [editing]="true"
-              [applyOnClose]="applyOnClose"
-              [createRef]="createCustomField.bind(this)"
-              (opened)="openCustomFieldsDropdown()"
-              [(selectionModel)]="customFieldsSelectionModel"
-              [documentCounts]="customFieldDocumentCounts"
-              extraButtonTitle="Set values"
-              i18n-extraButtonTitle
-              (extraButton)="setCustomFieldValues($event)"
-              (apply)="setCustomFields($event)">
-            </pngx-filterable-dropdown>
-          }
         </div>
-        <div class="d-flex align-items-center gap-2 ms-auto">
-          <div class="btn-toolbar">
-
-            <button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="setPermissions()" [disabled]="!userOwnsAll || !userCanEditAll">
-              <i-bs name="person-fill-lock"></i-bs><div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Permissions</ng-container></div>
-            </button>
-
-            <div ngbDropdown>
-              <button class="btn btn-sm btn-outline-primary" id="dropdownSelect" [disabled]="!userCanEdit && !userCanAdd" ngbDropdownToggle>
-                <i-bs name="three-dots"></i-bs>
-                <div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Actions</ng-container></div>
-              </button>
-              <div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
-                <button ngbDropdownItem (click)="reprocessSelected()" [disabled]="!userCanEditAll && !userCanEditAll">
-                  <i-bs name="body-text"></i-bs>&nbsp;<ng-container i18n>Reprocess</ng-container>
-                </button>
-                <button ngbDropdownItem (click)="rotateSelected()" [disabled]="!userOwnsAll && !userCanEditAll">
-                  <i-bs name="arrow-clockwise"></i-bs>&nbsp;<ng-container i18n>Rotate</ng-container>
-                </button>
-                <button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.selected.size < 2">
-                  <i-bs name="journals"></i-bs>&nbsp;<ng-container i18n>Merge</ng-container>
-                </button>
-              </div>
-            </div>
+      </div>
+    </div>
+    <div class="btn-group btn-group-sm">
+      <button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload" (click)="downloadSelected()">
+        @if (!awaitingDownload) {
+          <i-bs name="arrow-down"></i-bs>
+        }
+        @if (awaitingDownload) {
+          <div class="spinner-border spinner-border-sm" role="status">
+            <span class="visually-hidden">Preparing download...</span>
           </div>
-
-            <div class="btn-group btn-group-sm">
-              <button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload" (click)="downloadSelected()">
-                @if (!awaitingDownload) {
-                  <i-bs name="arrow-down"></i-bs>
-                }
-                @if (awaitingDownload) {
-                  <div class="spinner-border spinner-border-sm" role="status">
-                    <span class="visually-hidden">Preparing download...</span>
-                  </div>
-                }
-                <div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Download</ng-container></div>
-              </button>
-              <div ngbDropdown class="me-2 d-flex btn-group" role="group">
-                <button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle-split rounded-end" ngbDropdownToggle></button>
-                <div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
-                  <form [formGroup]="downloadForm" class="px-3 py-1">
-                    <p class="mb-1" i18n>Include:</p>
-                    <div class="form-group ps-3 mb-2">
-                      <div class="form-check">
-                        <input type="checkbox" class="form-check-input" id="downloadFileType_archive" formControlName="downloadFileTypeArchive" />
-                        <label class="form-check-label" for="downloadFileType_archive" i18n>Archived files</label>
-                      </div>
-                      <div class="form-check">
-                        <input type="checkbox" class="form-check-input" id="downloadFileType_originals" formControlName="downloadFileTypeOriginals" />
-                        <label class="form-check-label" for="downloadFileType_originals" i18n>Original files</label>
-                      </div>
-                    </div>
-                    <div class="form-check">
-                      <input type="checkbox" class="form-check-input" id="downloadUseFormatting" formControlName="downloadUseFormatting" />
-                      <label class="form-check-label" for="downloadUseFormatting" i18n>Use formatted filename</label>
-                    </div>
-                  </form>
-                </div>
+        }
+        <div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Download</ng-container></div>
+      </button>
+      <div ngbDropdown class="me-2 d-flex btn-group" role="group">
+        <button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle-split rounded-end" ngbDropdownToggle></button>
+        <div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
+          <form [formGroup]="downloadForm" class="px-3 py-1">
+            <p class="mb-1" i18n>Include:</p>
+            <div class="form-group ps-3 mb-2">
+              <div class="form-check">
+                <input type="checkbox" class="form-check-input" id="downloadFileType_archive" formControlName="downloadFileTypeArchive" />
+                <label class="form-check-label" for="downloadFileType_archive" i18n>Archived files</label>
               </div>
-            </div>
-
-            <div class="btn-group btn-group-sm">
-              <button type="button" class="btn btn-sm btn-outline-danger" (click)="applyDelete()" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }" [disabled]="!userOwnsAll">
-                <i-bs name="trash"></i-bs>&nbsp;<ng-container i18n>Delete</ng-container>
-                </button>
+              <div class="form-check">
+                <input type="checkbox" class="form-check-input" id="downloadFileType_originals" formControlName="downloadFileTypeOriginals" />
+                <label class="form-check-label" for="downloadFileType_originals" i18n>Original files</label>
               </div>
             </div>
-          </div>
+            <div class="form-check">
+              <input type="checkbox" class="form-check-input" id="downloadUseFormatting" formControlName="downloadUseFormatting" />
+              <label class="form-check-label" for="downloadUseFormatting" i18n>Use formatted filename</label>
+            </div>
+          </form>
+        </div>
+      </div>
+    </div>
+
+    <div class="btn-group btn-group-sm">
+      <button type="button" class="btn btn-sm btn-outline-danger" (click)="applyDelete()" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }" [disabled]="!userOwnsAll">
+        <i-bs name="trash"></i-bs>&nbsp;<ng-container i18n>Delete</ng-container>
+      </button>
+    </div>
+  </div>
+</div>
index 939f2c7902a24f84fd0975f4b106cf772669f1c2..a5ea35ce4ddc8fdad9c103dfc2c09533e2da9302 100644 (file)
@@ -5,3 +5,7 @@
 .dropdown-menu{
     --bs-dropdown-min-width: 12rem;
 }
+
+.btn-group .btn {
+  white-space: nowrap;
+}
index c58d1ede10bc4dee8044c3a2c08594fccfa9b05e..a6d23f2a54b364aa0043cedd18d755619d3e1bfc 100644 (file)
@@ -1,16 +1,36 @@
 <pngx-page-header [title]="getTitle()">
-
-  <div ngbDropdown class="btn-group flex-fill">
-    <button class="btn btn-sm btn-outline-primary" id="dropdownSelect" ngbDropdownToggle>
+  <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 (list.selected.size > 0) {
+        <pngx-clearable-badge [selected]="list.selected.size > 0" [number]="list.selected.size" (cleared)="list.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
+      }
     </button>
-    <div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
+    <div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow">
       <button ngbDropdownItem (click)="list.selectNone()" i18n>Select none</button>
       <button ngbDropdownItem (click)="list.selectPage()" i18n>Select page</button>
       <button ngbDropdownItem (click)="list.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">Select:</span>
+    </div>
+    <div class="btn-group btn-group-sm flex-nowrap">
+      @if (list.selected.size > 0) {
+        <button class="btn btn-sm btn-outline-secondary" (click)="list.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)="list.selectPage()">
+        <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)="list.selectAll()">
+        <i-bs name="check-all"></i-bs>&nbsp;<ng-container i18n>All</ng-container>
+      </button>
+    </div>
+  </div>
   <div ngbDropdown class="btn-group flex-fill">
     <button class="btn btn-sm btn-outline-primary" id="dropdownDisplayFields" ngbDropdownToggle>
       <i-bs name="card-heading"></i-bs>
       @if (!list.isReloading && isFiltered) {
         <button class="btn btn-link py-0" (click)="resetFilters()">
           <i-bs width="1em" height="1em" name="x"></i-bs><small i18n>Reset filters</small>
-          </button>
-        }
+        </button>
+      }
+      @if (!list.isReloading && list.selected.size > 0) {
+        <button class="btn btn-link py-0" (click)="list.selectNone()">
+          <i-bs width="1em" height="1em" name="slash-circle" class="me-1"></i-bs><small i18n>Clear selection</small>
+        </button>
+      }
       </div>
       @if (list.collectionSize) {
         <ngb-pagination [pageSize]="list.pageSize" [collectionSize]="list.collectionSize" [(page)]="list.currentPage" [maxSize]="5"
index aca686fcf32c320eb7ea573e370090015221dfd7..cef693e3a447efa015f0ea279d6146d5e4903d41 100644 (file)
@@ -56,6 +56,7 @@ import {
   filterRulesDiffer,
   isFullTextFilterRule,
 } from 'src/app/utils/filter-rules'
+import { ClearableBadgeComponent } from '../common/clearable-badge/clearable-badge.component'
 import { CustomFieldDisplayComponent } from '../common/custom-field-display/custom-field-display.component'
 import { PageHeaderComponent } from '../common/page-header/page-header.component'
 import { PreviewPopupComponent } from '../common/preview-popup/preview-popup.component'
@@ -72,6 +73,7 @@ import { SaveViewConfigDialogComponent } from './save-view-config-dialog/save-vi
   templateUrl: './document-list.component.html',
   styleUrls: ['./document-list.component.scss'],
   imports: [
+    ClearableBadgeComponent,
     CustomFieldDisplayComponent,
     PageHeaderComponent,
     BulkEditorComponent,