From ce1661aa57bcfd8c172242c6c7d68ad22dbc948d Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:13:04 -0700 Subject: [PATCH] Ok, move to tasks grouped by priority --- .../admin/tasks/tasks.component.html | 127 ++++++------ .../admin/tasks/tasks.component.spec.ts | 125 ++++-------- .../components/admin/tasks/tasks.component.ts | 185 +++++++++++------- 3 files changed, 206 insertions(+), 231 deletions(-) diff --git a/src-ui/src/app/components/admin/tasks/tasks.component.html b/src-ui/src/app/components/admin/tasks/tasks.component.html index 71ad4ddfa..c45b448b3 100644 --- a/src-ui/src/app/components/admin/tasks/tasks.component.html +++ b/src-ui/src/app/components/admin/tasks/tasks.component.html @@ -8,24 +8,20 @@ -
Filter by - @if (filterTargets.length > 1) { -
- - +
+ + - } @else { - {{filterTargetName}} - } +
@if (filterText?.length) { + @for (section of sections; track section) { + + } +
+ + +
+
+
{{ sectionLabel(section) }}
+
+ {tasks.length, plural, =1 {1 task} other {{{tasks.length}} tasks}} +
+
+
+ - @if (activeTab !== 'started' && activeTab !== 'queued') { + @if (sectionShowsResults(section)) { } @@ -68,11 +95,11 @@ - @for (task of tasks | slice: (page-1) * pageSize : page * pageSize; track task.id) { - + @for (task of tasks; track task.id) { + @@ -87,7 +114,7 @@ - @if (activeTab !== 'started' && activeTab !== 'queued') { + @if (sectionShowsResults(section)) { - }
- - + +
Name CreatedResultsInfo
- +
{{ task.date_created | customDate:'short' }} @if (task.result_message?.length > 50) {
+
{{ task.result_message }}
- -
- @if (tasks.length > 0) { -
- {tasks.length, plural, =1 {One {{this.activeTabLocalized}} task} other {{{tasks.length || 0}} total {{this.activeTabLocalized}} tasks}} - @if (selectedTasks.size > 0) { -  ({{selectedTasks.size}} selected) - } -
- } - @if (tasks.length > pageSize) { - - } -
- -
+@if (visibleSections.length > 0) { + @for (section of visibleSections; track section) { +
+ +
+ } +} @else { +
No tasks match the current filters.
+} diff --git a/src-ui/src/app/components/admin/tasks/tasks.component.spec.ts b/src-ui/src/app/components/admin/tasks/tasks.component.spec.ts index 9e8da60be..a352daa7d 100644 --- a/src-ui/src/app/components/admin/tasks/tasks.component.spec.ts +++ b/src-ui/src/app/components/admin/tasks/tasks.component.spec.ts @@ -9,12 +9,7 @@ import { FormsModule } from '@angular/forms' import { By } from '@angular/platform-browser' import { Router } from '@angular/router' import { RouterTestingModule } from '@angular/router/testing' -import { - NgbModal, - NgbModalRef, - NgbModule, - NgbNavItem, -} from '@ng-bootstrap/ng-bootstrap' +import { NgbModal, NgbModalRef, NgbModule } from '@ng-bootstrap/ng-bootstrap' import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { throwError } from 'rxjs' import { routes } from 'src/app/app-routing.module' @@ -33,7 +28,11 @@ import { ToastService } from 'src/app/services/toast.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, TaskTab } from './tasks.component' +import { + ALL_TASK_SECTIONS, + TasksComponent, + TaskSection, +} from './tasks.component' const tasks: PaperlessTask[] = [ { @@ -205,53 +204,25 @@ describe('TasksComponent', () => { .flush(tasks) }) - it('should display file tasks in 4 tabs by status', () => { - const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavItem)) + it('should display task sections with counts', () => { + expect(component.selectedSection).toBe(ALL_TASK_SECTIONS) - let currentTasksLength = tasks.filter( - (t) => t.status === PaperlessTaskStatus.Failure - ).length - component.activeTab = TaskTab.Failed - fixture.detectChanges() - expect(tabButtons[0].nativeElement.textContent).toEqual( - `Failed${currentTasksLength}` - ) - expect( - fixture.debugElement.queryAll(By.css('table input[type="checkbox"]')) - ).toHaveLength(currentTasksLength + 1) + const buttons = fixture.debugElement.queryAll(By.css('.btn.btn-sm')) + const text = buttons.map((button) => button.nativeElement.textContent) - currentTasksLength = tasks.filter( - (t) => t.status === PaperlessTaskStatus.Success - ).length - component.activeTab = TaskTab.Completed - fixture.detectChanges() - expect(tabButtons[1].nativeElement.textContent).toEqual( - `Complete${currentTasksLength}` - ) - - currentTasksLength = tasks.filter( - (t) => t.status === PaperlessTaskStatus.Started - ).length - component.activeTab = TaskTab.Started - fixture.detectChanges() - expect(tabButtons[2].nativeElement.textContent).toEqual( - `Started${currentTasksLength}` - ) - - currentTasksLength = tasks.filter( - (t) => t.status === PaperlessTaskStatus.Pending - ).length - component.activeTab = TaskTab.Queued - fixture.detectChanges() - expect(tabButtons[3].nativeElement.textContent).toEqual( - `Queued${currentTasksLength}` - ) + expect(text.join(' ')).toContain('All') + expect(text.join(' ')).toContain('Needs attention2') + expect(text.join(' ')).toContain('In progress2') + expect(text.join(' ')).toContain('Recent completed2') }) - it('should to go page 1 between tab switch', () => { - component.page = 10 - component.duringTabChange() - expect(component.page).toEqual(1) + it('should filter visible sections by selected status', () => { + component.setSection(TaskSection.InProgress) + fixture.detectChanges() + + expect(component.visibleSections).toEqual([TaskSection.InProgress]) + expect(fixture.nativeElement.textContent).toContain('In progress') + expect(fixture.nativeElement.textContent).not.toContain('Recent completed') }) it('should support expanding / collapsing one task at a time', () => { @@ -273,7 +244,7 @@ describe('TasksComponent', () => { component.toggleSelected(tasks[0]) component.toggleSelected(tasks[1]) component.toggleSelected(tasks[3]) - component.toggleSelected(tasks[3]) // uncheck, for coverage + component.toggleSelected(tasks[3]) const selected = new Set([tasks[0].id, tasks[1].id]) expect(component.selectedTasks).toEqual(selected) let modal: NgbModalRef @@ -322,32 +293,24 @@ describe('TasksComponent', () => { expect(component.selectedTasks.size).toBe(0) }) - it('should support dismiss all tasks', () => { + it('should support dismiss visible tasks', () => { + component.setSection(TaskSection.NeedsAttention) let modal: NgbModalRef modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1])) const dismissSpy = jest.spyOn(tasksService, 'dismissTasks') component.dismissTasks() expect(modal).not.toBeUndefined() modal.componentInstance.confirmClicked.emit() - expect(dismissSpy).toHaveBeenCalledWith(new Set(tasks.map((t) => t.id))) + expect(dismissSpy).toHaveBeenCalledWith(new Set([467, 466])) }) - it('should support toggle all tasks', () => { + it('should support toggling a full section', () => { const toggleCheck = fixture.debugElement.query( - By.css('table input[type=checkbox]') + By.css('#all-tasks-needs_attention') ) toggleCheck.nativeElement.dispatchEvent(new MouseEvent('click')) fixture.detectChanges() - expect(component.selectedTasks).toEqual( - new Set( - tasks - .filter((t) => t.status === PaperlessTaskStatus.Failure) - .map((t) => t.id) - ) - ) - toggleCheck.nativeElement.dispatchEvent(new MouseEvent('click')) - fixture.detectChanges() - expect(component.selectedTasks).toEqual(new Set()) + expect(component.selectedTasks).toEqual(new Set([467, 466])) }) it('should support dismiss and open a document', () => { @@ -374,16 +337,16 @@ describe('TasksComponent', () => { ) input.nativeElement.value = '191092' input.nativeElement.dispatchEvent(new Event('input')) - jest.advanceTimersByTime(150) // debounce time + jest.advanceTimersByTime(150) fixture.detectChanges() expect(component.filterText).toEqual('191092') - expect( - fixture.debugElement.queryAll(By.css('table tbody tr')).length - ).toEqual(2) // 1 task x 2 lines + expect(component.tasksForSection(TaskSection.NeedsAttention)).toHaveLength( + 1 + ) }) it('should fall back to task type when filename is unavailable', () => { - component.activeTab = TaskTab.Started + component.setSection(TaskSection.InProgress) fixture.detectChanges() const nameColumn = fixture.debugElement.queryAll( @@ -398,20 +361,19 @@ describe('TasksComponent', () => { }) it('should filter tasks by result', () => { - component.activeTab = TaskTab.Failed - fixture.detectChanges() + component.setSection(TaskSection.NeedsAttention) component.filterTargetID = 1 const input = fixture.debugElement.query( By.css('pngx-page-header input[type=text]') ) input.nativeElement.value = 'duplicate' input.nativeElement.dispatchEvent(new Event('input')) - jest.advanceTimersByTime(150) // debounce time + jest.advanceTimersByTime(150) fixture.detectChanges() expect(component.filterText).toEqual('duplicate') - expect( - fixture.debugElement.queryAll(By.css('table tbody tr')).length - ).toEqual(4) // 2 tasks x 2 lines + expect(component.tasksForSection(TaskSection.NeedsAttention)).toHaveLength( + 2 + ) }) it('should support keyboard events for filtering', () => { @@ -422,19 +384,10 @@ describe('TasksComponent', () => { input.nativeElement.dispatchEvent( new KeyboardEvent('keyup', { key: 'Enter' }) ) - expect(component.filterText).toEqual('191092') // no debounce needed + expect(component.filterText).toEqual('191092') 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) - }) }) diff --git a/src-ui/src/app/components/admin/tasks/tasks.component.ts b/src-ui/src/app/components/admin/tasks/tasks.component.ts index 464e9fe54..ef18394bf 100644 --- a/src-ui/src/app/components/admin/tasks/tasks.component.ts +++ b/src-ui/src/app/components/admin/tasks/tasks.component.ts @@ -1,4 +1,4 @@ -import { NgTemplateOutlet, SlicePipe } from '@angular/common' +import { NgTemplateOutlet } from '@angular/common' import { Component, inject, OnDestroy, OnInit } from '@angular/core' import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { Router } from '@angular/router' @@ -6,8 +6,6 @@ import { NgbCollapseModule, NgbDropdownModule, NgbModal, - NgbNavModule, - NgbPaginationModule, NgbPopoverModule, } from '@ng-bootstrap/ng-bootstrap' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' @@ -20,7 +18,7 @@ import { takeUntil, timer, } from 'rxjs' -import { PaperlessTask } from 'src/app/data/paperless-task' +import { PaperlessTask, PaperlessTaskStatus } 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' @@ -29,11 +27,10 @@ import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dial import { PageHeaderComponent } from '../../common/page-header/page-header.component' import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' -export enum TaskTab { - Queued = 'queued', - Started = 'started', +export enum TaskSection { + NeedsAttention = 'needs_attention', + InProgress = 'in_progress', Completed = 'completed', - Failed = 'failed', } enum TaskFilterTargetID { @@ -46,6 +43,14 @@ const FILTER_TARGETS = [ { id: TaskFilterTargetID.Result, name: $localize`Result` }, ] +export const ALL_TASK_SECTIONS = 'all' + +const SECTION_LABELS: Record = { + [TaskSection.NeedsAttention]: $localize`Needs attention`, + [TaskSection.InProgress]: $localize`In progress`, + [TaskSection.Completed]: $localize`Recently completed`, +} + @Component({ selector: 'pngx-tasks', templateUrl: './tasks.component.html', @@ -54,14 +59,11 @@ const FILTER_TARGETS = [ PageHeaderComponent, IfPermissionsDirective, CustomDatePipe, - SlicePipe, FormsModule, ReactiveFormsModule, NgTemplateOutlet, NgbCollapseModule, NgbDropdownModule, - NgbNavModule, - NgbPaginationModule, NgbPopoverModule, NgxBootstrapIconsModule, ], @@ -75,15 +77,18 @@ export class TasksComponent private readonly router = inject(Router) private readonly toastService = inject(ToastService) - public activeTab: TaskTab + readonly TaskSection = TaskSection + readonly sections = [ + TaskSection.NeedsAttention, + TaskSection.InProgress, + TaskSection.Completed, + ] + readonly allTaskSections = ALL_TASK_SECTIONS public selectedTasks: Set = new Set() - public togggleAll: boolean = false public expandedTask: number - - public pageSize: number = 25 - public page: number = 1 - public autoRefreshEnabled: boolean = true + public selectedSection: TaskSection | typeof ALL_TASK_SECTIONS = + ALL_TASK_SECTIONS private _filterText: string = '' get filterText() { @@ -95,20 +100,35 @@ export class TasksComponent public filterTargetID: TaskFilterTargetID = TaskFilterTargetID.Name public get filterTargetName(): string { - return this.filterTargets.find((t) => t.id == this.filterTargetID).name + return FILTER_TARGETS.find((t) => t.id == this.filterTargetID).name } private filterDebounce: Subject = new Subject() public get filterTargets(): Array<{ id: number; name: string }> { - return [TaskTab.Failed, TaskTab.Completed].includes(this.activeTab) - ? FILTER_TARGETS - : FILTER_TARGETS.slice(0, 1) + return FILTER_TARGETS } get dismissButtonText(): string { return this.selectedTasks.size > 0 ? $localize`Dismiss selected` - : $localize`Dismiss all` + : $localize`Dismiss visible` + } + + get visibleSections(): TaskSection[] { + const sections = + this.selectedSection === ALL_TASK_SECTIONS + ? this.sections + : [this.selectedSection] + + return sections.filter( + (section) => this.tasksForSection(section).length > 0 + ) + } + + get visibleTasks(): PaperlessTask[] { + return this.visibleSections.flatMap((section) => + this.tasksForSection(section) + ) } ngOnInit() { @@ -143,14 +163,16 @@ export class TasksComponent dismissTasks(task: PaperlessTask = undefined) { let tasks = task ? new Set([task.id]) : new Set(this.selectedTasks.values()) - if (!task && tasks.size == 0) - tasks = new Set(this.tasksService.allFileTasks.map((t) => t.id)) + if (!task && tasks.size == 0) { + tasks = new Set(this.visibleTasks.map((t) => t.id)) + } + if (tasks.size > 1) { let modal = this.modalService.open(ConfirmDialogComponent, { backdrop: 'static', }) - modal.componentInstance.title = $localize`Confirm Dismiss All` - modal.componentInstance.messageBold = $localize`Dismiss all ${tasks.size} tasks?` + modal.componentInstance.title = $localize`Confirm Dismiss` + modal.componentInstance.messageBold = $localize`Dismiss ${tasks.size} tasks?` modal.componentInstance.btnClass = 'btn-warning' modal.componentInstance.btnCaption = $localize`Dismiss` modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => { @@ -164,7 +186,7 @@ export class TasksComponent }) this.clearSelection() }) - } else { + } else if (tasks.size === 1) { this.tasksService.dismissTasks(tasks).subscribe({ error: (e) => this.toastService.showError($localize`Error dismissing task`, e), @@ -188,6 +210,21 @@ export class TasksComponent : this.selectedTasks.add(task.id) } + toggleSection(section: TaskSection, event: PointerEvent) { + const sectionTasks = this.tasksForSection(section) + if ((event.target as HTMLInputElement).checked) { + sectionTasks.forEach((task) => this.selectedTasks.add(task.id)) + } else { + sectionTasks.forEach((task) => this.selectedTasks.delete(task.id)) + } + } + + areAllSelected(tasks: PaperlessTask[]): boolean { + return ( + tasks.length > 0 && tasks.every((task) => this.selectedTasks.has(task.id)) + ) + } + taskDisplayName(task: PaperlessTask): string { return task.input_data?.filename?.toString() || task.task_type_display } @@ -196,73 +233,51 @@ export class TasksComponent return this.taskDisplayName(task) !== task.task_type_display } - get currentTasks(): PaperlessTask[] { - let tasks: PaperlessTask[] = [] - switch (this.activeTab) { - case TaskTab.Queued: - tasks = this.tasksService.queuedFileTasks - break - case TaskTab.Started: - tasks = this.tasksService.startedFileTasks - break - case TaskTab.Completed: - tasks = this.tasksService.completedFileTasks - break - case TaskTab.Failed: - tasks = this.tasksService.failedFileTasks - break - } + tasksForSection(section: TaskSection): PaperlessTask[] { + let tasks = this.tasksService.allFileTasks.filter((task) => + this.taskBelongsToSection(task, section) + ) + if (this._filterText.length) { - tasks = tasks.filter((t) => { + tasks = tasks.filter((task) => { if (this.filterTargetID == TaskFilterTargetID.Name) { - return this.taskDisplayName(t) + return this.taskDisplayName(task) ?.toLowerCase() .includes(this._filterText.toLowerCase()) } else if (this.filterTargetID == TaskFilterTargetID.Result) { - return t.result_message + return task.result_message ?.toLowerCase() .includes(this._filterText.toLowerCase()) } }) } + return tasks } - toggleAll(event: PointerEvent) { - if ((event.target as HTMLInputElement).checked) { - this.selectedTasks = new Set(this.currentTasks.map((t) => t.id)) - } else { - this.clearSelection() - } + sectionLabel(section: TaskSection): string { + return SECTION_LABELS[section] + } + + sectionCount(section: TaskSection): number { + return this.tasksService.allFileTasks.filter((task) => + this.taskBelongsToSection(task, section) + ).length + } + + sectionShowsResults(section: TaskSection): boolean { + return section !== TaskSection.InProgress + } + + setSection(section: TaskSection | typeof ALL_TASK_SECTIONS) { + this.selectedSection = section + this.clearSelection() } clearSelection() { - this.togggleAll = false this.selectedTasks.clear() } - duringTabChange() { - this.page = 1 - } - - beforeTabChange() { - this.resetFilter() - this.filterTargetID = TaskFilterTargetID.Name - } - - get activeTabLocalized(): string { - switch (this.activeTab) { - case TaskTab.Queued: - return $localize`queued` - case TaskTab.Started: - return $localize`started` - case TaskTab.Completed: - return $localize`completed` - case TaskTab.Failed: - return $localize`failed` - } - } - public resetFilter() { this._filterText = '' } @@ -274,4 +289,24 @@ export class TasksComponent this.resetFilter() } } + + private taskBelongsToSection( + task: PaperlessTask, + section: TaskSection + ): boolean { + switch (section) { + case TaskSection.NeedsAttention: + return [ + PaperlessTaskStatus.Failure, + PaperlessTaskStatus.Revoked, + ].includes(task.status) + case TaskSection.InProgress: + return [ + PaperlessTaskStatus.Pending, + PaperlessTaskStatus.Started, + ].includes(task.status) + case TaskSection.Completed: + return task.status === PaperlessTaskStatus.Success + } + } }