Chore: Paginate the task listing (#12633)

Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
This commit is contained in:
Trenton H
2026-04-24 10:31:37 -07:00
committed by GitHub
parent dbce393604
commit d6e45093e8
7 changed files with 260 additions and 64 deletions
@@ -98,6 +98,18 @@
<i-bs width="1em" height="1em" name="x"></i-bs><small i18n>Reset filters</small>
</button>
}
<ngb-pagination
class="ms-md-3 mb-0"
[pageSize]="pageSize"
[collectionSize]="totalTasks"
[page]="page"
[maxSize]="5"
[rotate]="true"
size="sm"
aria-label="Tasks pagination"
(pageChange)="setPage($event)">
</ngb-pagination>
</div>
<ng-template let-tasks="tasks" let-section="section" #tasksTemplate>
@@ -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<PaperlessTask> = {
count: tasks.length,
results: tasks,
}
describe('TasksComponent', () => {
let component: TasksComponent
let fixture: ComponentFixture<TasksComponent>
@@ -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())
})
@@ -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<number> = 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
},
})
}
}
+29 -9
View File
@@ -1,4 +1,8 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import {
HttpRequest,
provideHttpClient,
withInterceptorsFromDi,
} from '@angular/common/http'
import {
HttpTestingController,
provideHttpClientTesting,
@@ -37,16 +41,21 @@ describe('TasksService', () => {
it('calls tasks api endpoint on reload', () => {
tasksService.reload()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}tasks/?acknowledged=false`
(req: HttpRequest<unknown>) =>
req.url === `${environment.apiBaseUrl}tasks/` &&
req.params.get('acknowledged') === 'false' &&
req.params.get('page_size') === '1000'
)
expect(req.request.method).toEqual('GET')
req.flush({ count: 0, results: [] })
})
it('does not call tasks api endpoint on reload if already loading', () => {
tasksService.loading = true
tasksService.reload()
httpTestingController.expectNone(
`${environment.apiBaseUrl}tasks/?acknowledged=false`
(req: HttpRequest<unknown>) =>
req.url === `${environment.apiBaseUrl}tasks/`
)
})
@@ -62,8 +71,13 @@ describe('TasksService', () => {
req.flush([])
// reload is then called
httpTestingController
.expectOne(`${environment.apiBaseUrl}tasks/?acknowledged=false`)
.flush([])
.expectOne(
(req: HttpRequest<unknown>) =>
req.url === `${environment.apiBaseUrl}tasks/` &&
req.params.get('acknowledged') === 'false' &&
req.params.get('page_size') === '1000'
)
.flush({ count: 0, results: [] })
})
it('groups mixed task types by status when reloading', () => {
@@ -124,10 +138,13 @@ describe('TasksService', () => {
tasksService.reload()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}tasks/?acknowledged=false`
(req: HttpRequest<unknown>) =>
req.url === `${environment.apiBaseUrl}tasks/` &&
req.params.get('acknowledged') === 'false' &&
req.params.get('page_size') === '1000'
)
req.flush(mockTasks)
req.flush({ count: mockTasks.length, results: mockTasks })
expect(tasksService.allFileTasks).toHaveLength(5)
expect(tasksService.completedFileTasks).toHaveLength(2)
@@ -173,10 +190,13 @@ describe('TasksService', () => {
tasksService.reload()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}tasks/?acknowledged=false`
(req: HttpRequest<unknown>) =>
req.url === `${environment.apiBaseUrl}tasks/` &&
req.params.get('acknowledged') === 'false' &&
req.params.get('page_size') === '1000'
)
req.flush(mockTasks)
req.flush({ count: mockTasks.length, results: mockTasks })
expect(tasksService.needsAttentionTasks).toHaveLength(2)
expect(tasksService.needsAttentionTasks.map((task) => task.status)).toEqual(
+27 -4
View File
@@ -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<PaperlessTask[]>(
`${this.baseUrl}${this.endpoint}/?acknowledged=false`
)
.get<Results<PaperlessTask>>(`${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<string, string | number | boolean>
): Observable<Results<PaperlessTask>> {
return this.http.get<Results<PaperlessTask>>(
`${this.baseUrl}${this.endpoint}/`,
{
params: {
page,
page_size: pageSize,
...extraParams,
},
}
)
}
public dismissTasks(task_ids: Set<number>): Observable<any> {
return this.http
.post(`${this.baseUrl}tasks/acknowledge/`, {