Allow filtering by type + source

This commit is contained in:
shamoon
2026-04-20 12:30:03 -07:00
parent ce1661aa57
commit 8f8c3b072e
3 changed files with 259 additions and 18 deletions

View File

@@ -32,6 +32,24 @@
[(ngModel)]="filterText">
</div>
</div>
<div ngbDropdown>
<button class="btn btn-sm btn-outline-primary" ngbDropdownToggle>{{selectedTaskTypeLabel}}</button>
<div class="dropdown-menu shadow" ngbDropdownMenu>
<button ngbDropdownItem [class.active]="selectedTaskType === allFilterValue" (click)="setTaskType(allFilterValue)" i18n>All types</button>
@for (option of taskTypeOptions; track option.value) {
<button ngbDropdownItem [class.active]="selectedTaskType === option.value" [disabled]="isTaskTypeOptionDisabled(option.value)" (click)="setTaskType(option.value)">{{option.label}}</button>
}
</div>
</div>
<div ngbDropdown>
<button class="btn btn-sm btn-outline-primary" ngbDropdownToggle>{{selectedTriggerSourceLabel}}</button>
<div class="dropdown-menu shadow" ngbDropdownMenu>
<button ngbDropdownItem [class.active]="selectedTriggerSource === allFilterValue" (click)="setTriggerSource(allFilterValue)" i18n>All sources</button>
@for (option of triggerSourceOptions; track option.value) {
<button ngbDropdownItem [class.active]="selectedTriggerSource === option.value" [disabled]="isTriggerSourceOptionDisabled(option.value)" (click)="setTriggerSource(option.value)">{{option.label}}</button>
}
</div>
</div>
<div class="form-check form-switch mb-0 ms-2">
<input class="form-check-input" type="checkbox" role="switch" [(ngModel)]="autoRefreshEnabled">
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>

View File

