mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Merge pull request #1868 from paperless-ngx/fix/issue-1866
Fix: independent control of saved views
This commit is contained in:
		| @@ -15,6 +15,7 @@ import { DirtyFormGuard } from './guards/dirty-form.guard' | |||||||
| import { StoragePathListComponent } from './components/manage/storage-path-list/storage-path-list.component' | import { StoragePathListComponent } from './components/manage/storage-path-list/storage-path-list.component' | ||||||
| import { TasksComponent } from './components/manage/tasks/tasks.component' | import { TasksComponent } from './components/manage/tasks/tasks.component' | ||||||
| import { DirtyDocGuard } from './guards/dirty-doc.guard' | import { DirtyDocGuard } from './guards/dirty-doc.guard' | ||||||
|  | import { DirtySavedViewGuard } from './guards/dirty-saved-view.guard' | ||||||
|  |  | ||||||
| const routes: Routes = [ | const routes: Routes = [ | ||||||
|   { path: '', redirectTo: 'dashboard', pathMatch: 'full' }, |   { path: '', redirectTo: 'dashboard', pathMatch: 'full' }, | ||||||
| @@ -24,8 +25,16 @@ const routes: Routes = [ | |||||||
|     canDeactivate: [DirtyDocGuard], |     canDeactivate: [DirtyDocGuard], | ||||||
|     children: [ |     children: [ | ||||||
|       { path: 'dashboard', component: DashboardComponent }, |       { path: 'dashboard', component: DashboardComponent }, | ||||||
|       { path: 'documents', component: DocumentListComponent }, |       { | ||||||
|       { path: 'view/:id', component: DocumentListComponent }, |         path: 'documents', | ||||||
|  |         component: DocumentListComponent, | ||||||
|  |         canDeactivate: [DirtySavedViewGuard], | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         path: 'view/:id', | ||||||
|  |         component: DocumentListComponent, | ||||||
|  |         canDeactivate: [DirtySavedViewGuard], | ||||||
|  |       }, | ||||||
|       { path: 'documents/:id', component: DocumentDetailComponent }, |       { path: 'documents/:id', component: DocumentDetailComponent }, | ||||||
|       { path: 'asn/:id', component: DocumentAsnComponent }, |       { path: 'asn/:id', component: DocumentAsnComponent }, | ||||||
|       { path: 'tags', component: TagListComponent }, |       { path: 'tags', component: TagListComponent }, | ||||||
|   | |||||||
| @@ -69,6 +69,7 @@ import { ColorComponent } from './components/common/input/color/color.component' | |||||||
| import { DocumentAsnComponent } from './components/document-asn/document-asn.component' | import { DocumentAsnComponent } from './components/document-asn/document-asn.component' | ||||||
| import { DocumentCommentsComponent } from './components/document-comments/document-comments.component' | import { DocumentCommentsComponent } from './components/document-comments/document-comments.component' | ||||||
| import { DirtyDocGuard } from './guards/dirty-doc.guard' | import { DirtyDocGuard } from './guards/dirty-doc.guard' | ||||||
|  | import { DirtySavedViewGuard } from './guards/dirty-saved-view.guard' | ||||||
| import { StoragePathListComponent } from './components/manage/storage-path-list/storage-path-list.component' | import { StoragePathListComponent } from './components/manage/storage-path-list/storage-path-list.component' | ||||||
| import { StoragePathEditDialogComponent } from './components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component' | import { StoragePathEditDialogComponent } from './components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component' | ||||||
| import { SettingsService } from './services/settings.service' | import { SettingsService } from './services/settings.service' | ||||||
| @@ -215,6 +216,7 @@ function initializeApp(settings: SettingsService) { | |||||||
|     { provide: NgbDateAdapter, useClass: ISODateAdapter }, |     { provide: NgbDateAdapter, useClass: ISODateAdapter }, | ||||||
|     { provide: NgbDateParserFormatter, useClass: LocalizedDateParserFormatter }, |     { provide: NgbDateParserFormatter, useClass: LocalizedDateParserFormatter }, | ||||||
|     DirtyDocGuard, |     DirtyDocGuard, | ||||||
|  |     DirtySavedViewGuard, | ||||||
|   ], |   ], | ||||||
|   bootstrap: [AppComponent], |   bootstrap: [AppComponent], | ||||||
| }) | }) | ||||||
|   | |||||||
| @@ -16,4 +16,7 @@ | |||||||
|         <ngb-progressbar *ngIf="!confirmButtonEnabled" style="height: 1px;" type="dark" [max]="secondsTotal" [value]="seconds"></ngb-progressbar> |         <ngb-progressbar *ngIf="!confirmButtonEnabled" style="height: 1px;" type="dark" [max]="secondsTotal" [value]="seconds"></ngb-progressbar> | ||||||
|         <span class="visually-hidden">{{ seconds | number: '1.0-0' }} seconds</span> |         <span class="visually-hidden">{{ seconds | number: '1.0-0' }} seconds</span> | ||||||
|       </button> |       </button> | ||||||
|  |       <button *ngIf="alternativeBtnCaption" type="button" class="btn" [class]="alternativeBtnClass" (click)="alternative()" [disabled]="!alternativeButtonEnabled || !buttonsEnabled"> | ||||||
|  |         {{alternativeBtnCaption}} | ||||||
|  |       </button> | ||||||
|     </div> |     </div> | ||||||
|   | |||||||
| @@ -13,6 +13,9 @@ export class ConfirmDialogComponent { | |||||||
|   @Output() |   @Output() | ||||||
|   public confirmClicked = new EventEmitter() |   public confirmClicked = new EventEmitter() | ||||||
|  |  | ||||||
|  |   @Output() | ||||||
|  |   public alternativeClicked = new EventEmitter() | ||||||
|  |  | ||||||
|   @Input() |   @Input() | ||||||
|   title = $localize`Confirmation` |   title = $localize`Confirmation` | ||||||
|  |  | ||||||
| @@ -28,14 +31,22 @@ export class ConfirmDialogComponent { | |||||||
|   @Input() |   @Input() | ||||||
|   btnCaption = $localize`Confirm` |   btnCaption = $localize`Confirm` | ||||||
|  |  | ||||||
|  |   @Input() | ||||||
|  |   alternativeBtnClass = 'btn-secondary' | ||||||
|  |  | ||||||
|  |   @Input() | ||||||
|  |   alternativeBtnCaption | ||||||
|  |  | ||||||
|   @Input() |   @Input() | ||||||
|   buttonsEnabled = true |   buttonsEnabled = true | ||||||
|  |  | ||||||
|   confirmButtonEnabled = true |   confirmButtonEnabled = true | ||||||
|  |   alternativeButtonEnabled = true | ||||||
|   seconds = 0 |   seconds = 0 | ||||||
|   secondsTotal = 0 |   secondsTotal = 0 | ||||||
|  |  | ||||||
|   confirmSubject: Subject<boolean> |   confirmSubject: Subject<boolean> | ||||||
|  |   alternativeSubject: Subject<boolean> | ||||||
|  |  | ||||||
|   delayConfirm(seconds: number) { |   delayConfirm(seconds: number) { | ||||||
|     const refreshInterval = 0.15 // s |     const refreshInterval = 0.15 // s | ||||||
| @@ -68,4 +79,10 @@ export class ConfirmDialogComponent { | |||||||
|     this.confirmSubject?.next(true) |     this.confirmSubject?.next(true) | ||||||
|     this.confirmSubject?.complete() |     this.confirmSubject?.complete() | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   alternative() { | ||||||
|  |     this.alternativeClicked.emit() | ||||||
|  |     this.alternativeSubject?.next(true) | ||||||
|  |     this.alternativeSubject?.complete() | ||||||
|  |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -60,14 +60,19 @@ | |||||||
|   </div> |   </div> | ||||||
|  |  | ||||||
|   <div class="btn-group ms-2 flex-fill" ngbDropdown role="group"> |   <div class="btn-group ms-2 flex-fill" ngbDropdown role="group"> | ||||||
|     <button class="btn btn-sm btn-outline-primary dropdown-toggle flex-fill" tourAnchor="tour.documents-views" ngbDropdownToggle i18n>Views</button> |     <button class="btn btn-sm btn-outline-primary dropdown-toggle flex-fill" tourAnchor="tour.documents-views" ngbDropdownToggle> | ||||||
|  |       <ng-container i18n>Views</ng-container> | ||||||
|  |       <div *ngIf="savedViewIsModified" class="position-absolute top-0 start-100 p-2 translate-middle badge bg-secondary border border-light rounded-circle"> | ||||||
|  |         <span class="visually-hidden">selected</span> | ||||||
|  |       </div> | ||||||
|  |     </button> | ||||||
|     <div class="dropdown-menu shadow dropdown-menu-right" ngbDropdownMenu> |     <div class="dropdown-menu shadow dropdown-menu-right" ngbDropdownMenu> | ||||||
|       <ng-container *ngIf="!list.activeSavedViewId"> |       <ng-container *ngIf="!list.activeSavedViewId"> | ||||||
|         <button ngbDropdownItem *ngFor="let view of savedViewService.allViews" (click)="loadViewConfig(view.id)">{{view.name}}</button> |         <button ngbDropdownItem *ngFor="let view of savedViewService.allViews" (click)="loadViewConfig(view.id)">{{view.name}}</button> | ||||||
|         <div class="dropdown-divider" *ngIf="savedViewService.allViews.length > 0"></div> |         <div class="dropdown-divider" *ngIf="savedViewService.allViews.length > 0"></div> | ||||||
|       </ng-container> |       </ng-container> | ||||||
|  |  | ||||||
|       <button ngbDropdownItem (click)="saveViewConfig()" *ngIf="list.activeSavedViewId" i18n>Save "{{list.activeSavedViewTitle}}"</button> |       <button ngbDropdownItem (click)="saveViewConfig()" *ngIf="list.activeSavedViewId" [disabled]="!savedViewIsModified" i18n>Save "{{list.activeSavedViewTitle}}"</button> | ||||||
|       <button ngbDropdownItem (click)="saveViewConfigAs()" i18n>Save as...</button> |       <button ngbDropdownItem (click)="saveViewConfigAs()" i18n>Save as...</button> | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
|   | |||||||
| @@ -9,7 +9,11 @@ import { | |||||||
| import { ActivatedRoute, convertToParamMap, Router } from '@angular/router' | import { ActivatedRoute, convertToParamMap, Router } from '@angular/router' | ||||||
| import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | ||||||
| import { filter, first, map, Subject, switchMap, takeUntil } from 'rxjs' | import { filter, first, map, Subject, switchMap, takeUntil } from 'rxjs' | ||||||
| import { FilterRule, isFullTextFilterRule } from 'src/app/data/filter-rule' | import { | ||||||
|  |   FilterRule, | ||||||
|  |   filterRulesDiffer, | ||||||
|  |   isFullTextFilterRule, | ||||||
|  | } from 'src/app/data/filter-rule' | ||||||
| import { FILTER_FULLTEXT_MORELIKE } from 'src/app/data/filter-rule-type' | import { FILTER_FULLTEXT_MORELIKE } from 'src/app/data/filter-rule-type' | ||||||
| import { PaperlessDocument } from 'src/app/data/paperless-document' | import { PaperlessDocument } from 'src/app/data/paperless-document' | ||||||
| import { PaperlessSavedView } from 'src/app/data/paperless-saved-view' | import { PaperlessSavedView } from 'src/app/data/paperless-saved-view' | ||||||
| @@ -54,15 +58,36 @@ export class DocumentListComponent implements OnInit, OnDestroy { | |||||||
|   displayMode = 'smallCards' // largeCards, smallCards, details |   displayMode = 'smallCards' // largeCards, smallCards, details | ||||||
|  |  | ||||||
|   unmodifiedFilterRules: FilterRule[] = [] |   unmodifiedFilterRules: FilterRule[] = [] | ||||||
|  |   private unmodifiedSavedView: PaperlessSavedView | ||||||
|  |  | ||||||
|   private unsubscribeNotifier: Subject<any> = new Subject() |   private unsubscribeNotifier: Subject<any> = new Subject() | ||||||
|  |  | ||||||
|  |   get savedViewIsModified(): boolean { | ||||||
|  |     if (!this.list.activeSavedViewId || !this.unmodifiedSavedView) return false | ||||||
|  |     else { | ||||||
|  |       return ( | ||||||
|  |         this.unmodifiedSavedView.sort_field !== this.list.sortField || | ||||||
|  |         this.unmodifiedSavedView.sort_reverse !== this.list.sortReverse || | ||||||
|  |         filterRulesDiffer( | ||||||
|  |           this.unmodifiedSavedView.filter_rules, | ||||||
|  |           this.list.filterRules | ||||||
|  |         ) | ||||||
|  |       ) | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|   get isFiltered() { |   get isFiltered() { | ||||||
|     return this.list.filterRules?.length > 0 |     return this.list.filterRules?.length > 0 | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   getTitle() { |   getTitle() { | ||||||
|     return this.list.activeSavedViewTitle || $localize`Documents` |     let title = this.list.activeSavedViewTitle | ||||||
|  |     if (title && this.savedViewIsModified) { | ||||||
|  |       title += '*' | ||||||
|  |     } else if (!title) { | ||||||
|  |       title = $localize`Documents` | ||||||
|  |     } | ||||||
|  |     return title | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   getSortFields() { |   getSortFields() { | ||||||
| @@ -122,7 +147,7 @@ export class DocumentListComponent implements OnInit, OnDestroy { | |||||||
|           this.router.navigate(['404']) |           this.router.navigate(['404']) | ||||||
|           return |           return | ||||||
|         } |         } | ||||||
|  |         this.unmodifiedSavedView = view | ||||||
|         this.list.activateSavedViewWithQueryParams( |         this.list.activateSavedViewWithQueryParams( | ||||||
|           view, |           view, | ||||||
|           convertToParamMap(this.route.snapshot.queryParams) |           convertToParamMap(this.route.snapshot.queryParams) | ||||||
| @@ -165,7 +190,8 @@ export class DocumentListComponent implements OnInit, OnDestroy { | |||||||
|       this.savedViewService |       this.savedViewService | ||||||
|         .patch(savedView) |         .patch(savedView) | ||||||
|         .pipe(first()) |         .pipe(first()) | ||||||
|         .subscribe((result) => { |         .subscribe((view) => { | ||||||
|  |           this.unmodifiedSavedView = view | ||||||
|           this.toastService.showInfo( |           this.toastService.showInfo( | ||||||
|             $localize`View "${this.list.activeSavedViewTitle}" saved successfully.` |             $localize`View "${this.list.activeSavedViewTitle}" saved successfully.` | ||||||
|           ) |           ) | ||||||
| @@ -179,6 +205,7 @@ export class DocumentListComponent implements OnInit, OnDestroy { | |||||||
|       .getCached(viewID) |       .getCached(viewID) | ||||||
|       .pipe(first()) |       .pipe(first()) | ||||||
|       .subscribe((view) => { |       .subscribe((view) => { | ||||||
|  |         this.unmodifiedSavedView = view | ||||||
|         this.list.activateSavedView(view) |         this.list.activateSavedView(view) | ||||||
|         this.list.reload() |         this.list.reload() | ||||||
|       }) |       }) | ||||||
|   | |||||||
							
								
								
									
										51
									
								
								src-ui/src/app/guards/dirty-saved-view.guard.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								src-ui/src/app/guards/dirty-saved-view.guard.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,51 @@ | |||||||
|  | import { CanDeactivate } from '@angular/router' | ||||||
|  | import { Injectable } from '@angular/core' | ||||||
|  | import { first, Observable, Subject } from 'rxjs' | ||||||
|  | import { DocumentListComponent } from '../components/document-list/document-list.component' | ||||||
|  | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | ||||||
|  | import { ConfirmDialogComponent } from '../components/common/confirm-dialog/confirm-dialog.component' | ||||||
|  |  | ||||||
|  | @Injectable() | ||||||
|  | export class DirtySavedViewGuard | ||||||
|  |   implements CanDeactivate<DocumentListComponent> | ||||||
|  | { | ||||||
|  |   constructor(private modalService: NgbModal) {} | ||||||
|  |  | ||||||
|  |   canDeactivate( | ||||||
|  |     component: DocumentListComponent | ||||||
|  |   ): boolean | Observable<boolean> { | ||||||
|  |     return component.savedViewIsModified ? this.warn(component) : true | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   warn(component: DocumentListComponent) { | ||||||
|  |     let modal = this.modalService.open(ConfirmDialogComponent, { | ||||||
|  |       backdrop: 'static', | ||||||
|  |     }) | ||||||
|  |     modal.componentInstance.title = $localize`Unsaved Changes` | ||||||
|  |     modal.componentInstance.messageBold = | ||||||
|  |       $localize`You have unsaved changes to the saved view` + | ||||||
|  |       ' "' + | ||||||
|  |       component.getTitle() | ||||||
|  |     ;('".') | ||||||
|  |     modal.componentInstance.message = $localize`Are you sure you want to close this saved view?` | ||||||
|  |     modal.componentInstance.btnClass = 'btn-secondary' | ||||||
|  |     modal.componentInstance.btnCaption = $localize`Close` | ||||||
|  |     modal.componentInstance.alternativeBtnClass = 'btn-primary' | ||||||
|  |     modal.componentInstance.alternativeBtnCaption = $localize`Save and close` | ||||||
|  |     modal.componentInstance.alternativeClicked.pipe(first()).subscribe(() => { | ||||||
|  |       modal.componentInstance.buttonsEnabled = false | ||||||
|  |       component.saveViewConfig() | ||||||
|  |       modal.close() | ||||||
|  |     }) | ||||||
|  |     modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => { | ||||||
|  |       modal.componentInstance.buttonsEnabled = false | ||||||
|  |       modal.close() | ||||||
|  |     }) | ||||||
|  |  | ||||||
|  |     const subject = new Subject<boolean>() | ||||||
|  |     modal.componentInstance.confirmSubject = subject | ||||||
|  |     modal.componentInstance.alternativeSubject = subject | ||||||
|  |  | ||||||
|  |     return subject | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -171,15 +171,15 @@ export class DocumentListViewService { | |||||||
|     this.reduceSelectionToFilter() |     this.reduceSelectionToFilter() | ||||||
|  |  | ||||||
|     if (!this.router.routerState.snapshot.url.includes('/view/')) { |     if (!this.router.routerState.snapshot.url.includes('/view/')) { | ||||||
|       this.router.navigate([], { |       this.router.navigate(['view', view.id]) | ||||||
|         queryParams: { view: view.id }, |  | ||||||
|       }) |  | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   loadFromQueryParams(queryParams: ParamMap) { |   loadFromQueryParams(queryParams: ParamMap) { | ||||||
|     const paramsEmpty: boolean = queryParams.keys.length == 0 |     const paramsEmpty: boolean = queryParams.keys.length == 0 | ||||||
|     let newState: ListViewState = this.listViewStates.get(null) |     let newState: ListViewState = this.listViewStates.get( | ||||||
|  |       this._activeSavedViewId | ||||||
|  |     ) | ||||||
|     if (!paramsEmpty) newState = paramsToViewState(queryParams) |     if (!paramsEmpty) newState = paramsToViewState(queryParams) | ||||||
|     if (newState == undefined) newState = this.defaultListViewState() // if nothing in local storage |     if (newState == undefined) newState = this.defaultListViewState() // if nothing in local storage | ||||||
|  |  | ||||||
| @@ -276,7 +276,6 @@ export class DocumentListViewService { | |||||||
|     ) { |     ) { | ||||||
|       this.activeListViewState.sortField = 'created' |       this.activeListViewState.sortField = 'created' | ||||||
|     } |     } | ||||||
|     this._activeSavedViewId = null |  | ||||||
|     this.activeListViewState.filterRules = filterRules |     this.activeListViewState.filterRules = filterRules | ||||||
|     this.reload() |     this.reload() | ||||||
|     this.reduceSelectionToFilter() |     this.reduceSelectionToFilter() | ||||||
| @@ -288,7 +287,6 @@ export class DocumentListViewService { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   set sortField(field: string) { |   set sortField(field: string) { | ||||||
|     this._activeSavedViewId = null |  | ||||||
|     this.activeListViewState.sortField = field |     this.activeListViewState.sortField = field | ||||||
|     this.reload() |     this.reload() | ||||||
|     this.saveDocumentListView() |     this.saveDocumentListView() | ||||||
| @@ -299,7 +297,6 @@ export class DocumentListViewService { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   set sortReverse(reverse: boolean) { |   set sortReverse(reverse: boolean) { | ||||||
|     this._activeSavedViewId = null |  | ||||||
|     this.activeListViewState.sortReverse = reverse |     this.activeListViewState.sortReverse = reverse | ||||||
|     this.reload() |     this.reload() | ||||||
|     this.saveDocumentListView() |     this.saveDocumentListView() | ||||||
|   | |||||||
| @@ -397,6 +397,10 @@ textarea, | |||||||
|       background-color: var(--bs-primary); |       background-color: var(--bs-primary); | ||||||
|       color: var(--pngx-primary-text-contrast); |       color: var(--pngx-primary-text-contrast); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     &.disabled, &:disabled { | ||||||
|  |       opacity: 50%; | ||||||
|  |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 shamoon
					shamoon