Fix: dont paginate 'all' tasks view across sections (#14484)

This commit is contained in:
shamoon authored and GitHub committed 2026-10-18 20:22:18 -07:00
1 parent a8b835fcd0
commit b1738c85d7
5 files changed
+161 -62

No files matched your search

@@ -102,25 +102,34 @@
</button>
}
<ngb-pagination
[pageSize]="pageSize"
[collectionSize]="totalTasks()"
[page]="page"
[maxSize]="5"
[rotate]="true"
size="sm"
aria-label="Tasks pagination"
(pageChange)="setPage($event)">
</ngb-pagination>
@if (selectedSection !== TaskSection.All && totalTasks() > pageSize) {
<ngb-pagination
[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>
<div class="section-header d-flex align-items-center justify-content-between mb-2">
<div>
<h5 class="mb-0">{{ sectionLabel(section) }}</h5>
<div class="small text-muted">
<ng-container i18n>{tasks.length, plural, =1 {1 task} other {{{tasks.length}} tasks}}</ng-container>
</div>
<div class="section-header mb-2">
<h5 class="mb-0">{{ sectionLabel(section) }}</h5>
@let count = sectionCount(section);
@let hasMore = selectedSection === TaskSection.All && count > tasks.length;
<div class="small text-muted">
@if (hasMore) {
<ng-container i18n>{{tasks.length}} of {{count}} tasks</ng-container>
<button class="btn btn-link show-all p-0 ms-2 align-baseline" type="button" (click)="setSection(section)">
<ng-container i18n>Show all</ng-container><i-bs class="ms-1" width="1em" height="1em" name="arrow-right"></i-bs>
</button>
} @else {
<ng-container i18n>{count, plural, =1 {1 task} other {{{count}} tasks}}</ng-container>
}
</div>
</div>
@@ -169,10 +178,9 @@
<div>{{ taskDisplayName(task) }}</div>
<div class="small text-muted">
@if (taskShowsSeparateTypeLabel(task)) {
<span>{{ task.task_type_display }}</span>
<span class="mx-1">&bull;</span>
<span class="me-2">{{ task.task_type_display }}</span>
}
<span>{{ task.trigger_source_display }}</span>
<span class="text-nowrap"><i-bs class="me-1" width="1em" height="1em" name="box-arrow-in-right"></i-bs>{{ task.trigger_source_display }}</span>
</div>
</td>
<td class="d-none d-lg-table-cell created-col">{{ task.date_created | customDate:'short' }}</td>
@@ -72,3 +72,7 @@ pre {
tbody tr:nth-last-child(2) td {
border-bottom: none !important;
}
.section-header .show-all {
font-size: inherit;
}
@@ -223,16 +223,6 @@ describe('TasksComponent', () => {
)
.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)
httpTestingController
.expectOne(
(req) =>
@@ -241,8 +231,30 @@ describe('TasksComponent', () => {
!req.params.has('status')
)
.flush(sectionCountResponse)
flushTaskLists(10)
})
// answers task list requests with the fixture tasks matching their status filter
function flushTaskLists(pageSize: number, count?: number) {
httpTestingController
.match(
(req) =>
req.url === `${environment.apiBaseUrl}tasks/` &&
req.params.get('page_size') === `${pageSize}`
)
.forEach((req) => {
const statuses = req.request.params.getAll('status')
const results = tasks.filter((task) => statuses.includes(task.status))
req.flush({ count: count ?? results.length, results })
})
}
function selectSection(section: TaskSection, count: number) {
component.setSection(section)
flushTaskLists(25, count)
}
it('should display task sections with counts', () => {
expect(component.selectedSection).toBe(TaskSection.All)
expect(component.selectedTaskType).toBeNull()
@@ -338,6 +350,7 @@ describe('TasksComponent', () => {
})
it('should render pagination controls next to the task filter', () => {
selectSection(TaskSection.Completed, 30)
fixture.detectChanges()
const controls = fixture.debugElement.query(By.css('.task-controls'))
@@ -348,6 +361,51 @@ describe('TasksComponent', () => {
expect(pagination).not.toBeNull()
})
it('should only paginate a section with more than one page of tasks', () => {
const pagination = () =>
fixture.debugElement.query(By.css('ngb-pagination'))
fixture.detectChanges()
expect(pagination()).toBeNull()
selectSection(TaskSection.Completed, 2)
fixture.detectChanges()
expect(pagination()).toBeNull()
})
it('should load the all tasks overview one section at a time', () => {
expect(component.tasksForSection(TaskSection.NeedsAttention)).toHaveLength(
2
)
expect(component.tasksForSection(TaskSection.InProgress)).toHaveLength(3)
expect(component.tasksForSection(TaskSection.Completed)).toHaveLength(2)
expect(component.totalTasks()).toBe(7)
})
it('should show section totals and link truncated sections to their full list', () => {
component.sectionCounts.update((counts) => ({
...counts,
[TaskSection.Completed]: 27,
}))
fixture.detectChanges()
const header = (label: string) =>
fixture.debugElement
.queryAll(By.css('.section-header'))
.find((h) => h.nativeElement.textContent.includes(label))
expect(header('Needs attention').nativeElement.textContent).toContain(
'2 tasks'
)
expect(header('Needs attention').query(By.css('button'))).toBeNull()
expect(header('Recently completed').nativeElement.textContent).toContain(
'2 of 27 tasks'
)
header('Recently completed').query(By.css('button')).nativeElement.click()
expect(component.selectedSection).toBe(TaskSection.Completed)
})
it('should apply the selected section to the server-side task query', () => {
component.setSection(TaskSection.NeedsAttention)
@@ -368,55 +426,56 @@ describe('TasksComponent', () => {
it('should apply task type and trigger source filters to the server-side task query', () => {
component.setTaskType(PaperlessTaskType.SanityCheck)
httpTestingController
.expectOne(
expect(
httpTestingController.match(
(request) =>
request.url === `${environment.apiBaseUrl}tasks/` &&
request.params.get('page_size') === '25' &&
request.params.get('page_size') === '10' &&
request.params.get('task_type') === PaperlessTaskType.SanityCheck
)
.flush({ count: 1, results: [tasks[6]] })
).toHaveLength(3)
component.setTriggerSource(PaperlessTaskTriggerSource.System)
httpTestingController
.expectOne(
expect(
httpTestingController.match(
(request) =>
request.url === `${environment.apiBaseUrl}tasks/` &&
request.params.get('page_size') === '25' &&
request.params.get('page_size') === '10' &&
request.params.get('task_type') === PaperlessTaskType.SanityCheck &&
request.params.get('trigger_source') ===
PaperlessTaskTriggerSource.System
)
.flush({ count: 1, results: [tasks[6]] })
).toHaveLength(3)
})
it('should apply text filters to the server-side task query', () => {
component.filterText = 'invoice'
jest.advanceTimersByTime(150)
httpTestingController
.expectOne(
expect(
httpTestingController.match(
(request) =>
request.url === `${environment.apiBaseUrl}tasks/` &&
request.params.get('page_size') === '25' &&
request.params.get('page_size') === '10' &&
request.params.get('name') === 'invoice'
)
.flush({ count: 1, results: [tasks[0]] })
).toHaveLength(3)
component.setFilterTarget(TaskFilterTargetID.Result)
httpTestingController
.expectOne(
expect(
httpTestingController.match(
(request) =>
request.url === `${environment.apiBaseUrl}tasks/` &&
request.params.get('page_size') === '25' &&
request.params.get('page_size') === '10' &&
request.params.get('result') === 'invoice'
)
.flush({ count: 0, results: [] })
).toHaveLength(3)
})
it('should load a different task page when pagination changes', () => {
selectSection(TaskSection.Completed, 30)
component.setPage(2)
const pageTwoTasks = {
@@ -440,6 +499,7 @@ describe('TasksComponent', () => {
})
it('should not replace section counts with current-page counts', () => {
selectSection(TaskSection.Completed, 30)
component.setPage(2)
httpTestingController
@@ -455,11 +515,21 @@ describe('TasksComponent', () => {
results: [tasks[0]],
})
expect(component.sectionCount(TaskSection.All)).toBe(7)
expect(component.sectionCount(TaskSection.NeedsAttention)).toBe(2)
expect(component.sectionCount(TaskSection.InProgress)).toBe(3)
expect(component.sectionCount(TaskSection.Completed)).toBe(2)
})
it('should stop loading when the overview fails to load', () => {
component.setSection(TaskSection.All)
httpTestingController
.match((req) => req.params.get('page_size') === '10')[0]
.flush('', { status: 500, statusText: 'Error' })
expect(component.loading()).toBe(false)
})
it('should expose stable task type options and disable empty ones', () => {
expect(component.taskTypeOptions.map((option) => option.value)).toContain(
PaperlessTaskType.TrainClassifier
@@ -15,6 +15,9 @@ import {
distinctUntilChanged,
filter,
first,
forkJoin,
map,
Observable,
Subject,
takeUntil,
timer,
@@ -25,6 +28,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 { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { TasksService } from 'src/app/services/tasks.service'
@@ -169,6 +173,7 @@ export class TasksComponent
public expandedTask: number
public autoRefreshEnabled: boolean = true
public readonly pageSize = 25
public readonly overviewSize = 10
public page: number = 1
readonly totalTasks = signal(0)
readonly sectionCounts = signal<Record<TaskSection, number>>({
@@ -484,13 +489,6 @@ export class TasksComponent
return this.sectionCounts()[section]
}
private setSectionCount(section: TaskSection, count: number) {
this.sectionCounts.update((counts) => ({
...counts,
[section]: count,
}))
}
sectionShowsResults(section: TaskSection): boolean {
return section !== TaskSection.InProgress
}
@@ -737,21 +735,12 @@ export class TasksComponent
this.reloadSectionCounts()
this.loading.set(true)
this.tasksService
.list(
this.page,
this.pageSize,
this.getParamsForSection(this.selectedSection)
)
this.listTasks()
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (result) => {
this.pagedTasks.set(result.results)
this.totalTasks.set(result.count)
this.setSectionCount(TaskSection.All, result.count)
if (this.selectedSection !== TaskSection.All) {
this.setSectionCount(this.selectedSection, result.count)
}
this.loading.set(false)
if (
this.page > 1 &&
@@ -767,4 +756,30 @@ export class TasksComponent
},
})
}
private listTasks(): Observable<Results<PaperlessTask>> {
if (this.selectedSection !== TaskSection.All) {
return this.tasksService.list(
this.page,
this.pageSize,
this.getParamsForSection(this.selectedSection)
)
}
// All is an overview of each section to avoid splitting sections across pages
return forkJoin(
this.sections.map((section) =>
this.tasksService.list(
1,
this.overviewSize,
this.getParamsForSection(section)
)
)
).pipe(
map((results) => ({
count: results.reduce((sum, result) => sum + result.count, 0),
results: results.flatMap((result) => result.results),
}))
)
}
}
+2
View File
@@ -41,6 +41,7 @@ import {
bell,
bodyText,
bookmark,
boxArrowInRight,
boxArrowUp,
boxArrowUpRight,
boxes,
@@ -293,6 +294,7 @@ const icons = {
braces,
bodyText,
bookmark,
boxArrowInRight,
boxArrowUp,
boxArrowUpRight,
boxes,