@@ -29,6 +29,7 @@ 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 {
ALL_FILTER_VALUE,
ALL_TASK_SECTIONS,
TasksComponent,
TaskSection,
@@ -206,6 +207,8 @@ describe('TasksComponent', () => {
it('should display task sections with counts', () => {
expect(component.selectedSection).toBe(ALL_TASK_SECTIONS)
expect(component.selectedTaskType).toBe(ALL_FILTER_VALUE)
expect(component.selectedTriggerSource).toBe(ALL_FILTER_VALUE)
const buttons = fixture.debugElement.queryAll(By.css('.btn.btn-sm'))
const text = buttons.map((button) => button.nativeElement.textContent)
@@ -225,6 +228,54 @@ describe('TasksComponent', () => {
expect(fixture.nativeElement.textContent).not.toContain('Recent completed')
})
it('should filter tasks by task type', () => {
component.setSection(TaskSection.InProgress)
component.setTaskType(PaperlessTaskType.SanityCheck)
expect(component.tasksForSection(TaskSection.InProgress)).toHaveLength(1)
expect(component.tasksForSection(TaskSection.InProgress)[0].task_type).toBe(
PaperlessTaskType.SanityCheck
)
})
it('should filter tasks by trigger source', () => {
component.setSection(TaskSection.InProgress)
component.setTriggerSource(PaperlessTaskTriggerSource.EmailConsume)
expect(component.tasksForSection(TaskSection.InProgress)).toHaveLength(1)
expect(
component.tasksForSection(TaskSection.InProgress)[0].trigger_source
).toBe(PaperlessTaskTriggerSource.EmailConsume)
})
it('should expose stable task type options and disable empty ones', () => {
expect(component.taskTypeOptions.map((option) => option.value)).toContain(
PaperlessTaskType.TrainClassifier
)
expect(
component.isTaskTypeOptionDisabled(PaperlessTaskType.TrainClassifier)
).toBe(true)
expect(
component.isTaskTypeOptionDisabled(PaperlessTaskType.ConsumeFile)
).toBe(false)
})
it('should expose stable trigger source options and disable empty ones', () => {
expect(
component.triggerSourceOptions.map((option) => option.value)
).toContain(PaperlessTaskTriggerSource.ApiUpload)
expect(
component.isTriggerSourceOptionDisabled(
PaperlessTaskTriggerSource.ApiUpload
)
).toBe(true)
expect(
component.isTriggerSourceOptionDisabled(
PaperlessTaskTriggerSource.EmailConsume
)
).toBe(false)
})
it('should support expanding / collapsing one task at a time', () => {
component.expandTask(tasks[0])
expect(component.expandedTask).toEqual(tasks[0].id)
@@ -345,6 +396,17 @@ describe('TasksComponent', () => {
)
})
it('should match task type and source in name filtering', () => {
component.setSection(TaskSection.InProgress)
component.filterText = 'system'
jest.advanceTimersByTime(150)
expect(component.tasksForSection(TaskSection.InProgress)).toHaveLength(1)
expect(component.tasksForSection(TaskSection.InProgress)[0].task_type).toBe(
PaperlessTaskType.SanityCheck
)
})
it('should fall back to task type when filename is unavailable', () => {
component.setSection(TaskSection.InProgress)
fixture.detectChanges()

View File

@@ -18,7 +18,12 @@ import {
takeUntil,
timer,
} from 'rxjs'
import { PaperlessTask, PaperlessTaskStatus } from 'src/app/data/paperless-task'
import {
PaperlessTask,
PaperlessTaskStatus,
PaperlessTaskTriggerSource,
PaperlessTaskType,
} from 'src/app/data/paperless-task'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { TasksService } from 'src/app/services/tasks.service'
@@ -43,14 +48,57 @@ const FILTER_TARGETS = [
{ id: TaskFilterTargetID.Result, name: $localize`Result` },
]
export const ALL_TASK_SECTIONS = 'all'
export const ALL_FILTER_VALUE = 'all'
export const ALL_TASK_SECTIONS = ALL_FILTER_VALUE
const SECTION_LABELS: Record<TaskSection, string> = {
const SECTION_LABELS = {
[TaskSection.NeedsAttention]: $localize`Needs attention`,
[TaskSection.InProgress]: $localize`In progress`,
[TaskSection.Completed]: $localize`Recently completed`,
}
const TASK_TYPE_OPTIONS = [
{ value: PaperlessTaskType.ConsumeFile, label: $localize`Consume File` },
{
value: PaperlessTaskType.TrainClassifier,
label: $localize`Train Classifier`,
},
{ value: PaperlessTaskType.SanityCheck, label: $localize`Sanity Check` },
{ value: PaperlessTaskType.MailFetch, label: $localize`Mail Fetch` },
{ value: PaperlessTaskType.LlmIndex, label: $localize`AI Index` },
{ value: PaperlessTaskType.EmptyTrash, label: $localize`Empty Trash` },
{
value: PaperlessTaskType.CheckWorkflows,
label: $localize`Check Workflows`,
},
{ value: PaperlessTaskType.BulkUpdate, label: $localize`Bulk Update` },
{
value: PaperlessTaskType.ReprocessDocument,
label: $localize`Reprocess Document`,
},
{
value: PaperlessTaskType.BuildShareLink,
label: $localize`Build Share Link`,
},
{ value: PaperlessTaskType.BulkDelete, label: $localize`Bulk Delete` },
]
const TRIGGER_SOURCE_OPTIONS = [
{ value: PaperlessTaskTriggerSource.Scheduled, label: $localize`Scheduled` },
{ value: PaperlessTaskTriggerSource.WebUI, label: $localize`Web UI` },
{ value: PaperlessTaskTriggerSource.ApiUpload, label: $localize`API Upload` },
{
value: PaperlessTaskTriggerSource.FolderConsume,
label: $localize`Folder Consume`,
},
{
value: PaperlessTaskTriggerSource.EmailConsume,
label: $localize`Email Consume`,
},
{ value: PaperlessTaskTriggerSource.System, label: $localize`System` },
{ value: PaperlessTaskTriggerSource.Manual, label: $localize`Manual` },
]
@Component({
selector: 'pngx-tasks',
templateUrl: './tasks.component.html',
@@ -84,11 +132,14 @@ export class TasksComponent
TaskSection.Completed,
]
readonly allTaskSections = ALL_TASK_SECTIONS
readonly allFilterValue = ALL_FILTER_VALUE
public selectedTasks: Set<number> = new Set()
public expandedTask: number
public autoRefreshEnabled: boolean = true
public selectedSection: TaskSection | typeof ALL_TASK_SECTIONS =
ALL_TASK_SECTIONS
public selectedTaskType: string = ALL_FILTER_VALUE
public selectedTriggerSource: string = ALL_FILTER_VALUE
private _filterText: string = ''
get filterText() {
@@ -108,6 +159,38 @@ export class TasksComponent
return FILTER_TARGETS
}
public get taskTypeOptions(): Array<{ value: string; label: string }> {
return TASK_TYPE_OPTIONS
}
public get triggerSourceOptions(): Array<{ value: string; label: string }> {
return TRIGGER_SOURCE_OPTIONS
}
public get selectedTaskTypeLabel(): string {
if (this.selectedTaskType === ALL_FILTER_VALUE) {
return $localize`All types`
}
return (
this.taskTypeOptions.find(
(option) => option.value === this.selectedTaskType
)?.label ?? this.selectedTaskType
)
}
public get selectedTriggerSourceLabel(): string {
if (this.selectedTriggerSource === ALL_FILTER_VALUE) {
return $localize`All sources`
}
return (
this.triggerSourceOptions.find(
(option) => option.value === this.selectedTriggerSource
)?.label ?? this.selectedTriggerSource
)
}
get dismissButtonText(): string {
return this.selectedTasks.size > 0
? $localize`Dismiss selected`
@@ -238,21 +321,7 @@ export class TasksComponent
this.taskBelongsToSection(task, section)
)
if (this._filterText.length) {
tasks = tasks.filter((task) => {
if (this.filterTargetID == TaskFilterTargetID.Name) {
return this.taskDisplayName(task)
?.toLowerCase()
.includes(this._filterText.toLowerCase())
} else if (this.filterTargetID == TaskFilterTargetID.Result) {
return task.result_message
?.toLowerCase()
.includes(this._filterText.toLowerCase())
}
})
}
return tasks
return tasks.filter((task) => this.taskMatchesCurrentFilters(task))
}
sectionLabel(section: TaskSection): string {
@@ -274,6 +343,32 @@ export class TasksComponent
this.clearSelection()
}
setTaskType(taskType: string) {
this.selectedTaskType = taskType
this.clearSelection()
}
setTriggerSource(triggerSource: string) {
this.selectedTriggerSource = triggerSource
this.clearSelection()
}
taskTypeOptionCount(taskType: string): number {
return this.tasksForOptionCounts({ taskType }).length
}
triggerSourceOptionCount(triggerSource: string): number {
return this.tasksForOptionCounts({ triggerSource }).length
}
isTaskTypeOptionDisabled(taskType: string): boolean {
return this.taskTypeOptionCount(taskType) === 0
}
isTriggerSourceOptionDisabled(triggerSource: string): boolean {
return this.triggerSourceOptionCount(triggerSource) === 0
}
clearSelection() {
this.selectedTasks.clear()
}
@@ -309,4 +404,70 @@ export class TasksComponent
return task.status === PaperlessTaskStatus.Success
}
}
private taskMatchesCurrentFilters(task: PaperlessTask): boolean {
return this.taskMatchesFilters(task, {
taskType: this.selectedTaskType,
triggerSource: this.selectedTriggerSource,
})
}
private taskMatchesFilters(
task: PaperlessTask,
{
taskType,
triggerSource,
}: {
taskType: string
triggerSource: string
}
): boolean {
if (taskType !== ALL_FILTER_VALUE && task.task_type !== taskType) {
return false
}
if (
triggerSource !== ALL_FILTER_VALUE &&
task.trigger_source !== triggerSource
) {
return false
}
if (!this._filterText.length) {
return true
}
const query = this._filterText.toLowerCase()
if (this.filterTargetID == TaskFilterTargetID.Name) {
return [
this.taskDisplayName(task),
task.task_type_display,
task.trigger_source_display,
]
.filter(Boolean)
.some((value) => value.toLowerCase().includes(query))
}
return task.result_message?.toLowerCase().includes(query)
}
private tasksForOptionCounts({
taskType = this.selectedTaskType,
triggerSource = this.selectedTriggerSource,
}: {
taskType?: string
triggerSource?: string
}): PaperlessTask[] {
const sections =
this.selectedSection === ALL_TASK_SECTIONS
? this.sections
: [this.selectedSection]
return this.tasksService.allFileTasks.filter(
(task) =>
sections.some((section) => this.taskBelongsToSection(task, section)) &&
this.taskMatchesFilters(task, { taskType, triggerSource })
)
}
}