]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Enhancement: file task filtering (#8421)
authorshamoon <4887959+shamoon@users.noreply.github.com>
Tue, 3 Dec 2024 18:44:52 +0000 (10:44 -0800)
committerGitHub <noreply@github.com>
Tue, 3 Dec 2024 18:44:52 +0000 (10:44 -0800)
src-ui/messages.xlf
src-ui/src/app/components/admin/tasks/tasks.component.html
src-ui/src/app/components/admin/tasks/tasks.component.scss
src-ui/src/app/components/admin/tasks/tasks.component.spec.ts
src-ui/src/app/components/admin/tasks/tasks.component.ts

index af014edeac5743165b4bdf8e0f53ba758839572f..9329edb5d07deda50e8f6f705390b580e44774ee 100644 (file)
           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
           <context context-type="linenumber">36</context>
         </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
+          <context context-type="linenumber">30</context>
+        </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/trash/trash.component.html</context>
           <context context-type="linenumber">35</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
-          <context context-type="linenumber">68</context>
+          <context context-type="linenumber">124</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2134950584701094962" datatype="html">
           <context context-type="linenumber">147,149</context>
         </context-group>
       </trans-unit>
+      <trans-unit id="4880728824338713664" datatype="html">
+        <source>Filter by</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
+          <context context-type="linenumber">157</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="2525230676386818985" datatype="html">
+        <source>Result</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
+          <context context-type="linenumber">31</context>
+        </context-group>
+      </trans-unit>
       <trans-unit id="5404910960991552159" datatype="html">
         <source>Dismiss selected</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
-          <context context-type="linenumber">31</context>
+          <context context-type="linenumber">77</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8829078752502782653" datatype="html">
         <source>Dismiss all</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
-          <context context-type="linenumber">32</context>
+          <context context-type="linenumber">78</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1323591410517879795" datatype="html">
         <source>Confirm Dismiss All</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
-          <context context-type="linenumber">65</context>
+          <context context-type="linenumber">121</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4157200209636243740" datatype="html">
         <source>Dismiss all <x id="PH" equiv-text="tasks.size"/> tasks?</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
-          <context context-type="linenumber">66</context>
+          <context context-type="linenumber">122</context>
         </context-group>
       </trans-unit>
       <trans-unit id="9011556615675272238" datatype="html">
         <source>queued</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
-          <context context-type="linenumber">135</context>
+          <context context-type="linenumber">207</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6415892379431855826" datatype="html">
         <source>started</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
-          <context context-type="linenumber">137</context>
+          <context context-type="linenumber">209</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7510279840486540181" datatype="html">
         <source>completed</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
-          <context context-type="linenumber">139</context>
+          <context context-type="linenumber">211</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4083337005045748464" datatype="html">
         <source>failed</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
-          <context context-type="linenumber">141</context>
+          <context context-type="linenumber">213</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3418677553313974490" datatype="html">
index 3d40c789730a166dfc51161abb020c598415da0a..75dda1394b44df2ce0586e911cea33f16a4e4143 100644 (file)
   </div>
 </ng-template>
 
-<ul ngbNav #nav="ngbNav" [(activeId)]="activeTab" class="nav-tabs" (hidden)="duringTabChange($event)">
+<ul ngbNav #nav="ngbNav" [(activeId)]="activeTab" class="nav-tabs" (hidden)="duringTabChange()" (navChange)="beforeTabChange()">
   <li ngbNavItem="failed">
     <a ngbNavLink i18n>Failed@if (tasksService.failedFileTasks.length > 0) {
       <span class="badge bg-danger ms-2">{{tasksService.failedFileTasks.length}}</span>
     }</a>
     <ng-template ngbNavContent>
-      <ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:tasksService.failedFileTasks}"></ng-container>
+      <ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:currentTasks}"></ng-container>
     </ng-template>
   </li>
   <li ngbNavItem="completed">
       <span class="badge bg-secondary ms-2">{{tasksService.completedFileTasks.length}}</span>
     }</a>
     <ng-template ngbNavContent>
-      <ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:tasksService.completedFileTasks}"></ng-container>
+      <ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:currentTasks}"></ng-container>
     </ng-template>
   </li>
   <li ngbNavItem="started">
       <span class="badge bg-secondary ms-2">{{tasksService.startedFileTasks.length}}</span>
     }</a>
     <ng-template ngbNavContent>
