mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Merge pull request #540 from paperless-ngx/filtering-query-params
Feature: Filtering query params aka browser navigation for filtering
This commit is contained in:
		| @@ -62,7 +62,7 @@ | |||||||
|             </a> |             </a> | ||||||
|           </li> |           </li> | ||||||
|           <li class="nav-item"> |           <li class="nav-item"> | ||||||
|             <a class="nav-link" routerLink="documents" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" (click)="closeMenu()"> |             <a class="nav-link" routerLink="documents" routerLinkActive="active" (click)="closeMenu()"> | ||||||
|               <svg class="sidebaricon" fill="currentColor"> |               <svg class="sidebaricon" fill="currentColor"> | ||||||
|                 <use xlink:href="assets/bootstrap-icons.svg#files"/> |                 <use xlink:href="assets/bootstrap-icons.svg#files"/> | ||||||
|               </svg> <ng-container i18n>Documents</ng-container> |               </svg> <ng-container i18n>Documents</ng-container> | ||||||
|   | |||||||
| @@ -81,7 +81,10 @@ export class AppFrameComponent { | |||||||
|   search() { |   search() { | ||||||
|     this.closeMenu() |     this.closeMenu() | ||||||
|     this.list.quickFilter([ |     this.list.quickFilter([ | ||||||
|       { rule_type: FILTER_FULLTEXT_QUERY, value: this.searchField.value }, |       { | ||||||
|  |         rule_type: FILTER_FULLTEXT_QUERY, | ||||||
|  |         value: (this.searchField.value as string).trim(), | ||||||
|  |       }, | ||||||
|     ]) |     ]) | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,2 +1,2 @@ | |||||||
| <span *ngIf="!clickable" class="badge" [style.background]="tag.color" [style.color]="tag.text_color">{{tag.name}}</span> | <span *ngIf="!clickable" class="badge" [style.background]="tag.color" [style.color]="tag.text_color">{{tag.name}}</span> | ||||||
| <a [routerLink]="[]" [title]="linkTitle" *ngIf="clickable" class="badge" [style.background]="tag.color" [style.color]="tag.text_color">{{tag.name}}</a> | <a [title]="linkTitle" *ngIf="clickable" class="badge" [style.background]="tag.color" [style.color]="tag.text_color">{{tag.name}}</a> | ||||||
|   | |||||||
| @@ -0,0 +1,3 @@ | |||||||
|  | a { | ||||||
|  |     cursor: pointer; | ||||||
|  | } | ||||||
|   | |||||||
| @@ -17,7 +17,7 @@ | |||||||
|         <div class="d-flex justify-content-between align-items-center"> |         <div class="d-flex justify-content-between align-items-center"> | ||||||
|           <h5 class="card-title"> |           <h5 class="card-title"> | ||||||
|             <ng-container *ngIf="document.correspondent"> |             <ng-container *ngIf="document.correspondent"> | ||||||
|               <a *ngIf="clickCorrespondent.observers.length ; else nolink" [routerLink]="[]" title="Filter by correspondent" i18n-title (click)="clickCorrespondent.emit(document.correspondent);$event.stopPropagation()" class="fw-bold btn-link">{{(document.correspondent$ | async)?.name}}</a> |               <a *ngIf="clickCorrespondent.observers.length ; else nolink" title="Filter by correspondent" i18n-title (click)="clickCorrespondent.emit(document.correspondent);$event.stopPropagation()" class="fw-bold btn-link">{{(document.correspondent$ | async)?.name}}</a> | ||||||
|               <ng-template #nolink>{{(document.correspondent$ | async)?.name}}</ng-template>: |               <ng-template #nolink>{{(document.correspondent$ | async)?.name}}</ng-template>: | ||||||
|             </ng-container> |             </ng-container> | ||||||
|             {{document.title | documentTitle}} |             {{document.title | documentTitle}} | ||||||
|   | |||||||
| @@ -90,3 +90,7 @@ span ::ng-deep .match { | |||||||
|   color: black; |   color: black; | ||||||
|   background-color: rgb(255, 211, 66); |   background-color: rgb(255, 211, 66); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | a { | ||||||
|  |   cursor: pointer; | ||||||
|  | } | ||||||
|   | |||||||
| @@ -23,7 +23,7 @@ | |||||||
|     <div class="card-body p-2"> |     <div class="card-body p-2"> | ||||||
|       <p class="card-text"> |       <p class="card-text"> | ||||||
|         <ng-container *ngIf="document.correspondent"> |         <ng-container *ngIf="document.correspondent"> | ||||||
|           <a [routerLink]="[]" title="Filter by correspondent" i18n-title (click)="clickCorrespondent.emit(document.correspondent);$event.stopPropagation()" class="fw-bold btn-link">{{(document.correspondent$ | async)?.name}}</a>: |           <a title="Filter by correspondent" i18n-title (click)="clickCorrespondent.emit(document.correspondent);$event.stopPropagation()" class="fw-bold btn-link">{{(document.correspondent$ | async)?.name}}</a>: | ||||||
|         </ng-container> |         </ng-container> | ||||||
|         {{document.title | documentTitle}} |         {{document.title | documentTitle}} | ||||||
|       </p> |       </p> | ||||||
|   | |||||||
| @@ -76,3 +76,7 @@ | |||||||
|   text-align: left !important; |   text-align: left !important; | ||||||
|   font-size: 90%; |   font-size: 90%; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | a { | ||||||
|  |   cursor: pointer; | ||||||
|  | } | ||||||
|   | |||||||
| @@ -163,7 +163,7 @@ | |||||||
|         </td> |         </td> | ||||||
|         <td class="d-none d-md-table-cell"> |         <td class="d-none d-md-table-cell"> | ||||||
|           <ng-container *ngIf="d.correspondent"> |           <ng-container *ngIf="d.correspondent"> | ||||||
|             <a [routerLink]="[]" (click)="clickCorrespondent(d.correspondent);$event.stopPropagation()" title="Filter by correspondent">{{(d.correspondent$ | async)?.name}}</a> |             <a (click)="clickCorrespondent(d.correspondent);$event.stopPropagation()" title="Filter by correspondent">{{(d.correspondent$ | async)?.name}}</a> | ||||||
|           </ng-container> |           </ng-container> | ||||||
|         </td> |         </td> | ||||||
|         <td> |         <td> | ||||||
| @@ -172,7 +172,7 @@ | |||||||
|         </td> |         </td> | ||||||
|         <td class="d-none d-xl-table-cell"> |         <td class="d-none d-xl-table-cell"> | ||||||
|           <ng-container *ngIf="d.document_type"> |           <ng-container *ngIf="d.document_type"> | ||||||
|             <a [routerLink]="[]" (click)="clickDocumentType(d.document_type);$event.stopPropagation()" title="Filter by document type">{{(d.document_type$ | async)?.name}}</a> |             <a (click)="clickDocumentType(d.document_type);$event.stopPropagation()" title="Filter by document type">{{(d.document_type$ | async)?.name}}</a> | ||||||
|           </ng-container> |           </ng-container> | ||||||
|         </td> |         </td> | ||||||
|         <td> |         <td> | ||||||
|   | |||||||
| @@ -53,3 +53,7 @@ $paperless-card-breakpoints: ( | |||||||
|     margin-left: 0; |     margin-left: 0; | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | a { | ||||||
|  |   cursor: pointer; | ||||||
|  | } | ||||||
|   | |||||||
| @@ -1,4 +1,5 @@ | |||||||
| import { | import { | ||||||
|  |   AfterViewInit, | ||||||
|   Component, |   Component, | ||||||
|   OnDestroy, |   OnDestroy, | ||||||
|   OnInit, |   OnInit, | ||||||
| @@ -8,9 +9,20 @@ import { | |||||||
| } from '@angular/core' | } from '@angular/core' | ||||||
| import { ActivatedRoute, Router } from '@angular/router' | import { ActivatedRoute, Router } from '@angular/router' | ||||||
| import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | ||||||
| import { Subscription } from 'rxjs' | import { | ||||||
|  |   filter, | ||||||
|  |   first, | ||||||
|  |   map, | ||||||
|  |   Subject, | ||||||
|  |   Subscription, | ||||||
|  |   switchMap, | ||||||
|  |   takeUntil, | ||||||
|  | } from 'rxjs' | ||||||
| import { FilterRule, isFullTextFilterRule } from 'src/app/data/filter-rule' | import { FilterRule, isFullTextFilterRule } from 'src/app/data/filter-rule' | ||||||
| import { FILTER_FULLTEXT_MORELIKE } from 'src/app/data/filter-rule-type' | import { | ||||||
|  |   FILTER_FULLTEXT_MORELIKE, | ||||||
|  |   FILTER_RULE_TYPES, | ||||||
|  | } 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' | ||||||
| import { | import { | ||||||
| @@ -20,6 +32,7 @@ import { | |||||||
| import { ConsumerStatusService } from 'src/app/services/consumer-status.service' | import { ConsumerStatusService } from 'src/app/services/consumer-status.service' | ||||||
| import { DocumentListViewService } from 'src/app/services/document-list-view.service' | import { DocumentListViewService } from 'src/app/services/document-list-view.service' | ||||||
| import { | import { | ||||||
|  |   DocumentService, | ||||||
|   DOCUMENT_SORT_FIELDS, |   DOCUMENT_SORT_FIELDS, | ||||||
|   DOCUMENT_SORT_FIELDS_FULLTEXT, |   DOCUMENT_SORT_FIELDS_FULLTEXT, | ||||||
| } from 'src/app/services/rest/document.service' | } from 'src/app/services/rest/document.service' | ||||||
| @@ -33,9 +46,10 @@ import { SaveViewConfigDialogComponent } from './save-view-config-dialog/save-vi | |||||||
|   templateUrl: './document-list.component.html', |   templateUrl: './document-list.component.html', | ||||||
|   styleUrls: ['./document-list.component.scss'], |   styleUrls: ['./document-list.component.scss'], | ||||||
| }) | }) | ||||||
| export class DocumentListComponent implements OnInit, OnDestroy { | export class DocumentListComponent implements OnInit, OnDestroy, AfterViewInit { | ||||||
|   constructor( |   constructor( | ||||||
|     public list: DocumentListViewService, |     public list: DocumentListViewService, | ||||||
|  |     private documentService: DocumentService, | ||||||
|     public savedViewService: SavedViewService, |     public savedViewService: SavedViewService, | ||||||
|     public route: ActivatedRoute, |     public route: ActivatedRoute, | ||||||
|     private router: Router, |     private router: Router, | ||||||
| @@ -53,7 +67,7 @@ export class DocumentListComponent implements OnInit, OnDestroy { | |||||||
|  |  | ||||||
|   unmodifiedFilterRules: FilterRule[] = [] |   unmodifiedFilterRules: FilterRule[] = [] | ||||||
|  |  | ||||||
|   private consumptionFinishedSubscription: Subscription |   private unsubscribeNotifier: Subject<any> = new Subject() | ||||||
|  |  | ||||||
|   get isFiltered() { |   get isFiltered() { | ||||||
|     return this.list.filterRules?.length > 0 |     return this.list.filterRules?.length > 0 | ||||||
| @@ -85,34 +99,97 @@ export class DocumentListComponent implements OnInit, OnDestroy { | |||||||
|     if (localStorage.getItem('document-list:displayMode') != null) { |     if (localStorage.getItem('document-list:displayMode') != null) { | ||||||
|       this.displayMode = localStorage.getItem('document-list:displayMode') |       this.displayMode = localStorage.getItem('document-list:displayMode') | ||||||
|     } |     } | ||||||
|     this.consumptionFinishedSubscription = this.consumerStatusService |  | ||||||
|  |     this.consumerStatusService | ||||||
|       .onDocumentConsumptionFinished() |       .onDocumentConsumptionFinished() | ||||||
|  |       .pipe(takeUntil(this.unsubscribeNotifier)) | ||||||
|       .subscribe(() => { |       .subscribe(() => { | ||||||
|         this.list.reload() |         this.list.reload() | ||||||
|       }) |       }) | ||||||
|     this.route.paramMap.subscribe((params) => { |  | ||||||
|       if (params.has('id')) { |     this.route.paramMap | ||||||
|         this.savedViewService.getCached(+params.get('id')).subscribe((view) => { |       .pipe( | ||||||
|           if (!view) { |         filter((params) => params.has('id')), // only on saved view | ||||||
|             this.router.navigate(['404']) |         switchMap((params) => { | ||||||
|             return |           return this.savedViewService | ||||||
|           } |             .getCached(+params.get('id')) | ||||||
|           this.list.activateSavedView(view) |             .pipe(map((view) => ({ params, view }))) | ||||||
|           this.list.reload() |  | ||||||
|           this.unmodifiedFilterRules = view.filter_rules |  | ||||||
|         }) |         }) | ||||||
|       } else { |       ) | ||||||
|  |       .pipe(takeUntil(this.unsubscribeNotifier)) | ||||||
|  |       .subscribe(({ view, params }) => { | ||||||
|  |         if (!view) { | ||||||
|  |           this.router.navigate(['404']) | ||||||
|  |           return | ||||||
|  |         } | ||||||
|  |         this.list.activateSavedView(view) | ||||||
|  |         this.list.reload() | ||||||
|  |         this.unmodifiedFilterRules = view.filter_rules | ||||||
|  |       }) | ||||||
|  |  | ||||||
|  |     const allFilterRuleQueryParams: string[] = FILTER_RULE_TYPES.map( | ||||||
|  |       (rt) => rt.filtervar | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     this.route.queryParamMap | ||||||
|  |       .pipe( | ||||||
|  |         filter(() => !this.route.snapshot.paramMap.has('id')), // only when not on saved view | ||||||
|  |         takeUntil(this.unsubscribeNotifier) | ||||||
|  |       ) | ||||||
|  |       .subscribe((queryParams) => { | ||||||
|  |         // transform query params to filter rules | ||||||
|  |         let filterRulesFromQueryParams: FilterRule[] = [] | ||||||
|  |         allFilterRuleQueryParams | ||||||
|  |           .filter((frqp) => queryParams.has(frqp)) | ||||||
|  |           .forEach((filterQueryParamName) => { | ||||||
|  |             const filterQueryParamValues: string[] = queryParams | ||||||
|  |               .get(filterQueryParamName) | ||||||
|  |               .split(',') | ||||||
|  |  | ||||||
|  |             filterRulesFromQueryParams = filterRulesFromQueryParams.concat( | ||||||
|  |               // map all values to filter rules | ||||||
|  |               filterQueryParamValues.map((val) => { | ||||||
|  |                 return { | ||||||
|  |                   rule_type: FILTER_RULE_TYPES.find( | ||||||
|  |                     (rt) => rt.filtervar == filterQueryParamName | ||||||
|  |                   ).id, | ||||||
|  |                   value: val, | ||||||
|  |                 } | ||||||
|  |               }) | ||||||
|  |             ) | ||||||
|  |           }) | ||||||
|  |  | ||||||
|         this.list.activateSavedView(null) |         this.list.activateSavedView(null) | ||||||
|  |         this.list.filterRules = filterRulesFromQueryParams | ||||||
|         this.list.reload() |         this.list.reload() | ||||||
|         this.unmodifiedFilterRules = [] |         this.unmodifiedFilterRules = [] | ||||||
|       } |       }) | ||||||
|     }) |   } | ||||||
|  |  | ||||||
|  |   ngAfterViewInit(): void { | ||||||
|  |     this.filterEditor.filterRulesChange | ||||||
|  |       .pipe(takeUntil(this.unsubscribeNotifier)) | ||||||
|  |       .subscribe({ | ||||||
|  |         next: (filterRules) => { | ||||||
|  |           const params = | ||||||
|  |             this.documentService.filterRulesToQueryParams(filterRules) | ||||||
|  |  | ||||||
|  |           // if we were on a saved view we navigate 'away' to /documents | ||||||
|  |           let base = [] | ||||||
|  |           if (this.route.snapshot.paramMap.has('id')) base = ['/documents'] | ||||||
|  |  | ||||||
|  |           this.router.navigate(base, { | ||||||
|  |             relativeTo: this.route, | ||||||
|  |             queryParams: params, | ||||||
|  |           }) | ||||||
|  |         }, | ||||||
|  |       }) | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   ngOnDestroy() { |   ngOnDestroy() { | ||||||
|     if (this.consumptionFinishedSubscription) { |     // unsubscribes all | ||||||
|       this.consumptionFinishedSubscription.unsubscribe() |     this.unsubscribeNotifier.next(this) | ||||||
|     } |     this.unsubscribeNotifier.complete() | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   loadViewConfig(view: PaperlessSavedView) { |   loadViewConfig(view: PaperlessSavedView) { | ||||||
| @@ -128,12 +205,15 @@ export class DocumentListComponent implements OnInit, OnDestroy { | |||||||
|         sort_field: this.list.sortField, |         sort_field: this.list.sortField, | ||||||
|         sort_reverse: this.list.sortReverse, |         sort_reverse: this.list.sortReverse, | ||||||
|       } |       } | ||||||
|       this.savedViewService.patch(savedView).subscribe((result) => { |       this.savedViewService | ||||||
|         this.toastService.showInfo( |         .patch(savedView) | ||||||
|           $localize`View "${this.list.activeSavedViewTitle}" saved successfully.` |         .pipe(first()) | ||||||
|         ) |         .subscribe((result) => { | ||||||
|         this.unmodifiedFilterRules = this.list.filterRules |           this.toastService.showInfo( | ||||||
|       }) |             $localize`View "${this.list.activeSavedViewTitle}" saved successfully.` | ||||||
|  |           ) | ||||||
|  |           this.unmodifiedFilterRules = this.list.filterRules | ||||||
|  |         }) | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -142,7 +222,7 @@ export class DocumentListComponent implements OnInit, OnDestroy { | |||||||
|       backdrop: 'static', |       backdrop: 'static', | ||||||
|     }) |     }) | ||||||
|     modal.componentInstance.defaultName = this.filterEditor.generateFilterName() |     modal.componentInstance.defaultName = this.filterEditor.generateFilterName() | ||||||
|     modal.componentInstance.saveClicked.subscribe((formValue) => { |     modal.componentInstance.saveClicked.pipe(first()).subscribe((formValue) => { | ||||||
|       modal.componentInstance.buttonsEnabled = false |       modal.componentInstance.buttonsEnabled = false | ||||||
|       let savedView: PaperlessSavedView = { |       let savedView: PaperlessSavedView = { | ||||||
|         name: formValue.name, |         name: formValue.name, | ||||||
| @@ -153,18 +233,21 @@ export class DocumentListComponent implements OnInit, OnDestroy { | |||||||
|         sort_field: this.list.sortField, |         sort_field: this.list.sortField, | ||||||
|       } |       } | ||||||
|  |  | ||||||
|       this.savedViewService.create(savedView).subscribe( |       this.savedViewService | ||||||
|         () => { |         .create(savedView) | ||||||
|           modal.close() |         .pipe(first()) | ||||||
|           this.toastService.showInfo( |         .subscribe({ | ||||||
|             $localize`View "${savedView.name}" created successfully.` |           next: () => { | ||||||
|           ) |             modal.close() | ||||||
|         }, |             this.toastService.showInfo( | ||||||
|         (error) => { |               $localize`View "${savedView.name}" created successfully.` | ||||||
|           modal.componentInstance.error = error.error |             ) | ||||||
|           modal.componentInstance.buttonsEnabled = true |           }, | ||||||
|         } |           error: (error) => { | ||||||
|       ) |             modal.componentInstance.error = error.error | ||||||
|  |             modal.componentInstance.buttonsEnabled = true | ||||||
|  |           }, | ||||||
|  |         }) | ||||||
|     }) |     }) | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -143,8 +143,8 @@ export class DocumentListViewService { | |||||||
|         activeListViewState.sortReverse, |         activeListViewState.sortReverse, | ||||||
|         activeListViewState.filterRules |         activeListViewState.filterRules | ||||||
|       ) |       ) | ||||||
|       .subscribe( |       .subscribe({ | ||||||
|         (result) => { |         next: (result) => { | ||||||
|           this.isReloading = false |           this.isReloading = false | ||||||
|           activeListViewState.collectionSize = result.count |           activeListViewState.collectionSize = result.count | ||||||
|           activeListViewState.documents = result.results |           activeListViewState.documents = result.results | ||||||
| @@ -153,7 +153,7 @@ export class DocumentListViewService { | |||||||
|           } |           } | ||||||
|           this.rangeSelectionAnchorIndex = this.lastRangeSelectionToIndex = null |           this.rangeSelectionAnchorIndex = this.lastRangeSelectionToIndex = null | ||||||
|         }, |         }, | ||||||
|         (error) => { |         error: (error) => { | ||||||
|           this.isReloading = false |           this.isReloading = false | ||||||
|           if (activeListViewState.currentPage != 1 && error.status == 404) { |           if (activeListViewState.currentPage != 1 && error.status == 404) { | ||||||
|             // this happens when applying a filter: the current page might not be available anymore due to the reduced result set. |             // this happens when applying a filter: the current page might not be available anymore due to the reduced result set. | ||||||
| @@ -162,8 +162,8 @@ export class DocumentListViewService { | |||||||
|           } else { |           } else { | ||||||
|             this.error = error.error |             this.error = error.error | ||||||
|           } |           } | ||||||
|         } |         }, | ||||||
|       ) |       }) | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   set filterRules(filterRules: FilterRule[]) { |   set filterRules(filterRules: FilterRule[]) { | ||||||
| @@ -249,20 +249,11 @@ export class DocumentListViewService { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   quickFilter(filterRules: FilterRule[]) { |   quickFilter(filterRules: FilterRule[]) { | ||||||
|     this._activeSavedViewId = null |     const params = this.documentService.filterRulesToQueryParams(filterRules) | ||||||
|     this.activeListViewState.filterRules = filterRules |     this.router.navigate(['/documents'], { | ||||||
|     this.activeListViewState.currentPage = 1 |       relativeTo: this.route, | ||||||
|     if (isFullTextFilterRule(filterRules)) { |       queryParams: params, | ||||||
|       this.activeListViewState.sortField = 'score' |     }) | ||||||
|       this.activeListViewState.sortReverse = false |  | ||||||
|     } |  | ||||||
|     this.reduceSelectionToFilter() |  | ||||||
|     this.saveDocumentListView() |  | ||||||
|     if (this.router.url == '/documents') { |  | ||||||
|       this.reload() |  | ||||||
|     } else { |  | ||||||
|       this.router.navigate(['documents']) |  | ||||||
|     } |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   getLastPage(): number { |   getLastPage(): number { | ||||||
|   | |||||||
| @@ -57,7 +57,7 @@ export class DocumentService extends AbstractPaperlessService<PaperlessDocument> | |||||||
|     super(http, 'documents') |     super(http, 'documents') | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   private filterRulesToQueryParams(filterRules: FilterRule[]) { |   public filterRulesToQueryParams(filterRules: FilterRule[]): Object { | ||||||
|     if (filterRules) { |     if (filterRules) { | ||||||
|       let params = {} |       let params = {} | ||||||
|       for (let rule of filterRules) { |       for (let rule of filterRules) { | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 shamoon
					shamoon