From 746ad74888c08bf71dd71dd1f6914371668d6d17 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Thu, 23 Apr 2026 16:42:40 -0700 Subject: [PATCH] Support pagination of tasks on frontend --- .../admin/tasks/tasks.component.html | 12 ++++ .../admin/tasks/tasks.component.spec.ts | 62 +++++++++++++++++- .../components/admin/tasks/tasks.component.ts | 64 +++++++++++++++++-- src-ui/src/app/services/tasks.service.ts | 31 +++++++-- 4 files changed, 158 insertions(+), 11 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 8fd5ce174..256689e1c 100644 --- a/src-ui/src/app/components/admin/tasks/tasks.component.html +++ b/src-ui/src/app/components/admin/tasks/tasks.component.html @@ -98,6 +98,18 @@ Reset 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 161f3cdbd..f6f3405bf 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 @@ -19,6 +19,7 @@ import { PaperlessTaskTriggerSource, PaperlessTaskType, } from 'src/app/data/paperless-task' +import { Results } from 'src/app/data/results' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { PermissionsGuard } from 'src/app/guards/permissions.guard' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' @@ -148,6 +149,11 @@ const tasks: PaperlessTask[] = [ }, ] +const paginatedTasks: Results = { + count: tasks.length, + results: tasks, +} + describe('TasksComponent', () => { let component: TasksComponent let fixture: ComponentFixture @@ -196,9 +202,25 @@ describe('TasksComponent', () => { component = fixture.componentInstance jest.useFakeTimers() fixture.detectChanges() + httpTestingController - .expectOne(`${environment.apiBaseUrl}tasks/?acknowledged=false`) - .flush(tasks) + .expectOne( + (req) => + req.url === `${environment.apiBaseUrl}tasks/` && + req.params.get('acknowledged') === 'false' && + req.params.get('page_size') === '1000' + ) + .flush(paginatedTasks) + + httpTestingController + .expectOne( + (req) => + req.url === `${environment.apiBaseUrl}tasks/` && + req.params.get('acknowledged') === 'false' && + req.params.get('page_size') === '25' && + req.params.get('page') === '1' + ) + .flush(paginatedTasks) }) it('should display task sections with counts', () => { @@ -294,6 +316,40 @@ describe('TasksComponent', () => { ).toBeTruthy() }) + it('should render pagination controls next to the task filter', () => { + fixture.detectChanges() + + const controls = fixture.debugElement.query(By.css('.task-controls')) + const search = controls.query(By.css('.task-search')) + const pagination = controls.query(By.css('ngb-pagination')) + + expect(search).not.toBeNull() + expect(pagination).not.toBeNull() + }) + + it('should load a different task page when pagination changes', () => { + component.setPage(2) + + const pageTwoTasks = { + count: 30, + results: [tasks[0]], + } + + httpTestingController + .expectOne( + (req) => + req.url === `${environment.apiBaseUrl}tasks/` && + req.params.get('acknowledged') === 'false' && + req.params.get('page_size') === '25' && + req.params.get('page') === '2' + ) + .flush(pageTwoTasks) + + expect(component.page).toBe(2) + expect(component.totalTasks).toBe(30) + expect(component.pagedTasks).toEqual([tasks[0]]) + }) + it('should expose stable task type options and disable empty ones', () => { expect(component.taskTypeOptions.map((option) => option.value)).toContain( PaperlessTaskType.TrainClassifier @@ -470,7 +526,7 @@ describe('TasksComponent', () => { component.toggleSection(TaskSection.NeedsAttention, { target: { checked: false }, - } as PointerEvent) + } as unknown as PointerEvent) expect(component.selectedTasks).toEqual(new Set()) }) 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 7e8155bac..d9a57edce 100644 --- a/src-ui/src/app/components/admin/tasks/tasks.component.ts +++ b/src-ui/src/app/components/admin/tasks/tasks.component.ts @@ -6,6 +6,7 @@ import { NgbCollapseModule, NgbDropdownModule, NgbModal, + NgbPaginationModule, NgbPopoverModule, } from '@ng-bootstrap/ng-bootstrap' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' @@ -139,6 +140,7 @@ const TRIGGER_SOURCE_OPTIONS: Array<{ NgTemplateOutlet, NgbCollapseModule, NgbDropdownModule, + NgbPaginationModule, NgbPopoverModule, NgxBootstrapIconsModule, ], @@ -161,6 +163,10 @@ export class TasksComponent public selectedTasks: Set = new Set() public expandedTask: number public autoRefreshEnabled: boolean = true + public readonly pageSize = 25 + public page: number = 1 + public totalTasks: number = 0 + public pagedTasks: PaperlessTask[] = [] public selectedSection: TaskSection = TaskSection.All public selectedTaskType: PaperlessTaskType | null = null public selectedTriggerSource: PaperlessTaskTriggerSource | null = null @@ -254,6 +260,7 @@ export class TasksComponent ngOnInit() { this.tasksService.reload() + this.reloadPage() timer(5000, 5000) .pipe( filter(() => this.autoRefreshEnabled), @@ -261,6 +268,7 @@ export class TasksComponent ) .subscribe(() => { this.tasksService.reload() + this.reloadPage(false) }) this.filterDebounce @@ -270,7 +278,10 @@ export class TasksComponent distinctUntilChanged(), filter((query) => !query.length || query.length > 2) ) - .subscribe((query) => (this._filterText = query)) + .subscribe((query) => { + this._filterText = query + this.clearSelection() + }) } ngOnDestroy() { @@ -300,6 +311,9 @@ export class TasksComponent modal.componentInstance.buttonsEnabled = false modal.close() this.tasksService.dismissTasks(tasks).subscribe({ + next: () => { + this.reloadPage(false) + }, error: (e) => { this.toastService.showError($localize`Error dismissing tasks`, e) modal.componentInstance.buttonsEnabled = true @@ -309,6 +323,9 @@ export class TasksComponent }) } else if (tasks.size === 1) { this.tasksService.dismissTasks(tasks).subscribe({ + next: () => { + this.reloadPage(false) + }, error: (e) => this.toastService.showError($localize`Error dismissing task`, e), }) @@ -421,7 +438,7 @@ export class TasksComponent } tasksForSection(section: TaskSection): PaperlessTask[] { - let tasks = this.tasksService.allFileTasks.filter((task) => + let tasks = this.pagedTasks.filter((task) => this.taskBelongsToSection(task, section) ) @@ -433,7 +450,7 @@ export class TasksComponent } sectionCount(section: TaskSection): number { - return this.tasksService.allFileTasks.filter((task) => + return this.pagedTasks.filter((task) => this.taskBelongsToSection(task, section) ).length } @@ -481,6 +498,16 @@ export class TasksComponent this.selectedTasks.clear() } + setPage(page: number) { + if (this.page === page) { + return + } + + this.page = page + this.clearSelection() + this.reloadPage() + } + public resetFilter() { this._filterText = '' } @@ -576,10 +603,39 @@ export class TasksComponent ? this.sections : [this.selectedSection] - return this.tasksService.allFileTasks.filter( + return this.pagedTasks.filter( (task) => sections.some((section) => this.taskBelongsToSection(task, section)) && this.taskMatchesFilters(task, { taskType, triggerSource }) ) } + + private reloadPage(resetToFirstPage: boolean = false) { + if (resetToFirstPage) { + this.page = 1 + } + + this.loading = true + this.tasksService + .list(this.page, this.pageSize, { acknowledged: false }) + .pipe(first(), takeUntil(this.unsubscribeNotifier)) + .subscribe({ + next: (result) => { + this.pagedTasks = result.results + this.totalTasks = result.count + this.loading = false + if ( + this.page > 1 && + this.pagedTasks.length === 0 && + this.totalTasks > 0 + ) { + this.page -= 1 + this.reloadPage() + } + }, + error: () => { + this.loading = false + }, + }) + } } diff --git a/src-ui/src/app/services/tasks.service.ts b/src-ui/src/app/services/tasks.service.ts index c598d2b73..1eb5e0837 100644 --- a/src-ui/src/app/services/tasks.service.ts +++ b/src-ui/src/app/services/tasks.service.ts @@ -1,12 +1,13 @@ import { HttpClient } from '@angular/common/http' import { Injectable, inject } from '@angular/core' import { Observable, Subject } from 'rxjs' -import { first, takeUntil, tap } from 'rxjs/operators' +import { first, map, takeUntil, tap } from 'rxjs/operators' import { PaperlessTask, PaperlessTaskStatus, PaperlessTaskType, } from 'src/app/data/paperless-task' +import { Results } from 'src/app/data/results' import { environment } from 'src/environments/environment' @Injectable({ @@ -17,6 +18,7 @@ export class TasksService { private baseUrl: string = environment.apiBaseUrl private endpoint: string = 'tasks' + private readonly defaultReloadPageSize = 1000 public loading: boolean = false @@ -69,9 +71,13 @@ export class TasksService { this.loading = true this.http - .get( - `${this.baseUrl}${this.endpoint}/?acknowledged=false` - ) + .get>(`${this.baseUrl}${this.endpoint}/`, { + params: { + acknowledged: 'false', + page_size: this.defaultReloadPageSize, + }, + }) + .pipe(map((r) => r.results)) .pipe(takeUntil(this.unsubscribeNotifer), first()) .subscribe((r) => { this.fileTasks = r @@ -79,6 +85,23 @@ export class TasksService { }) } + public list( + page: number, + pageSize: number, + extraParams?: Record + ): Observable> { + return this.http.get>( + `${this.baseUrl}${this.endpoint}/`, + { + params: { + page, + page_size: pageSize, + ...extraParams, + }, + } + ) + } + public dismissTasks(task_ids: Set): Observable { return this.http .post(`${this.baseUrl}tasks/acknowledge/`, {