-      <ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:tasksService.startedFileTasks}"></ng-container>
+      <ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:currentTasks}"></ng-container>
     </ng-template>
   </li>
   <li ngbNavItem="queued">
       <span class="badge bg-secondary ms-2">{{tasksService.queuedFileTasks.length}}</span>
     }</a>
     <ng-template ngbNavContent>
-      <ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:tasksService.queuedFileTasks}"></ng-container>
+      <ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:currentTasks}"></ng-container>
     </ng-template>
   </li>
+  <li class="ms-auto">
+    <div class="form-inline d-flex align-items-center">
+      <div class="input-group input-group-sm flex-fill w-auto flex-nowrap">
+        <span class="input-group-text text-muted" i18n>Filter by</span>
+        @if (filterTargets.length > 1) {
+          <div ngbDropdown>
+            <button class="btn btn-sm btn-outline-primary" ngbDropdownToggle>{{filterTargetName}}</button>
+            <div class="dropdown-menu shadow" ngbDropdownMenu>
+              @for (t of filterTargets; track t.id) {
+                <button ngbDropdownItem [class.active]="filterTargetID === t.id" (click)="filterTargetID = t.id">{{t.name}}</button>
+              }
+            </div>
+          </div>
+        } @else {
+          <span class="input-group-text">{{filterTargetName}}</span>
+        }
+        @if (filterText?.length) {
+          <button class="btn btn-link btn-sm px-2 position-absolute top-0 end-0 z-10" (click)="resetFilter()">
+            <i-bs width="1em" height="1em" name="x"></i-bs>
+          </button>
+        }
+        <input #filterInput class="form-control form-control-sm" type="text"
+          (keyup)="filterInputKeyup($event)"
+          [(ngModel)]="filterText">
+      </div>
+    </div>
+  </li>
 </ul>
 <div [ngbNavOutlet]="nav"></div>
index 60f8f22976883ed79ae49a3fa688414bf3cdc554..325fd2c0254f16a3094b558c4d74f4450daec591 100644 (file)
@@ -26,3 +26,14 @@ pre {
         max-width: 150px;
     }
 }
+
+.input-group .dropdown .btn {
+    border-top-right-radius: 0;
+    border-bottom-right-radius: 0;
+    border-top-left-radius: 0;
+    border-bottom-left-radius: 0;
+}
+
+.z-10 {
+    z-index: 10;
+}
index 1ad2d53115cda3eace779d67a2bc6595e449e3a5..4d3e600acfa0dbb60168f07dbdc9c6005617e9de 100644 (file)
@@ -26,7 +26,7 @@ import { TasksService } from 'src/app/services/tasks.service'
 import { environment } from 'src/environments/environment'
 import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
 import { PageHeaderComponent } from '../../common/page-header/page-header.component'
-import { TasksComponent } from './tasks.component'
+import { TasksComponent, TaskTab } from './tasks.component'
 import { PermissionsGuard } from 'src/app/guards/permissions.guard'
 import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
 import { FormsModule } from '@angular/forms'
