mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Merge pull request #10 from shamoon/feature/any-all-filtering
Feature: any / all filtering with tags
This commit is contained in:
		| @@ -12,6 +12,16 @@ | |||||||
|   </button> |   </button> | ||||||
|   <div class="dropdown-menu py-0 shadow" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}"> |   <div class="dropdown-menu py-0 shadow" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}"> | ||||||
|     <div class="list-group list-group-flush"> |     <div class="list-group list-group-flush"> | ||||||
|  |       <div *ngIf="!editing && multiple" class="list-group-item d-flex"> | ||||||
|  |         <div class="btn-group btn-group-xs btn-group-toggle flex-fill" ngbRadioGroup [(ngModel)]="selectionModel.logicalOperator" (change)="selectionModel.toggleOperator()" [disabled]="!operatorToggleEnabled"> | ||||||
|  |           <label ngbButtonLabel class="btn btn-outline-primary"> | ||||||
|  |             <input ngbButton type="radio" name="logicalOperator" value="and"> All | ||||||
|  |           </label> | ||||||
|  |           <label ngbButtonLabel class="btn btn-outline-primary"> | ||||||
|  |             <input ngbButton type="radio" name="logicalOperator" value="or"> Any | ||||||
|  |           </label> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|       <div class="list-group-item"> |       <div class="list-group-item"> | ||||||
|         <div class="input-group input-group-sm"> |         <div class="input-group input-group-sm"> | ||||||
|           <input class="form-control" type="text" [(ngModel)]="filterText" [placeholder]="filterPlaceholder" (keyup.enter)="listFilterEnter()" #listFilterTextInput> |           <input class="form-control" type="text" [(ngModel)]="filterText" [placeholder]="filterPlaceholder" (keyup.enter)="listFilterEnter()" #listFilterTextInput> | ||||||
| @@ -19,15 +29,18 @@ | |||||||
|       </div> |       </div> | ||||||
|       <div *ngIf="selectionModel.items" class="items"> |       <div *ngIf="selectionModel.items" class="items"> | ||||||
|         <ng-container *ngFor="let item of selectionModel.itemsSorted | filter: filterText"> |         <ng-container *ngFor="let item of selectionModel.itemsSorted | filter: filterText"> | ||||||
|           <app-toggleable-dropdown-button *ngIf="allowSelectNone || item.id" [item]="item" [state]="selectionModel.get(item.id)" (toggle)="selectionModel.toggle(item.id)"></app-toggleable-dropdown-button> |           <app-toggleable-dropdown-button *ngIf="allowSelectNone || item.id" [item]="item" [state]="selectionModel.get(item.id)" (toggle)="selectionModel.toggle(item.id)" (exclude)="excludeClicked(item.id)"></app-toggleable-dropdown-button> | ||||||
|         </ng-container> |         </ng-container> | ||||||
|       </div> |       </div> | ||||||
|       <button *ngIf="editing" class="list-group-item list-group-item-action bg-light" (click)="applyClicked()" [disabled]="!selectionModel.isDirty()"> |       <button *ngIf="editing" class="list-group-item list-group-item-action bg-light" (click)="applyClicked()" [disabled]="!selectionModel.isDirty()"> | ||||||
|         <small class="ml-1" [ngClass]="{'font-weight-bold': selectionModel.isDirty()}" i18n>Apply</small> |         <small class="ml-2" [ngClass]="{'font-weight-bold': selectionModel.isDirty()}" i18n>Apply</small> | ||||||
|         <svg width="1.5em" height="1em" viewBox="0 0 16 16" fill="currentColor"> |         <svg width="1.5em" height="1em" viewBox="0 0 16 16" fill="currentColor"> | ||||||
|           <use xlink:href="assets/bootstrap-icons.svg#arrow-right" /> |           <use xlink:href="assets/bootstrap-icons.svg#arrow-right" /> | ||||||
|         </svg> |         </svg> | ||||||
|       </button> |       </button> | ||||||
|  |       <div *ngIf="!editing && multiple" class="list-group-item list-group-item-note pt-1 pb-2"> | ||||||
|  |         <small i18n>Click again to exclude items.</small> | ||||||
|  |       </div> | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
| </div> | </div> | ||||||
|   | |||||||
| @@ -1,3 +1,5 @@ | |||||||
|  | @import "/src/theme"; | ||||||
|  |  | ||||||
| .badge-corner { | .badge-corner { | ||||||
|   position: absolute; |   position: absolute; | ||||||
|   top: -8px; |   top: -8px; | ||||||
| @@ -12,3 +14,43 @@ | |||||||
|     overflow-y: scroll; |     overflow-y: scroll; | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .btn-group-xs { | ||||||
|  |   > .btn { | ||||||
|  |     padding: 0.2rem 0.25rem; | ||||||
|  |     font-size: 0.675rem; | ||||||
|  |     line-height: 1.2; | ||||||
|  |     border-radius: 0.15rem; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   > .btn:not(:first-child) { | ||||||
|  |     border-top-left-radius: 0; | ||||||
|  |     border-bottom-left-radius: 0; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   > .btn:not(:last-child) { | ||||||
|  |     border-top-right-radius: 0; | ||||||
|  |     border-bottom-right-radius: 0; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .btn-group > label.disabled { | ||||||
|  |   filter: brightness(0.5); | ||||||
|  |  | ||||||
|  |   &.active { | ||||||
|  |     background-color: lighten($primary, 30%); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | small > svg { | ||||||
|  |   margin-top: -2px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .list-group-item-note { | ||||||
|  |   line-height: 1; | ||||||
|  |  | ||||||
|  |   small { | ||||||
|  |     font-size: 65%; | ||||||
|  |   } | ||||||
|  | } | ||||||
|   | |||||||
| @@ -15,6 +15,8 @@ export class FilterableDropdownSelectionModel { | |||||||
|   changed = new Subject<FilterableDropdownSelectionModel>() |   changed = new Subject<FilterableDropdownSelectionModel>() | ||||||
|  |  | ||||||
|   multiple = false |   multiple = false | ||||||
|  |   private _logicalOperator = 'and' | ||||||
|  |   temporaryLogicalOperator = this._logicalOperator | ||||||
|  |  | ||||||
|   items: MatchingModel[] = [] |   items: MatchingModel[] = [] | ||||||
|  |  | ||||||
| @@ -43,6 +45,10 @@ export class FilterableDropdownSelectionModel { | |||||||
|     return this.items.filter(i => this.temporarySelectionStates.get(i.id) == ToggleableItemState.Selected) |     return this.items.filter(i => this.temporarySelectionStates.get(i.id) == ToggleableItemState.Selected) | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   getExcludedItems() { | ||||||
|  |     return this.items.filter(i => this.temporarySelectionStates.get(i.id) == ToggleableItemState.Excluded) | ||||||
|  |   } | ||||||
|  |  | ||||||
|   set(id: number, state: ToggleableItemState, fireEvent = true) { |   set(id: number, state: ToggleableItemState, fireEvent = true) { | ||||||
|     if (state == ToggleableItemState.NotSelected) { |     if (state == ToggleableItemState.NotSelected) { | ||||||
|       this.temporarySelectionStates.delete(id) |       this.temporarySelectionStates.delete(id) | ||||||
| @@ -56,9 +62,9 @@ export class FilterableDropdownSelectionModel { | |||||||
|  |  | ||||||
|   toggle(id: number, fireEvent = true) { |   toggle(id: number, fireEvent = true) { | ||||||
|     let state = this.temporarySelectionStates.get(id) |     let state = this.temporarySelectionStates.get(id) | ||||||
|     if (state == null || state != ToggleableItemState.Selected) { |     if (state == null || (state != ToggleableItemState.Selected && state != ToggleableItemState.Excluded)) { | ||||||
|       this.temporarySelectionStates.set(id, ToggleableItemState.Selected) |       this.temporarySelectionStates.set(id, ToggleableItemState.Selected) | ||||||
|     } else if (state == ToggleableItemState.Selected) { |     } else if (state == ToggleableItemState.Selected || state == ToggleableItemState.Excluded) { | ||||||
|       this.temporarySelectionStates.delete(id) |       this.temporarySelectionStates.delete(id) | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -83,13 +89,46 @@ export class FilterableDropdownSelectionModel { | |||||||
|     if (fireEvent) { |     if (fireEvent) { | ||||||
|       this.changed.next(this) |       this.changed.next(this) | ||||||
|     } |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   exclude(id: number, fireEvent:boolean = true) { | ||||||
|  |     let state = this.temporarySelectionStates.get(id) | ||||||
|  |     if (state == null || state != ToggleableItemState.Excluded) { | ||||||
|  |       this.temporarySelectionStates.set(id, ToggleableItemState.Excluded) | ||||||
|  |       this.temporaryLogicalOperator = this._logicalOperator = 'and' | ||||||
|  |     } else if (state == ToggleableItemState.Excluded) { | ||||||
|  |       this.temporarySelectionStates.delete(id) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (!this.multiple) { | ||||||
|  |       for (let key of this.temporarySelectionStates.keys()) { | ||||||
|  |         if (key != id) { | ||||||
|  |           this.temporarySelectionStates.delete(key) | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (fireEvent) { | ||||||
|  |       this.changed.next(this) | ||||||
|  |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   private getNonTemporary(id: number) { |   private getNonTemporary(id: number) { | ||||||
|     return this.selectionStates.get(id) || ToggleableItemState.NotSelected |     return this.selectionStates.get(id) || ToggleableItemState.NotSelected | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   get logicalOperator(): string { | ||||||
|  |     return this.temporaryLogicalOperator | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   set logicalOperator(operator: string) { | ||||||
|  |     this.temporaryLogicalOperator = operator | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   toggleOperator() { | ||||||
|  |     this.changed.next(this) | ||||||
|  |   } | ||||||
|  |  | ||||||
|   get(id: number) { |   get(id: number) { | ||||||
|     return this.temporarySelectionStates.get(id) || ToggleableItemState.NotSelected |     return this.temporarySelectionStates.get(id) || ToggleableItemState.NotSelected | ||||||
|   } |   } | ||||||
| @@ -100,6 +139,7 @@ export class FilterableDropdownSelectionModel { | |||||||
|  |  | ||||||
|   clear(fireEvent = true) { |   clear(fireEvent = true) { | ||||||
|     this.temporarySelectionStates.clear() |     this.temporarySelectionStates.clear() | ||||||
|  |     this.temporaryLogicalOperator = this._logicalOperator = 'and' | ||||||
|     if (fireEvent) { |     if (fireEvent) { | ||||||
|       this.changed.next(this) |       this.changed.next(this) | ||||||
|     } |     } | ||||||
| @@ -110,6 +150,8 @@ export class FilterableDropdownSelectionModel { | |||||||
|       return true |       return true | ||||||
|     } else if (!Array.from(this.selectionStates.keys()).every(id => this.selectionStates.get(id) == this.temporarySelectionStates.get(id))) { |     } else if (!Array.from(this.selectionStates.keys()).every(id => this.selectionStates.get(id) == this.temporarySelectionStates.get(id))) { | ||||||
|       return true |       return true | ||||||
|  |     } else if (this.temporaryLogicalOperator !== this._logicalOperator) { | ||||||
|  |       return true | ||||||
|     } else { |     } else { | ||||||
|       return false |       return false | ||||||
|     } |     } | ||||||
| @@ -129,6 +171,7 @@ export class FilterableDropdownSelectionModel { | |||||||
|     this.temporarySelectionStates.forEach((value, key) => { |     this.temporarySelectionStates.forEach((value, key) => { | ||||||
|       this.selectionStates.set(key, value) |       this.selectionStates.set(key, value) | ||||||
|     }) |     }) | ||||||
|  |     this._logicalOperator = this.temporaryLogicalOperator | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   reset() { |   reset() { | ||||||
| @@ -228,6 +271,10 @@ export class FilterableDropdownComponent { | |||||||
|   @Output() |   @Output() | ||||||
|   open = new EventEmitter() |   open = new EventEmitter() | ||||||
|  |  | ||||||
|  |   get operatorToggleEnabled(): boolean { | ||||||
|  |     return this.selectionModel.selectionSize() > 1 && this.selectionModel.getExcludedItems().length == 0 | ||||||
|  |   } | ||||||
|  |  | ||||||
|   constructor(private filterPipe: FilterPipe) { |   constructor(private filterPipe: FilterPipe) { | ||||||
|     this.selectionModel = new FilterableDropdownSelectionModel() |     this.selectionModel = new FilterableDropdownSelectionModel() | ||||||
|   } |   } | ||||||
| @@ -269,4 +316,12 @@ export class FilterableDropdownComponent { | |||||||
|       } |       } | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   excludeClicked(itemID: number) { | ||||||
|  |     if (this.editing) { | ||||||
|  |       this.selectionModel.toggle(itemID) | ||||||
|  |     } else { | ||||||
|  |       this.selectionModel.exclude(itemID) | ||||||
|  |     } | ||||||
|  |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| <button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-left-0 border-right-0 border-bottom" role="menuitem" (click)="toggleItem()"> | <button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-left-0 border-right-0 border-bottom" role="menuitem" (click)="toggleItem($event)"> | ||||||
|   <div class="selected-icon mr-1"> |   <div class="selected-icon mr-1"> | ||||||
|     <ng-container *ngIf="isChecked()"> |     <ng-container *ngIf="isChecked()"> | ||||||
|       <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" class="bi bi-check" viewBox="0 0 16 16"> |       <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" class="bi bi-check" viewBox="0 0 16 16"> | ||||||
| @@ -10,10 +10,14 @@ | |||||||
|         <path d="M4 8a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7A.5.5 0 0 1 4 8z"/> |         <path d="M4 8a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7A.5.5 0 0 1 4 8z"/> | ||||||
|       </svg> |       </svg> | ||||||
|     </ng-container> |     </ng-container> | ||||||
|  |     <ng-container *ngIf="isExcluded()"> | ||||||
|  |       <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" class="bi bi-x" viewBox="0 0 16 16"> | ||||||
|  |         <path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/> | ||||||
|  |       </svg> | ||||||
|  |     </ng-container> | ||||||
|   </div> |   </div> | ||||||
|   <div class="mr-1"> |   <div class="mr-1"> | ||||||
|     <app-tag *ngIf="isTag; else displayName" [tag]="item" [clickable]="true" linkTitle="Filter by tag"></app-tag> |     <app-tag *ngIf="isTag; else displayName" [tag]="item" [clickable]="false"></app-tag> | ||||||
|     <ng-template #displayName><small>{{item.name}}</small></ng-template> |     <ng-template #displayName><small>{{item.name}}</small></ng-template> | ||||||
|   </div> |   </div> | ||||||
|   <div class="badge badge-light rounded-pill ml-auto mr-1">{{item.document_count}}</div> |   <div class="badge badge-light rounded-pill ml-auto mr-1">{{item.document_count}}</div> | ||||||
|   | |||||||
| @@ -1,16 +1,11 @@ | |||||||
| import { Component, EventEmitter, Input, Output, OnInit } from '@angular/core'; | import { Component, EventEmitter, Input, Output, OnInit } from '@angular/core'; | ||||||
| import { MatchingModel } from 'src/app/data/matching-model'; | import { MatchingModel } from 'src/app/data/matching-model'; | ||||||
|  |  | ||||||
| export interface ToggleableItem { |  | ||||||
|   item: MatchingModel, |  | ||||||
|   state: ToggleableItemState, |  | ||||||
|   count: number |  | ||||||
| } |  | ||||||
|  |  | ||||||
| export enum ToggleableItemState { | export enum ToggleableItemState { | ||||||
|   NotSelected = 0, |   NotSelected = 0, | ||||||
|   Selected = 1, |   Selected = 1, | ||||||
|   PartiallySelected = 2 |   PartiallySelected = 2, | ||||||
|  |   Excluded = 3 | ||||||
| } | } | ||||||
|  |  | ||||||
| @Component({ | @Component({ | ||||||
| @@ -32,13 +27,20 @@ export class ToggleableDropdownButtonComponent { | |||||||
|   @Output() |   @Output() | ||||||
|   toggle = new EventEmitter() |   toggle = new EventEmitter() | ||||||
|  |  | ||||||
|  |   @Output() | ||||||
|  |   exclude = new EventEmitter() | ||||||
|  |  | ||||||
|   get isTag(): boolean { |   get isTag(): boolean { | ||||||
|     return 'is_inbox_tag' in this.item |     return 'is_inbox_tag' in this.item | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   toggleItem(): void { |   toggleItem(event: MouseEvent): void { | ||||||
|  |     if (this.state == ToggleableItemState.Selected) { | ||||||
|  |       this.exclude.emit() | ||||||
|  |     } else { | ||||||
|       this.toggle.emit() |       this.toggle.emit() | ||||||
|     } |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|   isChecked() { |   isChecked() { | ||||||
|     return this.state == ToggleableItemState.Selected |     return this.state == ToggleableItemState.Selected | ||||||
| @@ -48,4 +50,7 @@ export class ToggleableDropdownButtonComponent { | |||||||
|     return this.state == ToggleableItemState.PartiallySelected |     return this.state == ToggleableItemState.PartiallySelected | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   isExcluded() { | ||||||
|  |     return this.state == ToggleableItemState.Excluded | ||||||
|  |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -151,21 +151,21 @@ export class DocumentListComponent implements OnInit, OnDestroy { | |||||||
|   clickTag(tagID: number) { |   clickTag(tagID: number) { | ||||||
|     this.list.selectNone() |     this.list.selectNone() | ||||||
|     setTimeout(() => { |     setTimeout(() => { | ||||||
|       this.filterEditor.toggleTag(tagID) |       this.filterEditor.addTag(tagID) | ||||||
|     }) |     }) | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   clickCorrespondent(correspondentID: number) { |   clickCorrespondent(correspondentID: number) { | ||||||
|     this.list.selectNone() |     this.list.selectNone() | ||||||
|     setTimeout(() => { |     setTimeout(() => { | ||||||
|       this.filterEditor.toggleCorrespondent(correspondentID) |       this.filterEditor.addCorrespondent(correspondentID) | ||||||
|     }) |     }) | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   clickDocumentType(documentTypeID: number) { |   clickDocumentType(documentTypeID: number) { | ||||||
|     this.list.selectNone() |     this.list.selectNone() | ||||||
|     setTimeout(() => { |     setTimeout(() => { | ||||||
|       this.filterEditor.toggleDocumentType(documentTypeID) |       this.filterEditor.addDocumentType(documentTypeID) | ||||||
|     }) |     }) | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -8,7 +8,7 @@ import { DocumentTypeService } from 'src/app/services/rest/document-type.service | |||||||
| import { TagService } from 'src/app/services/rest/tag.service'; | import { TagService } from 'src/app/services/rest/tag.service'; | ||||||
| import { CorrespondentService } from 'src/app/services/rest/correspondent.service'; | import { CorrespondentService } from 'src/app/services/rest/correspondent.service'; | ||||||
| import { FilterRule } from 'src/app/data/filter-rule'; | import { FilterRule } from 'src/app/data/filter-rule'; | ||||||
| import { FILTER_ADDED_AFTER, FILTER_ADDED_BEFORE, FILTER_ASN, FILTER_CORRESPONDENT, FILTER_CREATED_AFTER, FILTER_CREATED_BEFORE, FILTER_DOCUMENT_TYPE, FILTER_FULLTEXT_MORELIKE, FILTER_FULLTEXT_QUERY, FILTER_HAS_ANY_TAG, FILTER_HAS_TAG, FILTER_TITLE, FILTER_TITLE_CONTENT } from 'src/app/data/filter-rule-type'; | import { FILTER_ADDED_AFTER, FILTER_ADDED_BEFORE, FILTER_ASN, FILTER_CORRESPONDENT, FILTER_CREATED_AFTER, FILTER_CREATED_BEFORE, FILTER_DOCUMENT_TYPE, FILTER_FULLTEXT_MORELIKE, FILTER_FULLTEXT_QUERY, FILTER_HAS_ANY_TAG, FILTER_HAS_TAGS_ALL, FILTER_HAS_TAGS_ANY, FILTER_DOES_NOT_HAVE_TAG, FILTER_TITLE, FILTER_TITLE_CONTENT } from 'src/app/data/filter-rule-type'; | ||||||
| import { FilterableDropdownSelectionModel } from '../../common/filterable-dropdown/filterable-dropdown.component'; | import { FilterableDropdownSelectionModel } from '../../common/filterable-dropdown/filterable-dropdown.component'; | ||||||
| import { ToggleableItemState } from '../../common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component'; | import { ToggleableItemState } from '../../common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component'; | ||||||
| import { DocumentService } from 'src/app/services/rest/document.service'; | import { DocumentService } from 'src/app/services/rest/document.service'; | ||||||
| @@ -46,7 +46,7 @@ export class FilterEditorComponent implements OnInit, OnDestroy { | |||||||
|             return $localize`Without document type` |             return $localize`Without document type` | ||||||
|           } |           } | ||||||
|  |  | ||||||
|         case FILTER_HAS_TAG: |         case FILTER_HAS_TAGS_ALL: | ||||||
|           return $localize`Tag: ${this.tags.find(t => t.id == +rule.value)?.name}` |           return $localize`Tag: ${this.tags.find(t => t.id == +rule.value)?.name}` | ||||||
|  |  | ||||||
|         case FILTER_HAS_ANY_TAG: |         case FILTER_HAS_ANY_TAG: | ||||||
| @@ -177,12 +177,19 @@ export class FilterEditorComponent implements OnInit, OnDestroy { | |||||||
|         case FILTER_ADDED_BEFORE: |         case FILTER_ADDED_BEFORE: | ||||||
|           this.dateAddedBefore = rule.value |           this.dateAddedBefore = rule.value | ||||||
|           break |           break | ||||||
|         case FILTER_HAS_TAG: |         case FILTER_HAS_TAGS_ALL: | ||||||
|  |           this.tagSelectionModel.set(rule.value ? +rule.value : null, ToggleableItemState.Selected, false) | ||||||
|  |           break | ||||||
|  |         case FILTER_HAS_TAGS_ANY: | ||||||
|  |           this.tagSelectionModel.logicalOperator = 'or' | ||||||
|           this.tagSelectionModel.set(rule.value ? +rule.value : null, ToggleableItemState.Selected, false) |           this.tagSelectionModel.set(rule.value ? +rule.value : null, ToggleableItemState.Selected, false) | ||||||
|           break |           break | ||||||
|         case FILTER_HAS_ANY_TAG: |         case FILTER_HAS_ANY_TAG: | ||||||
|           this.tagSelectionModel.set(null, ToggleableItemState.Selected, false) |           this.tagSelectionModel.set(null, ToggleableItemState.Selected, false) | ||||||
|           break |           break | ||||||
|  |         case FILTER_DOES_NOT_HAVE_TAG: | ||||||
|  |           this.tagSelectionModel.set(rule.value ? +rule.value : null, ToggleableItemState.Excluded, false) | ||||||
|  |           break | ||||||
|         case FILTER_CORRESPONDENT: |         case FILTER_CORRESPONDENT: | ||||||
|           this.correspondentSelectionModel.set(rule.value ? +rule.value : null, ToggleableItemState.Selected, false) |           this.correspondentSelectionModel.set(rule.value ? +rule.value : null, ToggleableItemState.Selected, false) | ||||||
|           break |           break | ||||||
| @@ -214,8 +221,12 @@ export class FilterEditorComponent implements OnInit, OnDestroy { | |||||||
|     if (this.tagSelectionModel.isNoneSelected()) { |     if (this.tagSelectionModel.isNoneSelected()) { | ||||||
|       filterRules.push({rule_type: FILTER_HAS_ANY_TAG, value: "false"}) |       filterRules.push({rule_type: FILTER_HAS_ANY_TAG, value: "false"}) | ||||||
|     } else { |     } else { | ||||||
|  |       const tagFilterType = this.tagSelectionModel.logicalOperator == 'and' ? FILTER_HAS_TAGS_ALL : FILTER_HAS_TAGS_ANY | ||||||
|       this.tagSelectionModel.getSelectedItems().filter(tag => tag.id).forEach(tag => { |       this.tagSelectionModel.getSelectedItems().filter(tag => tag.id).forEach(tag => { | ||||||
|         filterRules.push({rule_type: FILTER_HAS_TAG, value: tag.id?.toString()}) |         filterRules.push({rule_type: tagFilterType, value: tag.id?.toString()}) | ||||||
|  |       }) | ||||||
|  |       this.tagSelectionModel.getExcludedItems().filter(tag => tag.id).forEach(tag => { | ||||||
|  |         filterRules.push({rule_type: FILTER_DOES_NOT_HAVE_TAG, value: tag.id?.toString()}) | ||||||
|       }) |       }) | ||||||
|     } |     } | ||||||
|     this.correspondentSelectionModel.getSelectedItems().forEach(correspondent => { |     this.correspondentSelectionModel.getSelectedItems().forEach(correspondent => { | ||||||
| @@ -308,16 +319,16 @@ export class FilterEditorComponent implements OnInit, OnDestroy { | |||||||
|     this.updateRules() |     this.updateRules() | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   toggleTag(tagId: number) { |   addTag(tagId: number) { | ||||||
|     this.tagSelectionModel.toggle(tagId) |     this.tagSelectionModel.set(tagId, ToggleableItemState.Selected) | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   toggleCorrespondent(correspondentId: number) { |   addCorrespondent(correspondentId: number) { | ||||||
|     this.correspondentSelectionModel.toggle(correspondentId) |     this.correspondentSelectionModel.set(correspondentId, ToggleableItemState.Selected) | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   toggleDocumentType(documentTypeId: number) { |   addDocumentType(documentTypeId: number) { | ||||||
|     this.documentTypeSelectionModel.toggle(documentTypeId) |     this.documentTypeSelectionModel.set(documentTypeId, ToggleableItemState.Selected) | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   onTagsDropdownOpen() { |   onTagsDropdownOpen() { | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| import { Component } from '@angular/core'; | import { Component } from '@angular/core'; | ||||||
| import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; | import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; | ||||||
| import { FILTER_HAS_TAG } from 'src/app/data/filter-rule-type'; | import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type'; | ||||||
| import { PaperlessTag } from 'src/app/data/paperless-tag'; | import { PaperlessTag } from 'src/app/data/paperless-tag'; | ||||||
| import { DocumentListViewService } from 'src/app/services/document-list-view.service'; | import { DocumentListViewService } from 'src/app/services/document-list-view.service'; | ||||||
| import { TagService } from 'src/app/services/rest/tag.service'; | import { TagService } from 'src/app/services/rest/tag.service'; | ||||||
| @@ -27,7 +27,7 @@ export class TagListComponent extends GenericListComponent<PaperlessTag> { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   filterDocuments(object: PaperlessTag) { |   filterDocuments(object: PaperlessTag) { | ||||||
|     this.list.quickFilter([{rule_type: FILTER_HAS_TAG, value: object.id.toString()}]) |     this.list.quickFilter([{rule_type: FILTER_HAS_TAGS_ALL, value: object.id.toString()}]) | ||||||
|  |  | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -4,8 +4,9 @@ export const FILTER_ASN = 2 | |||||||
| export const FILTER_CORRESPONDENT = 3 | export const FILTER_CORRESPONDENT = 3 | ||||||
| export const FILTER_DOCUMENT_TYPE = 4 | export const FILTER_DOCUMENT_TYPE = 4 | ||||||
| export const FILTER_IS_IN_INBOX = 5 | export const FILTER_IS_IN_INBOX = 5 | ||||||
| export const FILTER_HAS_TAG = 6 | export const FILTER_HAS_TAGS_ALL = 6 | ||||||
| export const FILTER_HAS_ANY_TAG = 7 | export const FILTER_HAS_ANY_TAG = 7 | ||||||
|  | export const FILTER_HAS_TAGS_ANY = 22 | ||||||
| export const FILTER_CREATED_BEFORE = 8 | export const FILTER_CREATED_BEFORE = 8 | ||||||
| export const FILTER_CREATED_AFTER = 9 | export const FILTER_CREATED_AFTER = 9 | ||||||
| export const FILTER_CREATED_YEAR = 10 | export const FILTER_CREATED_YEAR = 10 | ||||||
| @@ -36,7 +37,8 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [ | |||||||
|   {id: FILTER_DOCUMENT_TYPE, filtervar: "document_type__id", isnull_filtervar: "document_type__isnull", datatype: "document_type", multi: false}, |   {id: FILTER_DOCUMENT_TYPE, filtervar: "document_type__id", isnull_filtervar: "document_type__isnull", datatype: "document_type", multi: false}, | ||||||
|  |  | ||||||
|   {id: FILTER_IS_IN_INBOX, filtervar: "is_in_inbox", datatype: "boolean", multi: false, default: true}, |   {id: FILTER_IS_IN_INBOX, filtervar: "is_in_inbox", datatype: "boolean", multi: false, default: true}, | ||||||
|   {id: FILTER_HAS_TAG, filtervar: "tags__id__all", datatype: "tag", multi: true}, |   {id: FILTER_HAS_TAGS_ALL, filtervar: "tags__id__all", datatype: "tag", multi: true}, | ||||||
|  |   {id: FILTER_HAS_TAGS_ANY, filtervar: "tags__id__in", datatype: "tag", multi: true}, | ||||||
|   {id: FILTER_DOES_NOT_HAVE_TAG, filtervar: "tags__id__none", datatype: "tag", multi: true}, |   {id: FILTER_DOES_NOT_HAVE_TAG, filtervar: "tags__id__none", datatype: "tag", multi: true}, | ||||||
|   {id: FILTER_HAS_ANY_TAG, filtervar: "is_tagged", datatype: "boolean", multi: false, default: true}, |   {id: FILTER_HAS_ANY_TAG, filtervar: "is_tagged", datatype: "boolean", multi: false, default: true}, | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,5 +1,4 @@ | |||||||
| import { Pipe, PipeTransform } from '@angular/core'; | import { Pipe, PipeTransform } from '@angular/core'; | ||||||
| import { ToggleableItem } from 'src/app/components/common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component'; |  | ||||||
| import { MatchingModel } from '../data/matching-model'; | import { MatchingModel } from '../data/matching-model'; | ||||||
|  |  | ||||||
| @Pipe({ | @Pipe({ | ||||||
|   | |||||||
| @@ -231,6 +231,10 @@ $border-color-dark-mode: #47494f; | |||||||
|       border-color: darken($primary-dark-mode, 10%); |       border-color: darken($primary-dark-mode, 10%); | ||||||
|       color: $text-color-dark-mode-accent; |       color: $text-color-dark-mode-accent; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     &.disabled.active { | ||||||
|  |       background-color: darken($primary-dark-mode, 10%); | ||||||
|  |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   .btn-outline-secondary { |   .btn-outline-secondary { | ||||||
|   | |||||||
| @@ -38,9 +38,10 @@ class DocumentTypeFilterSet(FilterSet): | |||||||
|  |  | ||||||
| class TagsFilter(Filter): | class TagsFilter(Filter): | ||||||
|  |  | ||||||
|     def __init__(self, exclude=False): |     def __init__(self, exclude=False, in_list=False): | ||||||
|         super(TagsFilter, self).__init__() |         super(TagsFilter, self).__init__() | ||||||
|         self.exclude = exclude |         self.exclude = exclude | ||||||
|  |         self.in_list = in_list | ||||||
|  |  | ||||||
|     def filter(self, qs, value): |     def filter(self, qs, value): | ||||||
|         if not value: |         if not value: | ||||||
| @@ -51,6 +52,9 @@ class TagsFilter(Filter): | |||||||
|         except ValueError: |         except ValueError: | ||||||
|             return qs |             return qs | ||||||
|  |  | ||||||
|  |         if self.in_list: | ||||||
|  |             qs = qs.filter(tags__id__in=tag_ids).distinct() | ||||||
|  |         else: | ||||||
|             for tag_id in tag_ids: |             for tag_id in tag_ids: | ||||||
|                 if self.exclude: |                 if self.exclude: | ||||||
|                     qs = qs.exclude(tags__id=tag_id) |                     qs = qs.exclude(tags__id=tag_id) | ||||||
| @@ -94,6 +98,8 @@ class DocumentFilterSet(FilterSet): | |||||||
|  |  | ||||||
|     tags__id__none = TagsFilter(exclude=True) |     tags__id__none = TagsFilter(exclude=True) | ||||||
|  |  | ||||||
|  |     tags__id__in = TagsFilter(in_list=True) | ||||||
|  |  | ||||||
|     is_in_inbox = InboxFilter() |     is_in_inbox = InboxFilter() | ||||||
|  |  | ||||||
|     title_content = TitleContentFilter() |     title_content = TitleContentFilter() | ||||||
|   | |||||||
| @@ -387,7 +387,8 @@ class SavedViewFilterRule(models.Model): | |||||||
|         (18, _("does not have ASN")), |         (18, _("does not have ASN")), | ||||||
|         (19, _("title or content contains")), |         (19, _("title or content contains")), | ||||||
|         (20, _("fulltext query")), |         (20, _("fulltext query")), | ||||||
|         (21, _("more like this")) |         (21, _("more like this")), | ||||||
|  |         (22, _("has tags in")) | ||||||
|     ] |     ] | ||||||
|  |  | ||||||
|     saved_view = models.ForeignKey( |     saved_view = models.ForeignKey( | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 shamoon
					shamoon