mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-20 03:06:10 -05:00 
			
		
		
		
	Enhancement: bulk edit object permissions (#4176)
* bulk_edit_object_perms API endpoint * Frontend support for bulk object permissions edit
This commit is contained in:
		| @@ -1,4 +1,14 @@ | ||||
| <pngx-page-header title="{{ typeNamePlural | titlecase }}"> | ||||
|   <button class="btn btn-sm btn-outline-secondary me-2" (click)="clearSelection()" [hidden]="selectedObjects.size === 0"> | ||||
|     <svg class="sidebaricon" fill="currentColor"> | ||||
|       <use xlink:href="assets/bootstrap-icons.svg#x"/> | ||||
|     </svg> <ng-container i18n>Clear selection</ng-container> | ||||
|   </button> | ||||
|   <button type="button" class="btn btn-sm btn-outline-primary me-5" (click)="setPermissions()" [disabled]="!userOwnsAll || selectedObjects.size === 0"> | ||||
|     <svg width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor"> | ||||
|       <use xlink:href="assets/bootstrap-icons.svg#person-fill-lock" /> | ||||
|     </svg> <ng-container i18n>Permissions</ng-container> | ||||
|   </button> | ||||
|   <button type="button" class="btn btn-sm btn-outline-primary" (click)="openCreateDialog()" *pngxIfPermissions="{ action: PermissionAction.Add, type: permissionType }" i18n>Create</button> | ||||
| </pngx-page-header> | ||||
|  | ||||
| @@ -16,6 +26,12 @@ | ||||
| <table class="table table-striped align-middle border shadow-sm"> | ||||
|   <thead> | ||||
|     <tr> | ||||
|       <th scope="col"> | ||||
|         <div class="form-check"> | ||||
|           <input type="checkbox" class="form-check-input" id="all-objects" [disabled]="data.length === 0" (click)="toggleAll($event); $event.stopPropagation();"> | ||||
|           <label class="form-check-label" for="all-objects"></label> | ||||
|         </div> | ||||
|       </th> | ||||
|       <th scope="col" pngxSortable="name" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Name</th> | ||||
|       <th scope="col" class="d-none d-sm-table-cell" pngxSortable="matching_algorithm" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Matching</th> | ||||
|       <th scope="col" pngxSortable="document_count" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Document count</th> | ||||
| @@ -30,7 +46,13 @@ | ||||
|         <ng-container i18n>Loading...</ng-container> | ||||
|       </td> | ||||
|     </tr> | ||||
|     <tr *ngFor="let object of data"> | ||||
|     <tr *ngFor="let object of data" (click)="toggleSelected(object, $event); $event.stopPropagation();"> | ||||
|       <td> | ||||
|         <div class="form-check"> | ||||
|           <input type="checkbox" class="form-check-input" id="{{typeName}}{{object.id}}" [checked]="selectedObjects.has(object.id)" (click)="toggleSelected(object); $event.stopPropagation();"> | ||||
|           <label class="form-check-label" for="{{typeName}}{{object.id}}"></label> | ||||
|         </div> | ||||
|       </td> | ||||
|       <td scope="row">{{ object.name }}</td> | ||||
|       <td scope="row" class="d-none d-sm-table-cell">{{ getMatching(object) }}</td> | ||||
|       <td scope="row">{{ object.document_count }}</td> | ||||
| @@ -54,17 +76,17 @@ | ||||
|           </div> | ||||
|         </div> | ||||
|         <div class="btn-group d-none d-sm-block"> | ||||
|           <button class="btn btn-sm btn-outline-secondary" (click)="filterDocuments(object)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"> | ||||
|           <button class="btn btn-sm btn-outline-secondary" (click)="filterDocuments(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"> | ||||
|             <svg class="buttonicon-sm" fill="currentColor"> | ||||
|               <use xlink:href="assets/bootstrap-icons.svg#filter" /> | ||||
|             </svg> <ng-container i18n>Documents</ng-container> | ||||
|           </button> | ||||
|           <button class="btn btn-sm btn-outline-secondary" (click)="openEditDialog(object)" *pngxIfPermissions="{ action: PermissionAction.Change, type: permissionType }" [disabled]="!userCanEdit(object)"> | ||||
|           <button class="btn btn-sm btn-outline-secondary" (click)="openEditDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: permissionType }" [disabled]="!userCanEdit(object)"> | ||||
|             <svg class="buttonicon-sm" fill="currentColor"> | ||||
|               <use xlink:href="assets/bootstrap-icons.svg#pencil" /> | ||||
|             </svg> <ng-container i18n>Edit</ng-container> | ||||
|           </button> | ||||
|           <button class="btn btn-sm btn-outline-danger" (click)="openDeleteDialog(object)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: permissionType }" [disabled]="!userCanDelete(object)"> | ||||
|           <button class="btn btn-sm btn-outline-danger" (click)="openDeleteDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Delete, type: permissionType }" [disabled]="!userCanDelete(object)"> | ||||
|             <svg class="buttonicon-sm" fill="currentColor"> | ||||
|               <use xlink:href="assets/bootstrap-icons.svg#trash" /> | ||||
|             </svg> <ng-container i18n>Delete</ng-container> | ||||
| @@ -75,7 +97,10 @@ | ||||
|   </tbody> | ||||
| </table> | ||||
|  | ||||
| <div class="d-flex" *ngIf="!isLoading"> | ||||
|   <div i18n *ngIf="collectionSize > 0">{collectionSize, plural, =1 {One {{typeName}}} other {{{collectionSize || 0}} total {{typeNamePlural}}}}</div> | ||||
| <div class="d-flex mb-2" *ngIf="!isLoading"> | ||||
|   <div *ngIf="collectionSize > 0"> | ||||
|     <ng-container i18n>{collectionSize, plural, =1 {One {{typeName}}} other {{{collectionSize || 0}} total {{typeNamePlural}}}}</ng-container> | ||||
|     <ng-container *ngIf="selectedObjects.size > 0"> ({{selectedObjects.size}} selected)</ng-container> | ||||
|   </div> | ||||
|   <ngb-pagination *ngIf="collectionSize > 20" class="ms-auto" [pageSize]="25" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination> | ||||
| </div> | ||||
|   | ||||
| @@ -35,6 +35,7 @@ import { PermissionsGuard } from 'src/app/guards/permissions.guard' | ||||
| import { MATCH_AUTO } from 'src/app/data/matching-model' | ||||
| import { MATCH_NONE } from 'src/app/data/matching-model' | ||||
| import { MATCH_LITERAL } from 'src/app/data/matching-model' | ||||
| import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component' | ||||
|  | ||||
| const tags: PaperlessTag[] = [ | ||||
|   { | ||||
| @@ -72,6 +73,7 @@ describe('ManagementListComponent', () => { | ||||
|         IfPermissionsDirective, | ||||
|         SafeHtmlPipe, | ||||
|         ConfirmDialogComponent, | ||||
|         PermissionsDialogComponent, | ||||
|       ], | ||||
|       providers: [ | ||||
|         { | ||||
| @@ -145,7 +147,7 @@ describe('ManagementListComponent', () => { | ||||
|     const toastInfoSpy = jest.spyOn(toastService, 'showInfo') | ||||
|     const reloadSpy = jest.spyOn(component, 'reloadData') | ||||
|  | ||||
|     const createButton = fixture.debugElement.queryAll(By.css('button'))[0] | ||||
|     const createButton = fixture.debugElement.queryAll(By.css('button'))[2] | ||||
|     createButton.triggerEventHandler('click') | ||||
|  | ||||
|     expect(modal).not.toBeUndefined() | ||||
| @@ -170,7 +172,7 @@ describe('ManagementListComponent', () => { | ||||
|     const toastInfoSpy = jest.spyOn(toastService, 'showInfo') | ||||
|     const reloadSpy = jest.spyOn(component, 'reloadData') | ||||
|  | ||||
|     const editButton = fixture.debugElement.queryAll(By.css('button'))[3] | ||||
|     const editButton = fixture.debugElement.queryAll(By.css('button'))[5] | ||||
|     editButton.triggerEventHandler('click') | ||||
|  | ||||
|     expect(modal).not.toBeUndefined() | ||||
| @@ -196,7 +198,7 @@ describe('ManagementListComponent', () => { | ||||
|     const deleteSpy = jest.spyOn(tagService, 'delete') | ||||
|     const reloadSpy = jest.spyOn(component, 'reloadData') | ||||
|  | ||||
|     const deleteButton = fixture.debugElement.queryAll(By.css('button'))[4] | ||||
|     const deleteButton = fixture.debugElement.queryAll(By.css('button'))[6] | ||||
|     deleteButton.triggerEventHandler('click') | ||||
|  | ||||
|     expect(modal).not.toBeUndefined() | ||||
| @@ -216,7 +218,7 @@ describe('ManagementListComponent', () => { | ||||
|  | ||||
|   it('should support quick filter for objects', () => { | ||||
|     const qfSpy = jest.spyOn(documentListViewService, 'quickFilter') | ||||
|     const filterButton = fixture.debugElement.queryAll(By.css('button'))[2] | ||||
|     const filterButton = fixture.debugElement.queryAll(By.css('button'))[4] | ||||
|     filterButton.triggerEventHandler('click') | ||||
|     expect(qfSpy).toHaveBeenCalledWith([ | ||||
|       { rule_type: FILTER_HAS_TAGS_ALL, value: tags[0].id.toString() }, | ||||
| @@ -229,4 +231,47 @@ describe('ManagementListComponent', () => { | ||||
|     sortable.triggerEventHandler('click') | ||||
|     expect(reloadSpy).toHaveBeenCalled() | ||||
|   }) | ||||
|  | ||||
|   it('should support toggle all items in view', () => { | ||||
|     expect(component.selectedObjects.size).toEqual(0) | ||||
|     const toggleAllSpy = jest.spyOn(component, 'toggleAll') | ||||
|     const checkButton = fixture.debugElement.queryAll( | ||||
|       By.css('input.form-check-input') | ||||
|     )[0] | ||||
|     checkButton.nativeElement.dispatchEvent(new Event('click')) | ||||
|     checkButton.nativeElement.checked = true | ||||
|     checkButton.nativeElement.dispatchEvent(new Event('click')) | ||||
|     expect(toggleAllSpy).toHaveBeenCalled() | ||||
|     expect(component.selectedObjects.size).toEqual(tags.length) | ||||
|   }) | ||||
|  | ||||
|   it('should support bulk edit permissions', () => { | ||||
|     const bulkEditPermsSpy = jest.spyOn(tagService, 'bulk_update_permissions') | ||||
|     component.toggleSelected(tags[0]) | ||||
|     component.toggleSelected(tags[1]) | ||||
|     component.toggleSelected(tags[2]) | ||||
|     component.toggleSelected(tags[2]) // uncheck, for coverage | ||||
|     const selected = new Set([tags[0].id, tags[1].id]) | ||||
|     expect(component.selectedObjects).toEqual(selected) | ||||
|     let modal: NgbModalRef | ||||
|     modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1])) | ||||
|     fixture.detectChanges() | ||||
|     component.setPermissions() | ||||
|     expect(modal).not.toBeUndefined() | ||||
|  | ||||
|     // fail first | ||||
|     bulkEditPermsSpy.mockReturnValueOnce( | ||||
|       throwError(() => new Error('error setting permissions')) | ||||
|     ) | ||||
|     const errorToastSpy = jest.spyOn(toastService, 'showError') | ||||
|     modal.componentInstance.confirmClicked.emit() | ||||
|     expect(bulkEditPermsSpy).toHaveBeenCalled() | ||||
|     expect(errorToastSpy).toHaveBeenCalled() | ||||
|  | ||||
|     const successToastSpy = jest.spyOn(toastService, 'showInfo') | ||||
|     bulkEditPermsSpy.mockReturnValueOnce(of('OK')) | ||||
|     modal.componentInstance.confirmClicked.emit() | ||||
|     expect(bulkEditPermsSpy).toHaveBeenCalled() | ||||
|     expect(successToastSpy).toHaveBeenCalled() | ||||
|   }) | ||||
| }) | ||||
|   | ||||
| @@ -6,7 +6,7 @@ import { | ||||
|   ViewChildren, | ||||
| } from '@angular/core' | ||||
| import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { Subject, Subscription } from 'rxjs' | ||||
| import { Subject } from 'rxjs' | ||||
| import { debounceTime, distinctUntilChanged, takeUntil } from 'rxjs/operators' | ||||
| import { | ||||
|   MatchingModel, | ||||
| @@ -15,7 +15,10 @@ import { | ||||
|   MATCH_NONE, | ||||
| } from 'src/app/data/matching-model' | ||||
| import { ObjectWithId } from 'src/app/data/object-with-id' | ||||
| import { ObjectWithPermissions } from 'src/app/data/object-with-permissions' | ||||
| import { | ||||
|   ObjectWithPermissions, | ||||
|   PermissionsObject, | ||||
| } from 'src/app/data/object-with-permissions' | ||||
| import { | ||||
|   SortableDirective, | ||||
|   SortEvent, | ||||
| @@ -28,11 +31,9 @@ import { | ||||
| import { AbstractNameFilterService } from 'src/app/services/rest/abstract-name-filter-service' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
| import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' | ||||
| import { | ||||
|   EditDialogComponent, | ||||
|   EditDialogMode, | ||||
| } from '../../common/edit-dialog/edit-dialog.component' | ||||
| import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component' | ||||
| import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component' | ||||
| import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component' | ||||
|  | ||||
| export interface ManagementListColumn { | ||||
|   key: string | ||||
| @@ -82,6 +83,8 @@ export abstract class ManagementListComponent<T extends ObjectWithId> | ||||
|   private unsubscribeNotifier: Subject<any> = new Subject() | ||||
|   private _nameFilter: string | ||||
|  | ||||
|   public selectedObjects: Set<number> = new Set() | ||||
|  | ||||
|   ngOnInit(): void { | ||||
|     this.reloadData() | ||||
|  | ||||
| @@ -243,4 +246,63 @@ export abstract class ManagementListComponent<T extends ObjectWithId> | ||||
|       object | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   get userOwnsAll(): boolean { | ||||
|     let ownsAll: boolean = true | ||||
|     const objects = this.data.filter((o) => this.selectedObjects.has(o.id)) | ||||
|     ownsAll = objects.every((o) => | ||||
|       this.permissionsService.currentUserOwnsObject(o) | ||||
|     ) | ||||
|     return ownsAll | ||||
|   } | ||||
|  | ||||
|   toggleAll(event: PointerEvent) { | ||||
|     if ((event.target as HTMLInputElement).checked) { | ||||
|       this.selectedObjects = new Set(this.data.map((o) => o.id)) | ||||
|     } else { | ||||
|       this.clearSelection() | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   clearSelection() { | ||||
|     this.selectedObjects.clear() | ||||
|   } | ||||
|  | ||||
|   toggleSelected(object) { | ||||
|     this.selectedObjects.has(object.id) | ||||
|       ? this.selectedObjects.delete(object.id) | ||||
|       : this.selectedObjects.add(object.id) | ||||
|   } | ||||
|  | ||||
|   setPermissions() { | ||||
|     let modal = this.modalService.open(PermissionsDialogComponent, { | ||||
|       backdrop: 'static', | ||||
|     }) | ||||
|     modal.componentInstance.confirmClicked.subscribe( | ||||
|       (permissions: { owner: number; set_permissions: PermissionsObject }) => { | ||||
|         modal.componentInstance.buttonsEnabled = false | ||||
|         this.service | ||||
|           .bulk_update_permissions( | ||||
|             Array.from(this.selectedObjects), | ||||
|             permissions | ||||
|           ) | ||||
|           .subscribe({ | ||||
|             next: () => { | ||||
|               modal.close() | ||||
|               this.toastService.showInfo( | ||||
|                 $localize`Permissions updated successfully` | ||||
|               ) | ||||
|               this.reloadData() | ||||
|             }, | ||||
|             error: (error) => { | ||||
|               modal.componentInstance.buttonsEnabled = true | ||||
|               this.toastService.showError( | ||||
|                 $localize`Error updating permissions`, | ||||
|                 error | ||||
|               ) | ||||
|             }, | ||||
|           }) | ||||
|       } | ||||
|     ) | ||||
|   } | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 shamoon
					shamoon