@@ -167,7 +167,7 @@ describe('TasksComponent', () => {
     let currentTasksLength = tasks.filter(
       (t) => t.status === PaperlessTaskStatus.Failed
     ).length
-    component.activeTab = 'failed'
+    component.activeTab = TaskTab.Failed
     fixture.detectChanges()
     expect(tabButtons[0].nativeElement.textContent).toEqual(
       `Failed${currentTasksLength}`
@@ -179,7 +179,7 @@ describe('TasksComponent', () => {
     currentTasksLength = tasks.filter(
       (t) => t.status === PaperlessTaskStatus.Complete
     ).length
-    component.activeTab = 'completed'
+    component.activeTab = TaskTab.Completed
     fixture.detectChanges()
     expect(tabButtons[1].nativeElement.textContent).toEqual(
       `Complete${currentTasksLength}`
@@ -188,7 +188,7 @@ describe('TasksComponent', () => {
     currentTasksLength = tasks.filter(
       (t) => t.status === PaperlessTaskStatus.Started
     ).length
-    component.activeTab = 'started'
+    component.activeTab = TaskTab.Started
     fixture.detectChanges()
     expect(tabButtons[2].nativeElement.textContent).toEqual(
       `Started${currentTasksLength}`
@@ -197,7 +197,7 @@ describe('TasksComponent', () => {
     currentTasksLength = tasks.filter(
       (t) => t.status === PaperlessTaskStatus.Pending
     ).length
-    component.activeTab = 'queued'
+    component.activeTab = TaskTab.Queued
     fixture.detectChanges()
     expect(tabButtons[3].nativeElement.textContent).toEqual(
       `Queued${currentTasksLength}`
@@ -206,7 +206,7 @@ describe('TasksComponent', () => {
 
   it('should to go page 1 between tab switch', () => {
     component.page = 10
-    component.duringTabChange(2)
+    component.duringTabChange()
     expect(component.page).toEqual(1)
   })
 
@@ -289,4 +289,53 @@ describe('TasksComponent', () => {
     jest.advanceTimersByTime(6000)
     expect(reloadSpy).toHaveBeenCalledTimes(2)
   })
+
+  it('should filter tasks by file name', () => {
+    const input = fixture.debugElement.query(By.css('ul input[type=text]'))
+    input.nativeElement.value = '191092'
+    input.nativeElement.dispatchEvent(new Event('input'))
+    jest.advanceTimersByTime(150) // debounce time
+    fixture.detectChanges()
+    expect(component.filterText).toEqual('191092')
+    expect(
+      fixture.debugElement.queryAll(By.css('table tbody tr')).length
+    ).toEqual(2) // 1 task x 2 lines
+  })
+
+  it('should filter tasks by result', () => {
+    component.activeTab = TaskTab.Failed
+    fixture.detectChanges()
+    component.filterTargetID = 1
+    const input = fixture.debugElement.query(By.css('ul input[type=text]'))
+    input.nativeElement.value = 'duplicate'
+    input.nativeElement.dispatchEvent(new Event('input'))
+    jest.advanceTimersByTime(150) // debounce time
+    fixture.detectChanges()
+    expect(component.filterText).toEqual('duplicate')
+    expect(
+      fixture.debugElement.queryAll(By.css('table tbody tr')).length
+    ).toEqual(4) // 2 tasks x 2 lines
+  })
+
+  it('should support keyboard events for filtering', () => {
+    const input = fixture.debugElement.query(By.css('ul input[type=text]'))
+    input.nativeElement.value = '191092'
+    input.nativeElement.dispatchEvent(
+      new KeyboardEvent('keyup', { key: 'Enter' })
+    )
+    expect(component.filterText).toEqual('191092') // no debounce needed
+    input.nativeElement.dispatchEvent(
+      new KeyboardEvent('keyup', { key: 'Escape' })
+    )
+    expect(component.filterText).toEqual('')
+  })
+
+  it('should reset filter and target on tab switch', () => {
+    component.filterText = '191092'
+    component.filterTargetID = 1
+    component.activeTab = TaskTab.Completed
+    component.beforeTabChange()
+    expect(component.filterText).toEqual('')
+    expect(component.filterTargetID).toEqual(0)
+  })
 })
index 7b01090d50f8bb252bd2da8656cf5bf436ed1173..6539b3692629240eedab0d76def43f204b3599dd 100644 (file)
@@ -1,12 +1,36 @@
 import { Component, OnInit, OnDestroy } from '@angular/core'
 import { Router } from '@angular/router'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
-import { first } from 'rxjs'
+import {
+  debounceTime,
+  distinctUntilChanged,
+  filter,
+  first,
+  Subject,
+  takeUntil,
+} from 'rxjs'
 import { PaperlessTask } from 'src/app/data/paperless-task'
 import { TasksService } from 'src/app/services/tasks.service'
 import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
 import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
 
+export enum TaskTab {
+  Queued = 'queued',
+  Started = 'started',
+  Completed = 'completed',
+  Failed = 'failed',
+}
+
+enum TaskFilterTargetID {
+  Name,
+  Result,
+}
+
+const FILTER_TARGETS = [
+  { id: TaskFilterTargetID.Name, name: $localize`Name` },
+  { id: TaskFilterTargetID.Result, name: $localize`Result` },
+]
+
 @Component({
   selector: 'pngx-tasks',
   templateUrl: './tasks.component.html',
@@ -16,7 +40,7 @@ export class TasksComponent
   extends ComponentWithPermissions
   implements OnInit, OnDestroy
 {
-  public activeTab: string
+  public activeTab: TaskTab
   public selectedTasks: Set<number> = new Set()
   public togggleAll: boolean = false
   public expandedTask: number
@@ -26,6 +50,28 @@ export class TasksComponent
 
   public autoRefreshInterval: any
 
+  private _filterText: string = ''
+  get filterText() {
+    return this._filterText
+  }
+  set filterText(value: string) {
+    this.filterDebounce.next(value)
+  }
+
+  public filterTargetID: TaskFilterTargetID = TaskFilterTargetID.Name
+  public get filterTargetName(): string {
+    return this.filterTargets.find((t) => t.id == this.filterTargetID).name
+  }
+  private filterDebounce: Subject<string> = new Subject<string>()
+
+  public get filterTargets(): Array<{ id: number; name: string }> {
+    return [TaskTab.Failed, TaskTab.Completed].includes(this.activeTab)
+      ? FILTER_TARGETS
+      : FILTER_TARGETS.slice(0, 1)
+  }
+
+  private unsubscribeNotifier: Subject<any> = new Subject()
+
   get dismissButtonText(): string {
     return this.selectedTasks.size > 0
       ? $localize`Dismiss selected`
@@ -43,11 +89,21 @@ export class TasksComponent
   ngOnInit() {
     this.tasksService.reload()
     this.toggleAutoRefresh()
+
+    this.filterDebounce
+      .pipe(
+        takeUntil(this.unsubscribeNotifier),
+        debounceTime(100),
+        distinctUntilChanged(),
+        filter((query) => !query.length || query.length > 2)
+      )
+      .subscribe((query) => (this._filterText = query))
   }
 
   ngOnDestroy() {
     this.tasksService.cancelPending()
     clearInterval(this.autoRefreshInterval)
+    this.unsubscribeNotifier.next(this)
   }
 
   dismissTask(task: PaperlessTask) {
@@ -96,19 +152,30 @@ export class TasksComponent
   get currentTasks(): PaperlessTask[] {
     let tasks: PaperlessTask[] = []
     switch (this.activeTab) {
-      case 'queued':
+      case TaskTab.Queued:
         tasks = this.tasksService.queuedFileTasks
         break
-      case 'started':
+      case TaskTab.Started:
         tasks = this.tasksService.startedFileTasks
         break
-      case 'completed':
+      case TaskTab.Completed:
         tasks = this.tasksService.completedFileTasks
         break
-      case 'failed':
+      case TaskTab.Failed:
         tasks = this.tasksService.failedFileTasks
         break
     }
+    if (this._filterText.length) {
+      tasks = tasks.filter((t) => {
+        if (this.filterTargetID == TaskFilterTargetID.Name) {
+          return t.task_file_name
+            .toLowerCase()
+            .includes(this._filterText.toLowerCase())
+        } else if (this.filterTargetID == TaskFilterTargetID.Result) {
+          return t.result.toLowerCase().includes(this._filterText.toLowerCase())
+        }
+      })
+    }
     return tasks
   }
 
@@ -125,19 +192,24 @@ export class TasksComponent
     this.selectedTasks.clear()
   }
 
-  duringTabChange(navID: number) {
+  duringTabChange() {
     this.page = 1
   }
 
+  beforeTabChange() {
+    this.resetFilter()
+    this.filterTargetID = TaskFilterTargetID.Name
+  }
+
   get activeTabLocalized(): string {
     switch (this.activeTab) {
-      case 'queued':
+      case TaskTab.Queued:
         return $localize`queued`
-      case 'started':
+      case TaskTab.Started:
         return $localize`started`
-      case 'completed':
+      case TaskTab.Completed:
         return $localize`completed`
-      case 'failed':
+      case TaskTab.Failed:
         return $localize`failed`
     }
   }
@@ -152,4 +224,16 @@ export class TasksComponent
       }, 5000)
     }
   }
+
+  public resetFilter() {
+    this._filterText = ''
+  }
+
+  filterInputKeyup(event: KeyboardEvent) {
+    if (event.key == 'Enter') {
+      this._filterText = (event.target as HTMLInputElement).value
+    } else if (event.key === 'Escape') {
+      this.resetFilter()
+    }
+  }
 }