From b1738c85d737d0fb3fedd72c85c83696ffc6c211 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:22:18 -0700 Subject: [PATCH] Fix: dont paginate 'all' tasks view across sections (#14484) --- .../admin/tasks/tasks.component.html | 46 ++++--- .../admin/tasks/tasks.component.scss | 4 + .../admin/tasks/tasks.component.spec.ts | 122 ++++++++++++++---- .../components/admin/tasks/tasks.component.ts | 49 ++++--- src-ui/src/main.ts | 2 + 5 files changed, 161 insertions(+), 62 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 a55fa0f7c..1e8996398 100644 --- a/src-ui/src/app/components/admin/tasks/tasks.component.html +++ b/src-ui/src/app/components/admin/tasks/tasks.component.html @@ -102,25 +102,34 @@ } - - + @if (selectedSection !== TaskSection.All && totalTasks() > pageSize) { + + + } -
-
-
{{ sectionLabel(section) }}
-
- {tasks.length, plural, =1 {1 task} other {{{tasks.length}} tasks}} -
+
+
{{ sectionLabel(section) }}
+ @let count = sectionCount(section); + @let hasMore = selectedSection === TaskSection.All && count > tasks.length; +
+ @if (hasMore) { + {{tasks.length}} of {{count}} tasks + + } @else { + {count, plural, =1 {1 task} other {{{count}} tasks}} + }
@@ -169,10 +178,9 @@
{{ taskDisplayName(task) }}
@if (taskShowsSeparateTypeLabel(task)) { - {{ task.task_type_display }} - • + {{ task.task_type_display }} } - {{ task.trigger_source_display }} + {{ task.trigger_source_display }}
{{ task.date_created | customDate:'short' }} diff --git a/src-ui/src/app/components/admin/tasks/tasks.component.scss b/src-ui/src/app/components/admin/tasks/tasks.component.scss index d5580c56b..a4a6e04ba 100644 --- a/src-ui/src/app/components/admin/tasks/tasks.component.scss +++ b/src-ui/src/app/components/admin/tasks/tasks.component.scss @@ -72,3 +72,7 @@ pre { tbody tr:nth-last-child(2) td { border-bottom: none !important; } + +.section-header .show-all { + font-size: inherit; +} 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 f49083623..376cd23ce 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 @@ -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 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 4116f62d4..b548703df 100644 --- a/src-ui/src/app/components/admin/tasks/tasks.component.ts +++ b/src-ui/src/app/components/admin/tasks/tasks.component.ts @@ -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>({ @@ -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> { + 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), + })) + ) + } } diff --git a/src-ui/src/main.ts b/src-ui/src/main.ts index 83990a3f9..cbe970710 100644 --- a/src-ui/src/main.ts +++ b/src-ui/src/main.ts @@ -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,