diff --git a/src-ui/src/app/app.component.ts b/src-ui/src/app/app.component.ts index 543d2ecaa..485264b51 100644 --- a/src-ui/src/app/app.component.ts +++ b/src-ui/src/app/app.component.ts @@ -219,7 +219,7 @@ export class AppComponent implements OnInit, OnDestroy { }, { anchorId: 'tour.file-tasks', - content: $localize`File Tasks shows you documents that have been consumed, are waiting to be, or may have failed during the process.`, + content: $localize`Tasks helps you track background work, what needs attention, and what recently completed.`, route: '/tasks', backdropConfig: { offset: 0, 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 934e9007f..8fd5ce174 100644 --- a/src-ui/src/app/components/admin/tasks/tasks.component.html +++ b/src-ui/src/app/components/admin/tasks/tasks.component.html @@ -1,41 +1,16 @@
- -
-
- Filter by - @if (filterTargets.length > 1) { -
- - -
- } @else { - {{filterTargetName}} - } - @if (filterText?.length) { - - } - -
-
@@ -48,133 +23,250 @@
Loading...
} - - - - - - - - @if (activeTab !== 'started' && activeTab !== 'queued') { - +
+
+ + + @for (section of sections; track section) { + +
- - - - - @for (task of tasks | slice: (page-1) * pageSize : page * pageSize; track task.id) { - - - - - @if (activeTab !== 'started' && activeTab !== 'queued') { - + + } + + +
+ + +
+
+ + +
+ + - - + + + @if (filterText?.length) { + + } + + + + + @if (isFiltered) { + + } + + + +
+
+
{{ sectionLabel(section) }}
+
+ {tasks.length, plural, =1 {1 task} other {{{tasks.length}} tasks}} +
+
+
+ +
+
-
- - -
-
NameCreatedResultsInfoActions
-
- - -
-
{{ task.input_data?.filename }}{{ task.date_created | customDate:'short' }} - @if (task.result_message?.length > 50) { -
- {{ task.result_message | slice:0:50 }}… -
- } - @if (task.result_message?.length <= 50) { - {{ task.result_message }} - } - -
{{ task.result_message | slice:0:300 }}@if (task.result_message.length > 300) {
-                  …
-                }
- @if (task.result_message?.length > 300) { -
(click for full output) - } -
-
- - -
- - - @if (task.related_document_ids?.[0]) { - - } - -
-
+ - + + + @if (sectionShowsResults(section)) { + + } + + + + + + @for (task of tasks; track task.id) { + + + + + @if (sectionShowsResults(section)) { + + } + + + + + } - -
-
{{ task.result_message }}
+
+
+ + +
+
NameCreatedResultsInfoActions
+
+ + +
+
+
{{ taskDisplayName(task) }}
+
+ @if (taskShowsSeparateTypeLabel(task)) { + {{ task.task_type_display }} + + } + {{ task.trigger_source_display }} +
+
{{ task.date_created | customDate:'short' }} + @if (taskHasLongResultMessage(task)) { +
+ {{ taskResultPreview(task) }} +
+ } + @if (taskHasResultMessage(task) && !taskHasLongResultMessage(task)) { + {{ taskResultMessage(task) }} + } + @if (duplicateDocumentId(task)) { +
+ + {{ duplicateTaskLabel(task) }} +
+ } + +
{{ taskResultPopoverMessage(task) }}@if (taskResultMessageOverflowsPopover(task)) {
+                    …
+                  }
+ @if (taskResultMessageOverflowsPopover(task)) { +
(click for full output) + } +
+
+ + +
+ + + @if (task.related_document_ids?.[0]) { + + } + +
+
+
+
+ @if (taskHasResultMessage(task)) { +
+
Result message
+
{{ taskResultMessage(task) }}
+
+ } + + @if (duplicateDocumentId(task); as duplicateDocumentId) { +
+
Duplicate
+
+
+
{{ duplicateTaskLabel(task) }}
+ +
+
+
+ } + +
+
+
+
Input data
+
{{ task.input_data | json }}
+
+
+
+
+
Result data
+
{{ (task.result_data ?? {}) | json }}
+
+
+
+
+
- -
- @if (tasks.length > 0) { -
- {tasks.length, plural, =1 {One {{this.activeTabLocalized}} task} other {{{tasks.length || 0}} total {{this.activeTabLocalized}} tasks}} - @if (selectedTasks.size > 0) { -  ({{selectedTasks.size}} selected) - } -
- } - @if (tasks.length > pageSize) { - - } + +
- -
+@if (visibleSections.length > 0) { + @for (section of visibleSections; track section) { +
+ +
+ } +} @else { +
No tasks match the current filters.
+} 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 325fd2c02..d8ebc21f0 100644 --- a/src-ui/src/app/components/admin/tasks/tasks.component.scss +++ b/src-ui/src/app/components/admin/tasks/tasks.component.scss @@ -37,3 +37,7 @@ pre { .z-10 { z-index: 10; } + +tbody tr:nth-last-child(2) td { + border-bottom: none !important; +} 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 4c85c939e..161f3cdbd 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 @@ -9,12 +9,7 @@ import { FormsModule } from '@angular/forms' import { By } from '@angular/platform-browser' import { Router } from '@angular/router' import { RouterTestingModule } from '@angular/router/testing' -import { - NgbModal, - NgbModalRef, - NgbModule, - NgbNavItem, -} from '@ng-bootstrap/ng-bootstrap' +import { NgbModal, NgbModalRef, NgbModule } from '@ng-bootstrap/ng-bootstrap' import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { throwError } from 'rxjs' import { routes } from 'src/app/app-routing.module' @@ -33,7 +28,7 @@ import { ToastService } from 'src/app/services/toast.service' import { environment } from 'src/environments/environment' import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' import { PageHeaderComponent } from '../../common/page-header/page-header.component' -import { TasksComponent, TaskTab } from './tasks.component' +import { TasksComponent, TaskSection } from './tasks.component' const tasks: PaperlessTask[] = [ { @@ -48,8 +43,10 @@ const tasks: PaperlessTask[] = [ trigger_source_display: 'Folder Consume', status: PaperlessTaskStatus.Failure, status_display: 'Failure', - result_message: - 'test.pd: Not consuming test.pdf: It is a duplicate of test (#100)', + result_data: { + error_message: + 'test.pd: Not consuming test.pdf: It is a duplicate of test (#100)', + }, acknowledged: false, related_document_ids: [], }, @@ -65,8 +62,7 @@ const tasks: PaperlessTask[] = [ trigger_source_display: 'Folder Consume', status: PaperlessTaskStatus.Failure, status_display: 'Failure', - result_message: - '191092.pd: Not consuming 191092.pdf: It is a duplicate of 191092 (#311)', + result_data: { duplicate_of: 311 }, acknowledged: false, related_document_ids: [], }, @@ -82,7 +78,7 @@ const tasks: PaperlessTask[] = [ trigger_source_display: 'Folder Consume', status: PaperlessTaskStatus.Pending, status_display: 'Pending', - result_message: null, + result_data: null, acknowledged: false, related_document_ids: [], }, @@ -98,7 +94,7 @@ const tasks: PaperlessTask[] = [ trigger_source_display: 'Email Consume', status: PaperlessTaskStatus.Success, status_display: 'Success', - result_message: 'Success. New document id 422 created', + result_data: { document_id: 422, duplicate_of: 99 }, acknowledged: false, related_document_ids: [422], }, @@ -114,7 +110,7 @@ const tasks: PaperlessTask[] = [ trigger_source_display: 'Folder Consume', status: PaperlessTaskStatus.Success, status_display: 'Success', - result_message: 'Success. New document id 421 created', + result_data: { document_id: 421 }, acknowledged: false, related_document_ids: [421], }, @@ -130,7 +126,23 @@ const tasks: PaperlessTask[] = [ trigger_source_display: 'Email Consume', status: PaperlessTaskStatus.Started, status_display: 'Started', - result_message: null, + result_data: null, + acknowledged: false, + related_document_ids: [], + }, + { + id: 461, + task_id: 'bb79efb3-1e78-4f31-b4be-0966620b0ce1', + input_data: { dry_run: false, scope: 'global' }, + date_created: new Date('2023-06-07T03:54:35.694916Z'), + date_done: null, + task_type: PaperlessTaskType.SanityCheck, + task_type_display: 'Sanity Check', + trigger_source: PaperlessTaskTriggerSource.System, + trigger_source_display: 'System', + status: PaperlessTaskStatus.Started, + status_display: 'Started', + result_data: { issues_found: 0 }, acknowledged: false, related_document_ids: [], }, @@ -185,59 +197,142 @@ describe('TasksComponent', () => { jest.useFakeTimers() fixture.detectChanges() httpTestingController - .expectOne( - `${environment.apiBaseUrl}tasks/?task_type=consume_file&acknowledged=false` - ) + .expectOne(`${environment.apiBaseUrl}tasks/?acknowledged=false`) .flush(tasks) }) - it('should display file tasks in 4 tabs by status', () => { - const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavItem)) + it('should display task sections with counts', () => { + expect(component.selectedSection).toBe(TaskSection.All) + expect(component.selectedTaskType).toBeNull() + expect(component.selectedTriggerSource).toBeNull() - let currentTasksLength = tasks.filter( - (t) => t.status === PaperlessTaskStatus.Failure - ).length - component.activeTab = TaskTab.Failed fixture.detectChanges() - expect(tabButtons[0].nativeElement.textContent).toEqual( - `Failed${currentTasksLength}` - ) - expect( - fixture.debugElement.queryAll(By.css('table input[type="checkbox"]')) - ).toHaveLength(currentTasksLength + 1) - currentTasksLength = tasks.filter( - (t) => t.status === PaperlessTaskStatus.Success - ).length - component.activeTab = TaskTab.Completed - fixture.detectChanges() - expect(tabButtons[1].nativeElement.textContent).toEqual( - `Complete${currentTasksLength}` - ) + const viewScope = fixture.debugElement.query(By.css('.task-view-scope')) + const text = viewScope.nativeElement.textContent - currentTasksLength = tasks.filter( - (t) => t.status === PaperlessTaskStatus.Started - ).length - component.activeTab = TaskTab.Started - fixture.detectChanges() - expect(tabButtons[2].nativeElement.textContent).toEqual( - `Started${currentTasksLength}` - ) + expect(text).toContain('All') + expect(text).toContain('Needs attention') + expect(text).toContain('2') + expect(text).toContain('In progress') + expect(text).toContain('3') + expect(text).toContain('Recently completed') + }) - currentTasksLength = tasks.filter( - (t) => t.status === PaperlessTaskStatus.Pending - ).length - component.activeTab = TaskTab.Queued + it('should filter visible sections by selected status', () => { + component.setSection(TaskSection.InProgress) fixture.detectChanges() - expect(tabButtons[3].nativeElement.textContent).toEqual( - `Queued${currentTasksLength}` + + expect(component.visibleSections).toEqual([TaskSection.InProgress]) + expect(fixture.nativeElement.textContent).toContain('In progress') + expect(fixture.nativeElement.textContent).not.toContain('Recent completed') + }) + + it('should filter tasks by task type', () => { + component.setSection(TaskSection.InProgress) + component.setTaskType(PaperlessTaskType.SanityCheck) + + expect(component.tasksForSection(TaskSection.InProgress)).toHaveLength(1) + expect(component.tasksForSection(TaskSection.InProgress)[0].task_type).toBe( + PaperlessTaskType.SanityCheck ) }) - it('should to go page 1 between tab switch', () => { - component.page = 10 - component.duringTabChange() - expect(component.page).toEqual(1) + it('should filter tasks by trigger source', () => { + component.setSection(TaskSection.InProgress) + component.setTriggerSource(PaperlessTaskTriggerSource.EmailConsume) + + expect(component.tasksForSection(TaskSection.InProgress)).toHaveLength(1) + expect( + component.tasksForSection(TaskSection.InProgress)[0].trigger_source + ).toBe(PaperlessTaskTriggerSource.EmailConsume) + }) + + it('should reset all active filters together', () => { + component.setSection(TaskSection.InProgress) + component.setTaskType(PaperlessTaskType.SanityCheck) + component.setTriggerSource(PaperlessTaskTriggerSource.System) + component.filterText = 'system' + jest.advanceTimersByTime(150) + + expect(component.isFiltered).toBe(true) + + component.resetFilters() + + expect(component.selectedSection).toBe(TaskSection.InProgress) + expect(component.selectedTaskType).toBeNull() + expect(component.selectedTriggerSource).toBeNull() + expect(component.filterText).toBe('') + expect(component.isFiltered).toBe(false) + }) + + it('should keep header controls focused on actions and auto refresh', () => { + fixture.detectChanges() + + const header = fixture.debugElement.query(By.css('pngx-page-header')) + const headerText = header.nativeElement.textContent + + expect(headerText).toContain('Dismiss visible') + expect(headerText).toContain('Auto refresh') + expect(headerText).not.toContain('All types') + expect(headerText).not.toContain('All sources') + expect(headerText).not.toContain('Reset filters') + }) + + it('should render the view scope row above the filter bar', () => { + fixture.detectChanges() + + const controls = fixture.debugElement.query(By.css('.task-controls')) + const viewScope = controls.query(By.css('.task-view-scope')) + const search = controls.query(By.css('.task-search')) + + expect(viewScope).not.toBeNull() + expect(search).not.toBeNull() + expect( + viewScope.nativeElement.compareDocumentPosition(search.nativeElement) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy() + }) + + it('should expose stable task type options and disable empty ones', () => { + expect(component.taskTypeOptions.map((option) => option.value)).toContain( + PaperlessTaskType.TrainClassifier + ) + expect( + component.isTaskTypeOptionDisabled(PaperlessTaskType.TrainClassifier) + ).toBe(true) + expect( + component.isTaskTypeOptionDisabled(PaperlessTaskType.ConsumeFile) + ).toBe(false) + }) + + it('should fall back to the raw selected task type label when no option matches', () => { + component.selectedTaskType = 'unknown_task_type' as PaperlessTaskType + + expect(component.selectedTaskTypeLabel).toBe('unknown_task_type') + }) + + it('should expose stable trigger source options and disable empty ones', () => { + expect( + component.triggerSourceOptions.map((option) => option.value) + ).toContain(PaperlessTaskTriggerSource.ApiUpload) + expect( + component.isTriggerSourceOptionDisabled( + PaperlessTaskTriggerSource.ApiUpload + ) + ).toBe(true) + expect( + component.isTriggerSourceOptionDisabled( + PaperlessTaskTriggerSource.EmailConsume + ) + ).toBe(false) + }) + + it('should fall back to the raw selected trigger source label when no option matches', () => { + component.selectedTriggerSource = + 'unknown_trigger_source' as PaperlessTaskTriggerSource + + expect(component.selectedTriggerSourceLabel).toBe('unknown_trigger_source') }) it('should support expanding / collapsing one task at a time', () => { @@ -249,6 +344,31 @@ describe('TasksComponent', () => { expect(component.expandedTask).toBeUndefined() }) + it('should show structured task details when expanded', () => { + component.setSection(TaskSection.InProgress) + component.expandTask(tasks[6]) + fixture.detectChanges() + + const detailText = fixture.nativeElement.textContent + + expect(detailText).toContain('Input data') + expect(detailText).toContain('Result data') + expect(detailText).toContain('"scope": "global"') + expect(detailText).toContain('"issues_found": 0') + }) + + it('should show duplicate warnings and duplicate details when present', () => { + component.setSection(TaskSection.Completed) + component.expandTask(tasks[3]) + fixture.detectChanges() + + const content = fixture.nativeElement.textContent + + expect(content).toContain('Duplicate of document #99') + expect(content).toContain('Duplicate') + expect(content).toContain('Open') + }) + it('should support dismiss single task', () => { const dismissSpy = jest.spyOn(tasksService, 'dismissTasks') component.dismissTask(tasks[0]) @@ -259,7 +379,7 @@ describe('TasksComponent', () => { component.toggleSelected(tasks[0]) component.toggleSelected(tasks[1]) component.toggleSelected(tasks[3]) - component.toggleSelected(tasks[3]) // uncheck, for coverage + component.toggleSelected(tasks[3]) const selected = new Set([tasks[0].id, tasks[1].id]) expect(component.selectedTasks).toEqual(selected) let modal: NgbModalRef @@ -308,31 +428,50 @@ describe('TasksComponent', () => { expect(component.selectedTasks.size).toBe(0) }) - it('should support dismiss all tasks', () => { + it('should support dismiss visible tasks', () => { + component.setSection(TaskSection.NeedsAttention) let modal: NgbModalRef modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1])) const dismissSpy = jest.spyOn(tasksService, 'dismissTasks') component.dismissTasks() expect(modal).not.toBeUndefined() modal.componentInstance.confirmClicked.emit() - expect(dismissSpy).toHaveBeenCalledWith(new Set(tasks.map((t) => t.id))) + expect(dismissSpy).toHaveBeenCalledWith(new Set([467, 466])) }) - it('should support toggle all tasks', () => { + it('should dismiss the currently visible scoped and filtered tasks', () => { + component.setSection(TaskSection.InProgress) + component.setTaskType(PaperlessTaskType.SanityCheck) + component.setTriggerSource(PaperlessTaskTriggerSource.System) + + const dismissSpy = jest.spyOn(tasksService, 'dismissTasks') + + component.dismissTasks() + + expect(dismissSpy).toHaveBeenCalledWith(new Set([461])) + }) + + it('should support toggling a full section', () => { + component.setSection(TaskSection.NeedsAttention) + fixture.detectChanges() + const toggleCheck = fixture.debugElement.query( - By.css('table input[type=checkbox]') - ) - toggleCheck.nativeElement.dispatchEvent(new MouseEvent('click')) - fixture.detectChanges() - expect(component.selectedTasks).toEqual( - new Set( - tasks - .filter((t) => t.status === PaperlessTaskStatus.Failure) - .map((t) => t.id) - ) + By.css('#all-tasks-needs_attention') ) + expect(toggleCheck).not.toBeNull() toggleCheck.nativeElement.dispatchEvent(new MouseEvent('click')) fixture.detectChanges() + expect(component.selectedTasks).toEqual(new Set([467, 466])) + }) + + it('should remove a full section from selection when toggled off', () => { + component.setSection(TaskSection.NeedsAttention) + component.selectedTasks = new Set([467, 466]) + + component.toggleSection(TaskSection.NeedsAttention, { + target: { checked: false }, + } as PointerEvent) + expect(component.selectedTasks).toEqual(new Set()) }) @@ -355,57 +494,127 @@ describe('TasksComponent', () => { }) it('should filter tasks by file name', () => { + fixture.detectChanges() const input = fixture.debugElement.query( - By.css('pngx-page-header input[type=text]') + By.css('.task-search input[type=text]') ) + expect(input).not.toBeNull() input.nativeElement.value = '191092' input.nativeElement.dispatchEvent(new Event('input')) - jest.advanceTimersByTime(150) // debounce time + jest.advanceTimersByTime(150) fixture.detectChanges() expect(component.filterText).toEqual('191092') - expect( - fixture.debugElement.queryAll(By.css('table tbody tr')).length - ).toEqual(2) // 1 task x 2 lines + expect(component.tasksForSection(TaskSection.NeedsAttention)).toHaveLength( + 1 + ) + }) + + it('should match task type and source in name filtering', () => { + component.setSection(TaskSection.InProgress) + component.filterText = 'system' + jest.advanceTimersByTime(150) + + expect(component.tasksForSection(TaskSection.InProgress)).toHaveLength(1) + expect(component.tasksForSection(TaskSection.InProgress)[0].task_type).toBe( + PaperlessTaskType.SanityCheck + ) + }) + + it('should fall back to task type when filename is unavailable', () => { + component.setSection(TaskSection.InProgress) + fixture.detectChanges() + + const nameColumn = fixture.debugElement.queryAll( + By.css('tbody td.name-col') + ) + const sanityTaskRow = nameColumn.find((cell) => + cell.nativeElement.textContent.includes('Sanity Check') + ) + + expect(sanityTaskRow.nativeElement.textContent).toContain('Sanity Check') + expect(sanityTaskRow.nativeElement.textContent).toContain('System') }) it('should filter tasks by result', () => { - component.activeTab = TaskTab.Failed - fixture.detectChanges() + component.setSection(TaskSection.NeedsAttention) component.filterTargetID = 1 + fixture.detectChanges() const input = fixture.debugElement.query( - By.css('pngx-page-header input[type=text]') + By.css('.task-search input[type=text]') ) + expect(input).not.toBeNull() input.nativeElement.value = 'duplicate' input.nativeElement.dispatchEvent(new Event('input')) - jest.advanceTimersByTime(150) // debounce time + jest.advanceTimersByTime(150) fixture.detectChanges() expect(component.filterText).toEqual('duplicate') + expect(component.tasksForSection(TaskSection.NeedsAttention)).toHaveLength( + 2 + ) + }) + + it('should prefer explicit reason in the result message', () => { expect( - fixture.debugElement.queryAll(By.css('table tbody tr')).length - ).toEqual(4) // 2 tasks x 2 lines + component.taskResultMessage({ + ...tasks[0], + result_data: { reason: 'Manual review required', duplicate_of: 311 }, + }) + ).toBe('Manual review required') + }) + + it('should return null preview and popover text when there is no result message', () => { + expect(component.taskResultPreview(tasks[2])).toBeNull() + expect(component.taskResultPopoverMessage(tasks[2])).toBe('') + expect(component.taskResultMessageOverflowsPopover(tasks[2])).toBe(false) + }) + + it('should navigate to a duplicate document details page', () => { + const routerSpy = jest.spyOn(router, 'navigate') + + component.openDuplicateDocument(99) + + expect(routerSpy).toHaveBeenCalledWith(['documents', 99, 'details']) + }) + + it('should report when a result message overflows the popover limit', () => { + const longMessage = 'x'.repeat(350) + const task = { + ...tasks[0], + result_data: { error_message: longMessage }, + } + + expect(component.taskResultPopoverMessage(task)).toBe( + longMessage.slice(0, 300) + ) + expect(component.taskResultMessageOverflowsPopover(task)).toBe(true) }) it('should support keyboard events for filtering', () => { + fixture.detectChanges() const input = fixture.debugElement.query( - By.css('pngx-page-header input[type=text]') + By.css('.task-search input[type=text]') ) + expect(input).not.toBeNull() input.nativeElement.value = '191092' input.nativeElement.dispatchEvent( new KeyboardEvent('keyup', { key: 'Enter' }) ) - expect(component.filterText).toEqual('191092') // no debounce needed + expect(component.filterText).toEqual('191092') input.nativeElement.dispatchEvent( new KeyboardEvent('keyup', { key: 'Escape' }) ) expect(component.filterText).toEqual('') }) - it('should reset filter and target on tab switch', () => { - component.filterText = '191092' - component.filterTargetID = 1 - component.activeTab = TaskTab.Completed - component.beforeTabChange() - expect(component.filterText).toEqual('') - expect(component.filterTargetID).toEqual(0) + it('should keep clearing selection independent from resetting filters', () => { + component.setTaskType(PaperlessTaskType.ConsumeFile) + component.toggleSelected(tasks[0]) + expect(component.selectedTasks.size).toBe(1) + + component.clearSelection() + + expect(component.selectedTasks.size).toBe(0) + expect(component.selectedTaskType).toBe(PaperlessTaskType.ConsumeFile) + expect(component.isFiltered).toBe(true) }) }) 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 decba1eb8..7e8155bac 100644 --- a/src-ui/src/app/components/admin/tasks/tasks.component.ts +++ b/src-ui/src/app/components/admin/tasks/tasks.component.ts @@ -1,4 +1,4 @@ -import { NgTemplateOutlet, SlicePipe } from '@angular/common' +import { JsonPipe, NgTemplateOutlet } from '@angular/common' import { Component, inject, OnDestroy, OnInit } from '@angular/core' import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { Router } from '@angular/router' @@ -6,8 +6,6 @@ import { NgbCollapseModule, NgbDropdownModule, NgbModal, - NgbNavModule, - NgbPaginationModule, NgbPopoverModule, } from '@ng-bootstrap/ng-bootstrap' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' @@ -20,7 +18,12 @@ import { takeUntil, timer, } from 'rxjs' -import { PaperlessTask } from 'src/app/data/paperless-task' +import { + PaperlessTask, + PaperlessTaskStatus, + PaperlessTaskTriggerSource, + PaperlessTaskType, +} from 'src/app/data/paperless-task' 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' @@ -29,11 +32,11 @@ import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dial import { PageHeaderComponent } from '../../common/page-header/page-header.component' import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' -export enum TaskTab { - Queued = 'queued', - Started = 'started', +export enum TaskSection { + All = 'all', + NeedsAttention = 'needs_attention', + InProgress = 'in_progress', Completed = 'completed', - Failed = 'failed', } enum TaskFilterTargetID { @@ -46,6 +49,82 @@ const FILTER_TARGETS = [ { id: TaskFilterTargetID.Result, name: $localize`Result` }, ] +const SECTION_LABELS = { + [TaskSection.All]: $localize`All`, + [TaskSection.NeedsAttention]: $localize`Needs attention`, + [TaskSection.InProgress]: $localize`In progress`, + [TaskSection.Completed]: $localize`Recently completed`, +} + +const TASK_TYPE_OPTIONS: Array<{ + value: PaperlessTaskType + label: string +}> = [ + { + value: PaperlessTaskType.ConsumeFile, + label: $localize`Consume File`, + }, + { + value: PaperlessTaskType.TrainClassifier, + label: $localize`Train Classifier`, + }, + { + value: PaperlessTaskType.SanityCheck, + label: $localize`Sanity Check`, + }, + { value: PaperlessTaskType.MailFetch, label: $localize`Mail Fetch` }, + { value: PaperlessTaskType.LlmIndex, label: $localize`LLM Index` }, + { + value: PaperlessTaskType.EmptyTrash, + label: $localize`Empty Trash`, + }, + { + value: PaperlessTaskType.CheckWorkflows, + label: $localize`Check Workflows`, + }, + { + value: PaperlessTaskType.BulkUpdate, + label: $localize`Bulk Update`, + }, + { + value: PaperlessTaskType.ReprocessDocument, + label: $localize`Reprocess Document`, + }, + { + value: PaperlessTaskType.BuildShareLink, + label: $localize`Build Share Link`, + }, + { + value: PaperlessTaskType.BulkDelete, + label: $localize`Bulk Delete`, + }, +] + +const TRIGGER_SOURCE_OPTIONS: Array<{ + value: PaperlessTaskTriggerSource + label: string +}> = [ + { + value: PaperlessTaskTriggerSource.Scheduled, + label: $localize`Scheduled`, + }, + { value: PaperlessTaskTriggerSource.WebUI, label: $localize`Web UI` }, + { + value: PaperlessTaskTriggerSource.ApiUpload, + label: $localize`API Upload`, + }, + { + value: PaperlessTaskTriggerSource.FolderConsume, + label: $localize`Folder Consume`, + }, + { + value: PaperlessTaskTriggerSource.EmailConsume, + label: $localize`Email Consume`, + }, + { value: PaperlessTaskTriggerSource.System, label: $localize`System` }, + { value: PaperlessTaskTriggerSource.Manual, label: $localize`Manual` }, +] + @Component({ selector: 'pngx-tasks', templateUrl: './tasks.component.html', @@ -54,14 +133,12 @@ const FILTER_TARGETS = [ PageHeaderComponent, IfPermissionsDirective, CustomDatePipe, - SlicePipe, + JsonPipe, FormsModule, ReactiveFormsModule, NgTemplateOutlet, NgbCollapseModule, NgbDropdownModule, - NgbNavModule, - NgbPaginationModule, NgbPopoverModule, NgxBootstrapIconsModule, ], @@ -75,15 +152,18 @@ export class TasksComponent private readonly router = inject(Router) private readonly toastService = inject(ToastService) - public activeTab: TaskTab + readonly TaskSection = TaskSection + readonly sections = [ + TaskSection.NeedsAttention, + TaskSection.InProgress, + TaskSection.Completed, + ] public selectedTasks: Set = new Set() - public togggleAll: boolean = false public expandedTask: number - - public pageSize: number = 25 - public page: number = 1 - public autoRefreshEnabled: boolean = true + public selectedSection: TaskSection = TaskSection.All + public selectedTaskType: PaperlessTaskType | null = null + public selectedTriggerSource: PaperlessTaskTriggerSource | null = null private _filterText: string = '' get filterText() { @@ -95,20 +175,81 @@ export class TasksComponent public filterTargetID: TaskFilterTargetID = TaskFilterTargetID.Name public get filterTargetName(): string { - return this.filterTargets.find((t) => t.id == this.filterTargetID).name + return FILTER_TARGETS.find((t) => t.id == this.filterTargetID).name } private filterDebounce: Subject = new Subject() public get filterTargets(): Array<{ id: number; name: string }> { - return [TaskTab.Failed, TaskTab.Completed].includes(this.activeTab) - ? FILTER_TARGETS - : FILTER_TARGETS.slice(0, 1) + return FILTER_TARGETS + } + + public get taskTypeOptions(): Array<{ + value: PaperlessTaskType + label: string + }> { + return TASK_TYPE_OPTIONS + } + + public get triggerSourceOptions(): Array<{ + value: PaperlessTaskTriggerSource + label: string + }> { + return TRIGGER_SOURCE_OPTIONS + } + + public get selectedTaskTypeLabel(): string { + if (this.selectedTaskType === null) { + return $localize`All types` + } + + return ( + this.taskTypeOptions.find( + (option) => option.value === this.selectedTaskType + )?.label ?? this.selectedTaskType + ) + } + + public get selectedTriggerSourceLabel(): string { + if (this.selectedTriggerSource === null) { + return $localize`All sources` + } + + return ( + this.triggerSourceOptions.find( + (option) => option.value === this.selectedTriggerSource + )?.label ?? this.selectedTriggerSource + ) } get dismissButtonText(): string { return this.selectedTasks.size > 0 ? $localize`Dismiss selected` - : $localize`Dismiss all` + : $localize`Dismiss visible` + } + + get visibleSections(): TaskSection[] { + const sections = + this.selectedSection === TaskSection.All + ? this.sections + : [this.selectedSection] + + return sections.filter( + (section) => this.tasksForSection(section).length > 0 + ) + } + + get visibleTasks(): PaperlessTask[] { + return this.visibleSections.flatMap((section) => + this.tasksForSection(section) + ) + } + + get isFiltered(): boolean { + return ( + this.selectedTaskType !== null || + this.selectedTriggerSource !== null || + this._filterText.length > 0 + ) } ngOnInit() { @@ -143,14 +284,16 @@ export class TasksComponent dismissTasks(task: PaperlessTask = undefined) { let tasks = task ? new Set([task.id]) : new Set(this.selectedTasks.values()) - if (!task && tasks.size == 0) - tasks = new Set(this.tasksService.allFileTasks.map((t) => t.id)) + if (!task && tasks.size == 0) { + tasks = new Set(this.visibleTasks.map((t) => t.id)) + } + if (tasks.size > 1) { let modal = this.modalService.open(ConfirmDialogComponent, { backdrop: 'static', }) - modal.componentInstance.title = $localize`Confirm Dismiss All` - modal.componentInstance.messageBold = $localize`Dismiss all ${tasks.size} tasks?` + modal.componentInstance.title = $localize`Confirm Dismiss` + modal.componentInstance.messageBold = $localize`Dismiss ${tasks.size} tasks?` modal.componentInstance.btnClass = 'btn-warning' modal.componentInstance.btnCaption = $localize`Dismiss` modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => { @@ -164,7 +307,7 @@ export class TasksComponent }) this.clearSelection() }) - } else { + } else if (tasks.size === 1) { this.tasksService.dismissTasks(tasks).subscribe({ error: (e) => this.toastService.showError($localize`Error dismissing task`, e), @@ -188,77 +331,167 @@ export class TasksComponent : this.selectedTasks.add(task.id) } - get currentTasks(): PaperlessTask[] { - let tasks: PaperlessTask[] = [] - switch (this.activeTab) { - case TaskTab.Queued: - tasks = this.tasksService.queuedFileTasks - break - case TaskTab.Started: - tasks = this.tasksService.startedFileTasks - break - case TaskTab.Completed: - tasks = this.tasksService.completedFileTasks - break - case TaskTab.Failed: - tasks = this.tasksService.failedFileTasks - break + toggleSection(section: TaskSection, event: PointerEvent) { + const sectionTasks = this.tasksForSection(section) + if ((event.target as HTMLInputElement).checked) { + sectionTasks.forEach((task) => this.selectedTasks.add(task.id)) + } else { + sectionTasks.forEach((task) => this.selectedTasks.delete(task.id)) } - if (this._filterText.length) { - tasks = tasks.filter((t) => { - if (this.filterTargetID == TaskFilterTargetID.Name) { - return (t.input_data?.filename as string) - ?.toLowerCase() - .includes(this._filterText.toLowerCase()) - } else if (this.filterTargetID == TaskFilterTargetID.Result) { - return t.result_message - ?.toLowerCase() - .includes(this._filterText.toLowerCase()) - } - }) - } - return tasks } - toggleAll(event: PointerEvent) { - if ((event.target as HTMLInputElement).checked) { - this.selectedTasks = new Set(this.currentTasks.map((t) => t.id)) - } else { - this.clearSelection() + areAllSelected(tasks: PaperlessTask[]): boolean { + return ( + tasks.length > 0 && tasks.every((task) => this.selectedTasks.has(task.id)) + ) + } + + taskDisplayName(task: PaperlessTask): string { + return task.input_data?.filename?.toString() || task.task_type_display + } + + taskShowsSeparateTypeLabel(task: PaperlessTask): boolean { + return this.taskDisplayName(task) !== task.task_type_display + } + + taskResultMessage(task: PaperlessTask): string | null { + if (!task.result_data) { + return null } + + const documentId = task.result_data?.['document_id'] + if (typeof documentId === 'number') { + return `Success. New document id ${documentId} created` + } + + const reason = task.result_data?.['reason'] + if (typeof reason === 'string') { + return reason + } + + const duplicateOf = task.result_data?.['duplicate_of'] + if (typeof duplicateOf === 'number') { + return `Duplicate of document #${duplicateOf}` + } + + const errorMessage = task.result_data?.['error_message'] + if (typeof errorMessage === 'string') { + return errorMessage + } + + return null + } + + taskResultPreview(task: PaperlessTask): string | null { + const message = this.taskResultMessage(task) + if (!message) { + return null + } + + return message.length > 50 ? `${message.slice(0, 50)}...` : message + } + + taskHasLongResultMessage(task: PaperlessTask): boolean { + return (this.taskResultMessage(task)?.length ?? 0) > 50 + } + + taskHasResultMessage(task: PaperlessTask): boolean { + return !!this.taskResultMessage(task) + } + + duplicateDocumentId(task: PaperlessTask): number | null { + const duplicateOf = task.result_data?.['duplicate_of'] + return typeof duplicateOf === 'number' ? duplicateOf : null + } + + duplicateTaskLabel(task: PaperlessTask): string { + return $localize`Duplicate of document #${this.duplicateDocumentId(task)}` + } + + openDuplicateDocument(documentId: number) { + this.router.navigate(['documents', documentId, 'details']) + } + + taskResultPopoverMessage(task: PaperlessTask): string { + return this.taskResultMessage(task)?.slice(0, 300) ?? '' + } + + taskResultMessageOverflowsPopover(task: PaperlessTask): boolean { + return (this.taskResultMessage(task)?.length ?? 0) > 300 + } + + tasksForSection(section: TaskSection): PaperlessTask[] { + let tasks = this.tasksService.allFileTasks.filter((task) => + this.taskBelongsToSection(task, section) + ) + + return tasks.filter((task) => this.taskMatchesCurrentFilters(task)) + } + + sectionLabel(section: TaskSection): string { + return SECTION_LABELS[section] + } + + sectionCount(section: TaskSection): number { + return this.tasksService.allFileTasks.filter((task) => + this.taskBelongsToSection(task, section) + ).length + } + + sectionShowsResults(section: TaskSection): boolean { + return section !== TaskSection.InProgress + } + + setSection(section: TaskSection) { + this.selectedSection = section + this.clearSelection() + } + + setTaskType(taskType: PaperlessTaskType | null) { + this.selectedTaskType = taskType + this.clearSelection() + } + + setTriggerSource(triggerSource: PaperlessTaskTriggerSource | null) { + this.selectedTriggerSource = triggerSource + this.clearSelection() + } + + taskTypeOptionCount(taskType: PaperlessTaskType | null): number { + return this.tasksForOptionCounts({ taskType }).length + } + + triggerSourceOptionCount( + triggerSource: PaperlessTaskTriggerSource | null + ): number { + return this.tasksForOptionCounts({ triggerSource }).length + } + + isTaskTypeOptionDisabled(taskType: PaperlessTaskType | null): boolean { + return this.taskTypeOptionCount(taskType) === 0 + } + + isTriggerSourceOptionDisabled( + triggerSource: PaperlessTaskTriggerSource | null + ): boolean { + return this.triggerSourceOptionCount(triggerSource) === 0 } clearSelection() { - this.togggleAll = false this.selectedTasks.clear() } - duringTabChange() { - this.page = 1 - } - - beforeTabChange() { - this.resetFilter() - this.filterTargetID = TaskFilterTargetID.Name - } - - get activeTabLocalized(): string { - switch (this.activeTab) { - case TaskTab.Queued: - return $localize`queued` - case TaskTab.Started: - return $localize`started` - case TaskTab.Completed: - return $localize`completed` - case TaskTab.Failed: - return $localize`failed` - } - } - public resetFilter() { this._filterText = '' } + public resetFilters() { + this.selectedTaskType = null + this.selectedTriggerSource = null + this.resetFilter() + this.clearSelection() + } + filterInputKeyup(event: KeyboardEvent) { if (event.key == 'Enter') { this._filterText = (event.target as HTMLInputElement).value @@ -266,4 +499,87 @@ export class TasksComponent this.resetFilter() } } + + private taskBelongsToSection( + task: PaperlessTask, + section: TaskSection + ): boolean { + switch (section) { + case TaskSection.NeedsAttention: + return [ + PaperlessTaskStatus.Failure, + PaperlessTaskStatus.Revoked, + ].includes(task.status) + case TaskSection.InProgress: + return [ + PaperlessTaskStatus.Pending, + PaperlessTaskStatus.Started, + ].includes(task.status) + case TaskSection.Completed: + return task.status === PaperlessTaskStatus.Success + } + } + + private taskMatchesCurrentFilters(task: PaperlessTask): boolean { + return this.taskMatchesFilters(task, { + taskType: this.selectedTaskType, + triggerSource: this.selectedTriggerSource, + }) + } + + private taskMatchesFilters( + task: PaperlessTask, + { + taskType, + triggerSource, + }: { + taskType: PaperlessTaskType | null + triggerSource: PaperlessTaskTriggerSource | null + } + ): boolean { + if (taskType !== null && task.task_type !== taskType) { + return false + } + + if (triggerSource !== null && task.trigger_source !== triggerSource) { + return false + } + + if (!this._filterText.length) { + return true + } + + const query = this._filterText.toLowerCase() + + if (this.filterTargetID == TaskFilterTargetID.Name) { + return [ + this.taskDisplayName(task), + task.task_type_display, + task.trigger_source_display, + ] + .filter(Boolean) + .some((value) => value.toLowerCase().includes(query)) + } + + return this.taskResultMessage(task)?.toLowerCase().includes(query) ?? false + } + + private tasksForOptionCounts({ + taskType = this.selectedTaskType, + triggerSource = this.selectedTriggerSource, + }: { + taskType?: PaperlessTaskType | null + triggerSource?: PaperlessTaskTriggerSource | null + }): PaperlessTask[] { + const sections = + this.selectedSection === TaskSection.All + ? this.sections + : [this.selectedSection] + + return this.tasksService.allFileTasks.filter( + (task) => + sections.some((section) => this.taskBelongsToSection(task, section)) && + this.taskMatchesFilters(task, { taskType, triggerSource }) + ) + } } diff --git a/src-ui/src/app/components/app-frame/app-frame.component.html b/src-ui/src/app/components/app-frame/app-frame.component.html index 11a4aefe2..53ea901df 100644 --- a/src-ui/src/app/components/app-frame/app-frame.component.html +++ b/src-ui/src/app/components/app-frame/app-frame.component.html @@ -294,13 +294,13 @@ *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }" tourAnchor="tour.file-tasks"> - File Tasks@if (tasksService.failedFileTasks.length > 0) { - {{tasksService.failedFileTasks.length}} + Tasks@if (tasksService.needsAttentionTasks.length > 0) { + {{tasksService.needsAttentionTasks.length}} } - @if (tasksService.failedFileTasks.length > 0 && slimSidebarEnabled) { - {{tasksService.failedFileTasks.length}} + @if (tasksService.needsAttentionTasks.length > 0 && slimSidebarEnabled) { + {{tasksService.needsAttentionTasks.length}} } diff --git a/src-ui/src/app/components/app-frame/app-frame.component.spec.ts b/src-ui/src/app/components/app-frame/app-frame.component.spec.ts index 1341c6e5a..d6f84276f 100644 --- a/src-ui/src/app/components/app-frame/app-frame.component.spec.ts +++ b/src-ui/src/app/components/app-frame/app-frame.component.spec.ts @@ -36,6 +36,7 @@ import { RemoteVersionService } from 'src/app/services/rest/remote-version.servi import { SavedViewService } from 'src/app/services/rest/saved-view.service' import { SearchService } from 'src/app/services/rest/search.service' import { SettingsService } from 'src/app/services/settings.service' +import { TasksService } from 'src/app/services/tasks.service' import { ToastService } from 'src/app/services/toast.service' import { environment } from 'src/environments/environment' import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component' @@ -97,6 +98,7 @@ describe('AppFrameComponent', () => { let savedViewSpy let modalService: NgbModal let maybeRefreshSpy + let tasksService: TasksService beforeEach(async () => { TestBed.configureTestingModule({ @@ -174,6 +176,7 @@ describe('AppFrameComponent', () => { openDocumentsService = TestBed.inject(OpenDocumentsService) modalService = TestBed.inject(NgbModal) router = TestBed.inject(Router) + tasksService = TestBed.inject(TasksService) jest .spyOn(settingsService, 'displayName', 'get') @@ -444,6 +447,16 @@ describe('AppFrameComponent', () => { expect(maybeRefreshSpy).toHaveBeenCalled() }) + it('should show tasks badge for needs-attention tasks', () => { + jest + .spyOn(tasksService, 'needsAttentionTasks', 'get') + .mockReturnValue([{} as any, {} as any]) + + fixture.detectChanges() + + expect(fixture.nativeElement.textContent).toContain('Tasks2') + }) + it('should indicate attributes management availability when any permission is granted', () => { jest .spyOn(permissionsService, 'currentUserCan') diff --git a/src-ui/src/app/data/paperless-task.ts b/src-ui/src/app/data/paperless-task.ts index aa3390c96..53aba0edd 100644 --- a/src-ui/src/app/data/paperless-task.ts +++ b/src-ui/src/app/data/paperless-task.ts @@ -45,9 +45,8 @@ export interface PaperlessTask extends ObjectWithId { date_done?: Date duration_seconds?: number wait_time_seconds?: number - input_data: Record - result_data?: Record - result_message?: string + input_data: Record + result_data?: Record related_document_ids: number[] acknowledged: boolean owner?: number diff --git a/src-ui/src/app/services/tasks.service.spec.ts b/src-ui/src/app/services/tasks.service.spec.ts index 09bd29441..922ab103f 100644 --- a/src-ui/src/app/services/tasks.service.spec.ts +++ b/src-ui/src/app/services/tasks.service.spec.ts @@ -5,7 +5,11 @@ import { } from '@angular/common/http/testing' import { TestBed } from '@angular/core/testing' import { environment } from 'src/environments/environment' -import { PaperlessTaskStatus, PaperlessTaskType } from '../data/paperless-task' +import { + PaperlessTaskStatus, + PaperlessTaskTriggerSource, + PaperlessTaskType, +} from '../data/paperless-task' import { TasksService } from './tasks.service' describe('TasksService', () => { @@ -33,7 +37,7 @@ describe('TasksService', () => { it('calls tasks api endpoint on reload', () => { tasksService.reload() const req = httpTestingController.expectOne( - `${environment.apiBaseUrl}tasks/?task_type=consume_file&acknowledged=false` + `${environment.apiBaseUrl}tasks/?acknowledged=false` ) expect(req.request.method).toEqual('GET') }) @@ -42,7 +46,7 @@ describe('TasksService', () => { tasksService.loading = true tasksService.reload() httpTestingController.expectNone( - `${environment.apiBaseUrl}tasks/?task_type=consume_file&acknowledged=false` + `${environment.apiBaseUrl}tasks/?acknowledged=false` ) }) @@ -58,17 +62,16 @@ describe('TasksService', () => { req.flush([]) // reload is then called httpTestingController - .expectOne( - `${environment.apiBaseUrl}tasks/?task_type=consume_file&acknowledged=false` - ) + .expectOne(`${environment.apiBaseUrl}tasks/?acknowledged=false`) .flush([]) }) - it('sorts tasks returned from api', () => { + it('groups mixed task types by status when reloading', () => { expect(tasksService.total).toEqual(0) const mockTasks = [ { task_type: PaperlessTaskType.ConsumeFile, + trigger_source: PaperlessTaskTriggerSource.FolderConsume, status: PaperlessTaskStatus.Success, acknowledged: false, task_id: '1234', @@ -77,38 +80,42 @@ describe('TasksService', () => { related_document_ids: [], }, { - task_type: PaperlessTaskType.ConsumeFile, + task_type: PaperlessTaskType.SanityCheck, + trigger_source: PaperlessTaskTriggerSource.System, status: PaperlessTaskStatus.Failure, acknowledged: false, task_id: '1235', - input_data: { filename: 'file2.pdf' }, + input_data: {}, date_created: new Date(), related_document_ids: [], }, { - task_type: PaperlessTaskType.ConsumeFile, + task_type: PaperlessTaskType.MailFetch, + trigger_source: PaperlessTaskTriggerSource.Scheduled, status: PaperlessTaskStatus.Pending, acknowledged: false, task_id: '1236', - input_data: { filename: 'file3.pdf' }, + input_data: {}, date_created: new Date(), related_document_ids: [], }, { - task_type: PaperlessTaskType.ConsumeFile, + task_type: PaperlessTaskType.LlmIndex, + trigger_source: PaperlessTaskTriggerSource.WebUI, status: PaperlessTaskStatus.Started, acknowledged: false, task_id: '1237', - input_data: { filename: 'file4.pdf' }, + input_data: {}, date_created: new Date(), related_document_ids: [], }, { - task_type: PaperlessTaskType.ConsumeFile, + task_type: PaperlessTaskType.EmptyTrash, + trigger_source: PaperlessTaskTriggerSource.Manual, status: PaperlessTaskStatus.Success, acknowledged: false, task_id: '1238', - input_data: { filename: 'file5.pdf' }, + input_data: {}, date_created: new Date(), related_document_ids: [], }, @@ -117,7 +124,7 @@ describe('TasksService', () => { tasksService.reload() const req = httpTestingController.expectOne( - `${environment.apiBaseUrl}tasks/?task_type=consume_file&acknowledged=false` + `${environment.apiBaseUrl}tasks/?acknowledged=false` ) req.flush(mockTasks) @@ -129,6 +136,57 @@ describe('TasksService', () => { expect(tasksService.startedFileTasks).toHaveLength(1) }) + it('includes revoked tasks in needs attention', () => { + const mockTasks = [ + { + task_type: PaperlessTaskType.SanityCheck, + trigger_source: PaperlessTaskTriggerSource.System, + status: PaperlessTaskStatus.Failure, + acknowledged: false, + task_id: '1235', + input_data: {}, + date_created: new Date(), + related_document_ids: [], + }, + { + task_type: PaperlessTaskType.MailFetch, + trigger_source: PaperlessTaskTriggerSource.Scheduled, + status: PaperlessTaskStatus.Revoked, + acknowledged: false, + task_id: '1236', + input_data: {}, + date_created: new Date(), + related_document_ids: [], + }, + { + task_type: PaperlessTaskType.EmptyTrash, + trigger_source: PaperlessTaskTriggerSource.Manual, + status: PaperlessTaskStatus.Success, + acknowledged: false, + task_id: '1238', + input_data: {}, + date_created: new Date(), + related_document_ids: [], + }, + ] + + tasksService.reload() + + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}tasks/?acknowledged=false` + ) + + req.flush(mockTasks) + + expect(tasksService.needsAttentionTasks).toHaveLength(2) + expect(tasksService.needsAttentionTasks.map((task) => task.status)).toEqual( + expect.arrayContaining([ + PaperlessTaskStatus.Failure, + PaperlessTaskStatus.Revoked, + ]) + ) + }) + it('supports running tasks', () => { tasksService.run(PaperlessTaskType.SanityCheck).subscribe((res) => { expect(res).toEqual({ diff --git a/src-ui/src/app/services/tasks.service.ts b/src-ui/src/app/services/tasks.service.ts index bdfdf0eb1..c598d2b73 100644 --- a/src-ui/src/app/services/tasks.service.ts +++ b/src-ui/src/app/services/tasks.service.ts @@ -56,13 +56,21 @@ export class TasksService { ) } + public get needsAttentionTasks(): PaperlessTask[] { + return this.fileTasks.filter((t) => + [PaperlessTaskStatus.Failure, PaperlessTaskStatus.Revoked].includes( + t.status + ) + ) + } + public reload() { if (this.loading) return this.loading = true this.http .get( - `${this.baseUrl}${this.endpoint}/?task_type=${PaperlessTaskType.ConsumeFile}&acknowledged=false` + `${this.baseUrl}${this.endpoint}/?acknowledged=false` ) .pipe(takeUntil(this.unsubscribeNotifer), first()) .subscribe((r) => { diff --git a/src-ui/src/styles.scss b/src-ui/src/styles.scss index 109e7b38f..c0933b32b 100644 --- a/src-ui/src/styles.scss +++ b/src-ui/src/styles.scss @@ -119,6 +119,10 @@ table .btn-link { font-size: 1em; } +.fs-7 { + font-size: 0.75rem !important; +} + .bg-body { background-color: var(--bs-body-bg); } @@ -128,6 +132,10 @@ table .btn-link { color: var(--pngx-primary-text-contrast); } +.bg-darker { + background-color: var(--pngx-bg-darker) !important; +} + .navbar-brand { color: var(--pngx-primary-text-contrast) !important; }