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/`, {