Compare commits

...

24 Commits

Author SHA1 Message Date
shamoon
01edf9218c Coverage 2026-04-20 22:58:28 -07:00
shamoon
7046837740 Fix compatibility with duplicate_of instead of duplicate_documents 2026-04-20 22:58:20 -07:00
shamoon
a7c3336ba9 Update tasks.component.html 2026-04-20 22:40:39 -07:00
shamoon
ee62851d0f More sonar 2026-04-20 22:40:38 -07:00
shamoon
775212c86f Sonar stuff 2026-04-20 22:40:37 -07:00
shamoon
d8d8b18830 DRY 2026-04-20 22:40:36 -07:00
shamoon
1b984b7222 Better use these enums 2026-04-20 22:40:35 -07:00
shamoon
4ac47634ac Drop this 2026-04-20 22:40:34 -07:00
shamoon
e96f93b589 Fix text / css-class tests 2026-04-20 22:07:48 -07:00
shamoon
6c2d7adf76 Reorganize 2026-04-20 22:07:48 -07:00
shamoon
d8e2ab9e71 Add a reset filters 2026-04-20 22:07:47 -07:00
shamoon
ab76eddd85 Remove slicepipe 2026-04-20 22:07:47 -07:00
shamoon
4695df348c llm index name 2026-04-20 22:07:47 -07:00
shamoon
b46ccbe3d9 Update for compatibility with 58789e5061 2026-04-20 22:07:47 -07:00
shamoon
b2bc5a18ca Use needs attention for badge 2026-04-20 22:07:47 -07:00
shamoon
df98f471ae Fancy result details 2026-04-20 22:07:46 -07:00
shamoon
e67fbc7bc4 Fix borders 2026-04-20 22:07:46 -07:00
shamoon
8f8c3b072e Allow filtering by type + source 2026-04-20 22:07:46 -07:00
shamoon
ce1661aa57 Ok, move to tasks grouped by priority 2026-04-20 22:07:46 -07:00
shamoon
b8cc0e32ad Actually update this text 2026-04-20 22:07:45 -07:00
shamoon
74b0331a74 Make task names display noice 2026-04-20 22:07:45 -07:00
shamoon
4a837d3854 Change this type 2026-04-20 22:07:45 -07:00
shamoon
8fdd194f94 Get all tasks, update these tests first 2026-04-20 22:07:45 -07:00
shamoon
0e707391f1 Rename + update tour step 2026-04-20 22:07:45 -07:00
11 changed files with 1056 additions and 349 deletions

View File

@@ -219,7 +219,7 @@ export class AppComponent implements OnInit, OnDestroy {
}, },
{ {
anchorId: 'tour.file-tasks', 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', route: '/tasks',
backdropConfig: { backdropConfig: {
offset: 0, offset: 0,

View File

@@ -1,41 +1,16 @@
<pngx-page-header <pngx-page-header
title="File Tasks" title="Tasks"
i18n-title i18n-title
info="File Tasks shows you documents that have been consumed, are waiting to be, or may have failed during the process." info="Tasks shows detailed information about document consumption and system tasks."
i18n-info i18n-info
> >
<div class="btn-toolbar col col-md-auto align-items-center gap-2"> <div class="btn-toolbar col col-md-auto align-items-center gap-2">
<button class="btn btn-sm btn-outline-secondary me-2" (click)="clearSelection()" [hidden]="selectedTasks.size === 0"> <button class="btn btn-sm btn-outline-secondary me-2" (click)="clearSelection()" [hidden]="selectedTasks.size === 0">
<i-bs name="x" class="me-1"></i-bs><ng-container i18n>Clear selection</ng-container> <i-bs name="x" class="me-1"></i-bs><ng-container i18n>Clear selection</ng-container>
</button> </button>
<button class="btn btn-sm btn-outline-primary me-2" (click)="dismissTasks()" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }" [disabled]="tasksService.total === 0"> <button class="btn btn-sm btn-outline-primary me-2" (click)="dismissTasks()" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }" [disabled]="visibleTasks.length === 0">
<i-bs name="check2-all" class="me-1"></i-bs>{{dismissButtonText}} <i-bs name="check2-all" class="me-1"></i-bs>{{dismissButtonText}}
</button> </button>
<div class="form-inline d-flex align-items-center">
<div class="input-group input-group-sm flex-fill w-auto flex-nowrap">
<span class="input-group-text text-muted" i18n>Filter by</span>
@if (filterTargets.length > 1) {
<div ngbDropdown>
<button class="btn btn-sm btn-outline-primary" ngbDropdownToggle>{{filterTargetName}}</button>
<div class="dropdown-menu shadow" ngbDropdownMenu>
@for (t of filterTargets; track t.id) {
<button ngbDropdownItem [class.active]="filterTargetID === t.id" (click)="filterTargetID = t.id">{{t.name}}</button>
}
</div>
</div>
} @else {
<span class="input-group-text">{{filterTargetName}}</span>
}
@if (filterText?.length) {
<button class="btn btn-link btn-sm px-2 position-absolute top-0 end-0 z-10" (click)="resetFilter()">
<i-bs width="1em" height="1em" name="x"></i-bs>
</button>
}
<input #filterInput class="form-control form-control-sm" type="text"
(keyup)="filterInputKeyup($event)"
[(ngModel)]="filterText">
</div>
</div>
<div class="form-check form-switch mb-0 ms-2"> <div class="form-check form-switch mb-0 ms-2">
<input class="form-check-input" type="checkbox" role="switch" [(ngModel)]="autoRefreshEnabled"> <input class="form-check-input" type="checkbox" role="switch" [(ngModel)]="autoRefreshEnabled">
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label> <label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
@@ -48,133 +23,250 @@
<div class="visually-hidden" i18n>Loading...</div> <div class="visually-hidden" i18n>Loading...</div>
} }
<ng-template let-tasks="tasks" #tasksTemplate> <div class="task-controls mb-3 btn-toolbar">
<table class="table table-striped align-middle border shadow-sm"> <div class="task-view-scope btn-group btn-group-sm me-3" role="group">
<thead> <input
<tr> type="radio"
<th scope="col"> class="btn-check"
<div class="form-check"> [checked]="selectedSection === TaskSection.All"
<input type="checkbox" class="form-check-input" id="all-tasks" [disabled]="currentTasks.length === 0" [(ngModel)]="togggleAll" (click)="toggleAll($event); $event.stopPropagation();"> id="section-all"
<label class="form-check-label" for="all-tasks"></label> (click)="setSection(TaskSection.All)"
</div> (keydown)="setSection(TaskSection.All)" />
</th> <label class="btn btn-outline-primary" for="section-all">
<th scope="col" i18n>Name</th> <ng-container i18n>All</ng-container>
<th scope="col" class="d-none d-lg-table-cell" i18n>Created</th> </label>
@if (activeTab !== 'started' && activeTab !== 'queued') { @for (section of sections; track section) {
<th scope="col" class="d-none d-lg-table-cell" i18n>Results</th> <input
type="radio"
class="btn-check"
[checked]="selectedSection === section"
id="section-{{section}}"
(click)="setSection(section)"
(keydown)="setSection(section)" />
<label class="btn btn-outline-primary" for="section-{{section}}">
{{ sectionLabel(section) }}
@if (sectionCount(section) > 0) {
<span class="badge ms-2" [class.bg-danger]="section === TaskSection.NeedsAttention" [class.bg-secondary]="section !== TaskSection.NeedsAttention">{{sectionCount(section)}}</span>
} }
<th scope="col" class="d-table-cell d-lg-none" i18n>Info</th> </label>
<th scope="col" i18n>Actions</th> }
</tr> </div>
</thead>
<tbody> <div ngbDropdown>
@for (task of tasks | slice: (page-1) * pageSize : page * pageSize; track task.id) { <button class="btn btn-sm btn-outline-primary me-3" ngbDropdownToggle>{{selectedTaskTypeLabel}}</button>
<tr (click)="toggleSelected(task, $event); $event.stopPropagation();"> <div class="dropdown-menu shadow" ngbDropdownMenu>
<td> <button ngbDropdownItem [class.active]="selectedTaskType === null" (click)="setTaskType(null)" i18n>All types</button>
<div class="form-check"> @for (option of taskTypeOptions; track option.value) {
<input type="checkbox" class="form-check-input" id="task{{task.id}}" [checked]="selectedTasks.has(task.id)" (click)="toggleSelected(task, $event); $event.stopPropagation();"> <button ngbDropdownItem [class.active]="selectedTaskType === option.value" [disabled]="isTaskTypeOptionDisabled(option.value)" (click)="setTaskType(option.value)">{{option.label}}</button>
<label class="form-check-label" for="task{{task.id}}"></label> }
</div> </div>
</td> </div>
<td class="overflow-auto name-col">{{ task.input_data?.filename }}</td> <div ngbDropdown>
<td class="d-none d-lg-table-cell">{{ task.date_created | customDate:'short' }}</td> <button class="btn btn-sm btn-outline-primary me-3" ngbDropdownToggle>{{selectedTriggerSourceLabel}}</button>
@if (activeTab !== 'started' && activeTab !== 'queued') { <div class="dropdown-menu shadow" ngbDropdownMenu>
<td class="d-none d-lg-table-cell"> <button ngbDropdownItem [class.active]="selectedTriggerSource === null" (click)="setTriggerSource(null)" i18n>All sources</button>
@if (task.result_message?.length > 50) { @for (option of triggerSourceOptions; track option.value) {
<div class="result" (click)="expandTask(task); $event.stopPropagation();" <button ngbDropdownItem [class.active]="selectedTriggerSource === option.value" [disabled]="isTriggerSourceOptionDisabled(option.value)" (click)="setTriggerSource(option.value)">{{option.label}}</button>
[ngbPopover]="resultPopover" popoverClass="shadow small mobile" triggers="mouseenter:mouseleave" container="body"> }
<span class="small d-none d-md-inline-block font-monospace text-muted">{{ task.result_message | slice:0:50 }}&hellip;</span> </div>
</div> </div>
}
@if (task.result_message?.length <= 50) { <div class="form-inline d-flex align-items-center flex-grow-1 task-search">
<span class="small d-none d-md-inline-block font-monospace text-muted">{{ task.result_message }}</span> <div class="input-group input-group-sm flex-fill w-auto flex-nowrap">
} <span class="input-group-text text-muted" i18n>Filter by</span>
<ng-template #resultPopover> <div ngbDropdown>
<pre class="small mb-0">{{ task.result_message | slice:0:300 }}@if (task.result_message.length > 300) { <button class="btn btn-sm btn-outline-primary" ngbDropdownToggle>{{filterTargetName}}</button>
&hellip; <div class="dropdown-menu shadow" ngbDropdownMenu>
}</pre> @for (t of filterTargets; track t.id) {
@if (task.result_message?.length > 300) { <button ngbDropdownItem [class.active]="filterTargetID === t.id" (click)="filterTargetID = t.id">{{t.name}}</button>
<br/><em>(<ng-container i18n>click for full output</ng-container>)</em>
}
</ng-template>
</td>
} }
<td class="d-lg-none"> </div>
<button class="btn btn-link" (click)="expandTask(task); $event.stopPropagation();"> </div>
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs> @if (filterText?.length) {
</button> <button class="btn btn-link btn-sm px-2 position-absolute top-0 end-0 z-10" (click)="resetFilter()">
</td> <i-bs width="1em" height="1em" name="x"></i-bs>
<td scope="row"> </button>
<div class="btn-group" role="group"> }
<button class="btn btn-sm btn-outline-secondary" (click)="dismissTask(task); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }"> <input #filterInput class="form-control form-control-sm" type="text"
<i-bs name="check" class="me-1"></i-bs><ng-container i18n>Dismiss</ng-container> (keyup)="filterInputKeyup($event)"
</button> [(ngModel)]="filterText">
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"> </div>
@if (task.related_document_ids?.[0]) { </div>
<button class="btn btn-sm btn-outline-primary" (click)="dismissAndGo(task); $event.stopPropagation();">
<i-bs name="file-text" class="me-1"></i-bs><ng-container i18n>Open Document</ng-container> @if (isFiltered) {
</button> <button class="btn btn-link py-0 ms-md-auto" (click)="resetFilters()">
} <i-bs width="1em" height="1em" name="x"></i-bs><small i18n>Reset filters</small>
</ng-container> </button>
</div> }
</td> </div>
</tr>
<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>
</div>
<div class="card border table-responsive mb-3">
<table class="table table-striped align-middle shadow-sm mb-0">
<thead>
<tr> <tr>
<td class="p-0" [class.border-0]="expandedTask !== task.id" colspan="5"> <th scope="col">
<pre #collapse="ngbCollapse" [ngbCollapse]="expandedTask !== task.id" class="small mb-0"><div class="small p-1 p-lg-3 ms-lg-3">{{ task.result_message }}</div></pre> <div class="form-check">
<input
type="checkbox"
class="form-check-input"
[id]="'all-tasks-' + section"
[disabled]="tasks.length === 0"
[checked]="areAllSelected(tasks)"
(click)="toggleSection(section, $event); $event.stopPropagation();"
(keydown)="toggleSection(section, $event); $event.stopPropagation();" />
<label class="form-check-label" for="all-tasks-{{section}}"><span class="visually-hidden">Check all</span></label>
</div>
</th>
<th scope="col" i18n>Name</th>
<th scope="col" class="d-none d-lg-table-cell" i18n>Created</th>
@if (sectionShowsResults(section)) {
<th scope="col" class="d-none d-lg-table-cell" i18n>Results</th>
}
<th scope="col" class="d-table-cell d-lg-none" i18n>Info</th>
<th scope="col" i18n>Actions</th>
</tr>
</thead>
<tbody>
@for (task of tasks; track task.id) {
<tr (click)="toggleSelected(task); $event.stopPropagation();" (keydown)="toggleSelected(task); $event.stopPropagation();">
<td>
<div class="form-check">
<input
type="checkbox"
class="form-check-input"
id="task{{task.id}}"
[checked]="selectedTasks.has(task.id)"
(click)="toggleSelected(task); $event.stopPropagation();"
(keydown)="toggleSelected(task); $event.stopPropagation();" />
<label class="form-check-label" for="task{{task.id}}"></label>
</div>
</td>
<td class="overflow-auto name-col">
<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>{{ task.trigger_source_display }}</span>
</div>
</td>
<td class="d-none d-lg-table-cell">{{ task.date_created | customDate:'short' }}</td>
@if (sectionShowsResults(section)) {
<td class="d-none d-lg-table-cell">
@if (taskHasLongResultMessage(task)) {
<div class="result" (click)="expandTask(task); $event.stopPropagation();"
[ngbPopover]="resultPopover" popoverClass="shadow small mobile" triggers="mouseenter:mouseleave" container="body">
<span class="small d-none d-md-inline-block font-monospace text-muted">{{ taskResultPreview(task) }}</span>
</div>
}
@if (taskHasResultMessage(task) && !taskHasLongResultMessage(task)) {
<span class="small d-none d-md-inline-block font-monospace text-muted">{{ taskResultMessage(task) }}</span>
}
@if (duplicateDocumentId(task)) {
<div class="small text-warning-emphasis d-flex align-items-center gap-1 mt-1">
<i-bs class="lh-1" width="1em" height="1em" name="exclamation-triangle"></i-bs>
<span>{{ duplicateTaskLabel(task) }}</span>
</div>
}
<ng-template #resultPopover>
<pre class="small mb-0">{{ taskResultPopoverMessage(task) }}@if (taskResultMessageOverflowsPopover(task)) {
&hellip;
}</pre>
@if (taskResultMessageOverflowsPopover(task)) {
<br/><em>(<ng-container i18n>click for full output</ng-container>)</em>
}
</ng-template>
</td>
}
<td class="d-lg-none">
<button class="btn btn-link" (click)="expandTask(task); $event.stopPropagation();">
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
</button>
</td>
<td scope="row">
<div class="btn-group" role="group">
<button class="btn btn-sm btn-outline-secondary" (click)="dismissTask(task); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }">
<i-bs name="check" class="me-1"></i-bs><ng-container i18n>Dismiss</ng-container>
</button>
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
@if (task.related_document_ids?.[0]) {
<button class="btn btn-sm btn-outline-primary" (click)="dismissAndGo(task); $event.stopPropagation();">
<i-bs name="file-text" class="me-1"></i-bs><ng-container i18n>Open Document</ng-container>
</button>
}
</ng-container>
</div>
</td>
</tr>
<tr>
<td class="p-0" [class.border-0]="expandedTask !== task.id" [attr.colspan]="sectionShowsResults(section) ? 5 : 4">
<div #collapse="ngbCollapse" [ngbCollapse]="expandedTask !== task.id" class="task-detail-panel bg-darker small mb-0">
<div class="p-2 p-lg-3 ms-lg-3">
@if (taskHasResultMessage(task)) {
<div class="detail-section mb-3">
<div class="detail-label fs-7 fw-bold text-uppercase text-muted mb-1" i18n>Result message</div>
<pre class="detail-block border border-dark bg-body p-3 rounded-2 mb-0">{{ taskResultMessage(task) }}</pre>
</div>
}
@if (duplicateDocumentId(task); as duplicateDocumentId) {
<div class="detail-section mb-3">
<div class="detail-label fs-7 fw-bold text-uppercase text-muted mb-1" i18n>Duplicate</div>
<div class="detail-block border border-dark bg-body p-3 rounded-2 mb-0">
<div class="d-flex align-items-center justify-content-between gap-3">
<div class="text-break">{{ duplicateTaskLabel(task) }}</div>
<button
class="btn btn-sm btn-outline-primary"
type="button"
(click)="openDuplicateDocument(duplicateDocumentId)">
<ng-container i18n>Open</ng-container>
</button>
</div>
</div>
</div>
}
<div class="row g-3">
<div class="col-12 col-xl-6">
<div class="detail-section h-100">
<div class="detail-label fs-7 fw-bold text-uppercase text-muted mb-1" i18n>Input data</div>
<pre class="detail-block border border-dark bg-body p-3 rounded-2 mb-0">{{ task.input_data | json }}</pre>
</div>
</div>
<div class="col-12 col-xl-6">
<div class="detail-section h-100">
<div class="detail-label fs-7 fw-bold text-uppercase text-muted mb-1" i18n>Result data</div>
<pre class="detail-block border border-dark bg-body p-3 rounded-2 mb-0">{{ (task.result_data ?? {}) | json }}</pre>
</div>
</div>
</div>
</div>
</div>
</td> </td>
</tr> </tr>
} }
</tbody> </tbody>
</table> </table>
<div class="pb-3 d-sm-flex justify-content-between align-items-center">
@if (tasks.length > 0) {
<div class="pb-2 pb-sm-0">
<ng-container i18n>{tasks.length, plural, =1 {One {{this.activeTabLocalized}} task} other {{{tasks.length || 0}} total {{this.activeTabLocalized}} tasks}}</ng-container>
@if (selectedTasks.size > 0) {
<ng-container i18n>&nbsp;({{selectedTasks.size}} selected)</ng-container>
}
</div>
}
@if (tasks.length > pageSize) {
<ngb-pagination [(page)]="page" [pageSize]="pageSize" [collectionSize]="tasks.length" maxSize="8" size="sm"></ngb-pagination>
}
</div> </div>
</ng-template> </ng-template>
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTab" class="nav-tabs" (hidden)="duringTabChange()" (navChange)="beforeTabChange()"> @if (visibleSections.length > 0) {
<li ngbNavItem="failed"> @for (section of visibleSections; track section) {
<a ngbNavLink i18n>Failed@if (tasksService.failedFileTasks.length > 0) { <div class="mb-4">
<span class="badge bg-danger ms-2">{{tasksService.failedFileTasks.length}}</span> <ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks: tasksForSection(section), section: section}"></ng-container>
}</a> </div>
<ng-template ngbNavContent> }
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:currentTasks}"></ng-container> } @else {
</ng-template> <div class="alert alert-secondary fst-italic" i18n>No tasks match the current filters.</div>
</li> }
<li ngbNavItem="completed">
<a ngbNavLink i18n>Complete@if (tasksService.completedFileTasks.length > 0) {
<span class="badge bg-secondary ms-2">{{tasksService.completedFileTasks.length}}</span>
}</a>
<ng-template ngbNavContent>
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:currentTasks}"></ng-container>
</ng-template>
</li>
<li ngbNavItem="started">
<a ngbNavLink i18n>Started@if (tasksService.startedFileTasks.length > 0) {
<span class="badge bg-secondary ms-2">{{tasksService.startedFileTasks.length}}</span>
}</a>
<ng-template ngbNavContent>
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:currentTasks}"></ng-container>
</ng-template>
</li>
<li ngbNavItem="queued">
<a ngbNavLink i18n>Queued@if (tasksService.queuedFileTasks.length > 0) {
<span class="badge bg-secondary ms-2">{{tasksService.queuedFileTasks.length}}</span>
}</a>
<ng-template ngbNavContent>
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:currentTasks}"></ng-container>
</ng-template>
</li>
</ul>
<div [ngbNavOutlet]="nav"></div>

View File

@@ -37,3 +37,7 @@ pre {
.z-10 { .z-10 {
z-index: 10; z-index: 10;
} }
tbody tr:nth-last-child(2) td {
border-bottom: none !important;
}

View File

@@ -9,12 +9,7 @@ import { FormsModule } from '@angular/forms'
import { By } from '@angular/platform-browser' import { By } from '@angular/platform-browser'
import { Router } from '@angular/router' import { Router } from '@angular/router'
import { RouterTestingModule } from '@angular/router/testing' import { RouterTestingModule } from '@angular/router/testing'
import { import { NgbModal, NgbModalRef, NgbModule } from '@ng-bootstrap/ng-bootstrap'
NgbModal,
NgbModalRef,
NgbModule,
NgbNavItem,
} from '@ng-bootstrap/ng-bootstrap'
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { throwError } from 'rxjs' import { throwError } from 'rxjs'
import { routes } from 'src/app/app-routing.module' 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 { environment } from 'src/environments/environment'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.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[] = [ const tasks: PaperlessTask[] = [
{ {
@@ -48,8 +43,10 @@ const tasks: PaperlessTask[] = [
trigger_source_display: 'Folder Consume', trigger_source_display: 'Folder Consume',
status: PaperlessTaskStatus.Failure, status: PaperlessTaskStatus.Failure,
status_display: 'Failure', status_display: 'Failure',
result_message: result_data: {
'test.pd: Not consuming test.pdf: It is a duplicate of test (#100)', error_message:
'test.pd: Not consuming test.pdf: It is a duplicate of test (#100)',
},
acknowledged: false, acknowledged: false,
related_document_ids: [], related_document_ids: [],
}, },
@@ -65,8 +62,7 @@ const tasks: PaperlessTask[] = [
trigger_source_display: 'Folder Consume', trigger_source_display: 'Folder Consume',
status: PaperlessTaskStatus.Failure, status: PaperlessTaskStatus.Failure,
status_display: 'Failure', status_display: 'Failure',
result_message: result_data: { duplicate_of: 311 },
'191092.pd: Not consuming 191092.pdf: It is a duplicate of 191092 (#311)',
acknowledged: false, acknowledged: false,
related_document_ids: [], related_document_ids: [],
}, },
@@ -82,7 +78,7 @@ const tasks: PaperlessTask[] = [
trigger_source_display: 'Folder Consume', trigger_source_display: 'Folder Consume',
status: PaperlessTaskStatus.Pending, status: PaperlessTaskStatus.Pending,
status_display: 'Pending', status_display: 'Pending',
result_message: null, result_data: null,
acknowledged: false, acknowledged: false,
related_document_ids: [], related_document_ids: [],
}, },
@@ -98,7 +94,7 @@ const tasks: PaperlessTask[] = [
trigger_source_display: 'Email Consume', trigger_source_display: 'Email Consume',
status: PaperlessTaskStatus.Success, status: PaperlessTaskStatus.Success,
status_display: 'Success', status_display: 'Success',
result_message: 'Success. New document id 422 created', result_data: { document_id: 422, duplicate_of: 99 },
acknowledged: false, acknowledged: false,
related_document_ids: [422], related_document_ids: [422],
}, },
@@ -114,7 +110,7 @@ const tasks: PaperlessTask[] = [
trigger_source_display: 'Folder Consume', trigger_source_display: 'Folder Consume',
status: PaperlessTaskStatus.Success, status: PaperlessTaskStatus.Success,
status_display: 'Success', status_display: 'Success',
result_message: 'Success. New document id 421 created', result_data: { document_id: 421 },
acknowledged: false, acknowledged: false,
related_document_ids: [421], related_document_ids: [421],
}, },
@@ -130,7 +126,23 @@ const tasks: PaperlessTask[] = [
trigger_source_display: 'Email Consume', trigger_source_display: 'Email Consume',
status: PaperlessTaskStatus.Started, status: PaperlessTaskStatus.Started,
status_display: '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, acknowledged: false,
related_document_ids: [], related_document_ids: [],
}, },
@@ -185,59 +197,142 @@ describe('TasksComponent', () => {
jest.useFakeTimers() jest.useFakeTimers()
fixture.detectChanges() fixture.detectChanges()
httpTestingController httpTestingController
.expectOne( .expectOne(`${environment.apiBaseUrl}tasks/?acknowledged=false`)
`${environment.apiBaseUrl}tasks/?task_type=consume_file&acknowledged=false`
)
.flush(tasks) .flush(tasks)
}) })
it('should display file tasks in 4 tabs by status', () => { it('should display task sections with counts', () => {
const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavItem)) 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() 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( const viewScope = fixture.debugElement.query(By.css('.task-view-scope'))
(t) => t.status === PaperlessTaskStatus.Success const text = viewScope.nativeElement.textContent
).length
component.activeTab = TaskTab.Completed
fixture.detectChanges()
expect(tabButtons[1].nativeElement.textContent).toEqual(
`Complete${currentTasksLength}`
)
currentTasksLength = tasks.filter( expect(text).toContain('All')
(t) => t.status === PaperlessTaskStatus.Started expect(text).toContain('Needs attention')
).length expect(text).toContain('2')
component.activeTab = TaskTab.Started expect(text).toContain('In progress')
fixture.detectChanges() expect(text).toContain('3')
expect(tabButtons[2].nativeElement.textContent).toEqual( expect(text).toContain('Recently completed')
`Started${currentTasksLength}` })
)
currentTasksLength = tasks.filter( it('should filter visible sections by selected status', () => {
(t) => t.status === PaperlessTaskStatus.Pending component.setSection(TaskSection.InProgress)
).length
component.activeTab = TaskTab.Queued
fixture.detectChanges() 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', () => { it('should filter tasks by trigger source', () => {
component.page = 10 component.setSection(TaskSection.InProgress)
component.duringTabChange() component.setTriggerSource(PaperlessTaskTriggerSource.EmailConsume)
expect(component.page).toEqual(1)
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', () => { it('should support expanding / collapsing one task at a time', () => {
@@ -249,6 +344,31 @@ describe('TasksComponent', () => {
expect(component.expandedTask).toBeUndefined() 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', () => { it('should support dismiss single task', () => {
const dismissSpy = jest.spyOn(tasksService, 'dismissTasks') const dismissSpy = jest.spyOn(tasksService, 'dismissTasks')
component.dismissTask(tasks[0]) component.dismissTask(tasks[0])
@@ -259,7 +379,7 @@ describe('TasksComponent', () => {
component.toggleSelected(tasks[0]) component.toggleSelected(tasks[0])
component.toggleSelected(tasks[1]) component.toggleSelected(tasks[1])
component.toggleSelected(tasks[3]) 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]) const selected = new Set([tasks[0].id, tasks[1].id])
expect(component.selectedTasks).toEqual(selected) expect(component.selectedTasks).toEqual(selected)
let modal: NgbModalRef let modal: NgbModalRef
@@ -308,31 +428,50 @@ describe('TasksComponent', () => {
expect(component.selectedTasks.size).toBe(0) 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 let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1])) modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const dismissSpy = jest.spyOn(tasksService, 'dismissTasks') const dismissSpy = jest.spyOn(tasksService, 'dismissTasks')
component.dismissTasks() component.dismissTasks()
expect(modal).not.toBeUndefined() expect(modal).not.toBeUndefined()
modal.componentInstance.confirmClicked.emit() 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( const toggleCheck = fixture.debugElement.query(
By.css('table input[type=checkbox]') By.css('#all-tasks-needs_attention')
)
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)
)
) )
expect(toggleCheck).not.toBeNull()
toggleCheck.nativeElement.dispatchEvent(new MouseEvent('click')) toggleCheck.nativeElement.dispatchEvent(new MouseEvent('click'))
fixture.detectChanges() 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()) expect(component.selectedTasks).toEqual(new Set())
}) })
@@ -355,57 +494,127 @@ describe('TasksComponent', () => {
}) })
it('should filter tasks by file name', () => { it('should filter tasks by file name', () => {
fixture.detectChanges()
const input = fixture.debugElement.query( 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.value = '191092'
input.nativeElement.dispatchEvent(new Event('input')) input.nativeElement.dispatchEvent(new Event('input'))
jest.advanceTimersByTime(150) // debounce time jest.advanceTimersByTime(150)
fixture.detectChanges() fixture.detectChanges()
expect(component.filterText).toEqual('191092') expect(component.filterText).toEqual('191092')
expect( expect(component.tasksForSection(TaskSection.NeedsAttention)).toHaveLength(
fixture.debugElement.queryAll(By.css('table tbody tr')).length 1
).toEqual(2) // 1 task x 2 lines )
})
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', () => { it('should filter tasks by result', () => {
component.activeTab = TaskTab.Failed component.setSection(TaskSection.NeedsAttention)
fixture.detectChanges()
component.filterTargetID = 1 component.filterTargetID = 1
fixture.detectChanges()
const input = fixture.debugElement.query( 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.value = 'duplicate'
input.nativeElement.dispatchEvent(new Event('input')) input.nativeElement.dispatchEvent(new Event('input'))
jest.advanceTimersByTime(150) // debounce time jest.advanceTimersByTime(150)
fixture.detectChanges() fixture.detectChanges()
expect(component.filterText).toEqual('duplicate') expect(component.filterText).toEqual('duplicate')
expect(component.tasksForSection(TaskSection.NeedsAttention)).toHaveLength(
2
)
})
it('should prefer explicit reason in the result message', () => {
expect( expect(
fixture.debugElement.queryAll(By.css('table tbody tr')).length component.taskResultMessage({
).toEqual(4) // 2 tasks x 2 lines ...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', () => { it('should support keyboard events for filtering', () => {
fixture.detectChanges()
const input = fixture.debugElement.query( 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.value = '191092'
input.nativeElement.dispatchEvent( input.nativeElement.dispatchEvent(
new KeyboardEvent('keyup', { key: 'Enter' }) new KeyboardEvent('keyup', { key: 'Enter' })
) )
expect(component.filterText).toEqual('191092') // no debounce needed expect(component.filterText).toEqual('191092')
input.nativeElement.dispatchEvent( input.nativeElement.dispatchEvent(
new KeyboardEvent('keyup', { key: 'Escape' }) new KeyboardEvent('keyup', { key: 'Escape' })
) )
expect(component.filterText).toEqual('') expect(component.filterText).toEqual('')
}) })
it('should reset filter and target on tab switch', () => { it('should keep clearing selection independent from resetting filters', () => {
component.filterText = '191092' component.setTaskType(PaperlessTaskType.ConsumeFile)
component.filterTargetID = 1 component.toggleSelected(tasks[0])
component.activeTab = TaskTab.Completed expect(component.selectedTasks.size).toBe(1)
component.beforeTabChange()
expect(component.filterText).toEqual('') component.clearSelection()
expect(component.filterTargetID).toEqual(0)
expect(component.selectedTasks.size).toBe(0)
expect(component.selectedTaskType).toBe(PaperlessTaskType.ConsumeFile)
expect(component.isFiltered).toBe(true)
}) })
}) })

View File

@@ -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 { Component, inject, OnDestroy, OnInit } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { Router } from '@angular/router' import { Router } from '@angular/router'
@@ -6,8 +6,6 @@ import {
NgbCollapseModule, NgbCollapseModule,
NgbDropdownModule, NgbDropdownModule,
NgbModal, NgbModal,
NgbNavModule,
NgbPaginationModule,
NgbPopoverModule, NgbPopoverModule,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
@@ -20,7 +18,12 @@ import {
takeUntil, takeUntil,
timer, timer,
} from 'rxjs' } 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 { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { TasksService } from 'src/app/services/tasks.service' 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 { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
export enum TaskTab { export enum TaskSection {
Queued = 'queued', All = 'all',
Started = 'started', NeedsAttention = 'needs_attention',
InProgress = 'in_progress',
Completed = 'completed', Completed = 'completed',
Failed = 'failed',
} }
enum TaskFilterTargetID { enum TaskFilterTargetID {
@@ -46,6 +49,82 @@ const FILTER_TARGETS = [
{ id: TaskFilterTargetID.Result, name: $localize`Result` }, { 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({ @Component({
selector: 'pngx-tasks', selector: 'pngx-tasks',
templateUrl: './tasks.component.html', templateUrl: './tasks.component.html',
@@ -54,14 +133,12 @@ const FILTER_TARGETS = [
PageHeaderComponent, PageHeaderComponent,
IfPermissionsDirective, IfPermissionsDirective,
CustomDatePipe, CustomDatePipe,
SlicePipe, JsonPipe,
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
NgTemplateOutlet, NgTemplateOutlet,
NgbCollapseModule, NgbCollapseModule,
NgbDropdownModule, NgbDropdownModule,
NgbNavModule,
NgbPaginationModule,
NgbPopoverModule, NgbPopoverModule,
NgxBootstrapIconsModule, NgxBootstrapIconsModule,
], ],
@@ -75,15 +152,18 @@ export class TasksComponent
private readonly router = inject(Router) private readonly router = inject(Router)
private readonly toastService = inject(ToastService) private readonly toastService = inject(ToastService)
public activeTab: TaskTab readonly TaskSection = TaskSection
readonly sections = [
TaskSection.NeedsAttention,
TaskSection.InProgress,
TaskSection.Completed,
]
public selectedTasks: Set<number> = new Set() public selectedTasks: Set<number> = new Set()
public togggleAll: boolean = false
public expandedTask: number public expandedTask: number
public pageSize: number = 25
public page: number = 1
public autoRefreshEnabled: boolean = true public autoRefreshEnabled: boolean = true
public selectedSection: TaskSection = TaskSection.All
public selectedTaskType: PaperlessTaskType | null = null
public selectedTriggerSource: PaperlessTaskTriggerSource | null = null
private _filterText: string = '' private _filterText: string = ''
get filterText() { get filterText() {
@@ -95,20 +175,81 @@ export class TasksComponent
public filterTargetID: TaskFilterTargetID = TaskFilterTargetID.Name public filterTargetID: TaskFilterTargetID = TaskFilterTargetID.Name
public get filterTargetName(): string { 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<string> = new Subject<string>() private filterDebounce: Subject<string> = new Subject<string>()
public get filterTargets(): Array<{ id: number; name: string }> { public get filterTargets(): Array<{ id: number; name: string }> {
return [TaskTab.Failed, TaskTab.Completed].includes(this.activeTab) return FILTER_TARGETS
? FILTER_TARGETS }
: FILTER_TARGETS.slice(0, 1)
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 { get dismissButtonText(): string {
return this.selectedTasks.size > 0 return this.selectedTasks.size > 0
? $localize`Dismiss selected` ? $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() { ngOnInit() {
@@ -143,14 +284,16 @@ export class TasksComponent
dismissTasks(task: PaperlessTask = undefined) { dismissTasks(task: PaperlessTask = undefined) {
let tasks = task ? new Set([task.id]) : new Set(this.selectedTasks.values()) let tasks = task ? new Set([task.id]) : new Set(this.selectedTasks.values())
if (!task && tasks.size == 0) if (!task && tasks.size == 0) {
tasks = new Set(this.tasksService.allFileTasks.map((t) => t.id)) tasks = new Set(this.visibleTasks.map((t) => t.id))
}
if (tasks.size > 1) { if (tasks.size > 1) {
let modal = this.modalService.open(ConfirmDialogComponent, { let modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static', backdrop: 'static',
}) })
modal.componentInstance.title = $localize`Confirm Dismiss All` modal.componentInstance.title = $localize`Confirm Dismiss`
modal.componentInstance.messageBold = $localize`Dismiss all ${tasks.size} tasks?` modal.componentInstance.messageBold = $localize`Dismiss ${tasks.size} tasks?`
modal.componentInstance.btnClass = 'btn-warning' modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Dismiss` modal.componentInstance.btnCaption = $localize`Dismiss`
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => { modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
@@ -164,7 +307,7 @@ export class TasksComponent
}) })
this.clearSelection() this.clearSelection()
}) })
} else { } else if (tasks.size === 1) {
this.tasksService.dismissTasks(tasks).subscribe({ this.tasksService.dismissTasks(tasks).subscribe({
error: (e) => error: (e) =>
this.toastService.showError($localize`Error dismissing task`, e), this.toastService.showError($localize`Error dismissing task`, e),
@@ -188,77 +331,167 @@ export class TasksComponent
: this.selectedTasks.add(task.id) : this.selectedTasks.add(task.id)
} }
get currentTasks(): PaperlessTask[] { toggleSection(section: TaskSection, event: PointerEvent) {
let tasks: PaperlessTask[] = [] const sectionTasks = this.tasksForSection(section)
switch (this.activeTab) { if ((event.target as HTMLInputElement).checked) {
case TaskTab.Queued: sectionTasks.forEach((task) => this.selectedTasks.add(task.id))
tasks = this.tasksService.queuedFileTasks } else {
break sectionTasks.forEach((task) => this.selectedTasks.delete(task.id))
case TaskTab.Started:
tasks = this.tasksService.startedFileTasks
break
case TaskTab.Completed:
tasks = this.tasksService.completedFileTasks
break
case TaskTab.Failed:
tasks = this.tasksService.failedFileTasks
break
} }
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) { areAllSelected(tasks: PaperlessTask[]): boolean {
if ((event.target as HTMLInputElement).checked) { return (
this.selectedTasks = new Set(this.currentTasks.map((t) => t.id)) tasks.length > 0 && tasks.every((task) => this.selectedTasks.has(task.id))
} else { )
this.clearSelection() }
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() { clearSelection() {
this.togggleAll = false
this.selectedTasks.clear() 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() { public resetFilter() {
this._filterText = '' this._filterText = ''
} }
public resetFilters() {
this.selectedTaskType = null
this.selectedTriggerSource = null
this.resetFilter()
this.clearSelection()
}
filterInputKeyup(event: KeyboardEvent) { filterInputKeyup(event: KeyboardEvent) {
if (event.key == 'Enter') { if (event.key == 'Enter') {
this._filterText = (event.target as HTMLInputElement).value this._filterText = (event.target as HTMLInputElement).value
@@ -266,4 +499,87 @@ export class TasksComponent
this.resetFilter() 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 })
)
}
} }

View File

@@ -294,13 +294,13 @@
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }"
tourAnchor="tour.file-tasks"> tourAnchor="tour.file-tasks">
<a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="File Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" ngbPopover="Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="list-task"></i-bs><span><ng-container i18n>File Tasks</ng-container>@if (tasksService.failedFileTasks.length > 0) { <i-bs class="me-2" name="list-task"></i-bs><span><ng-container i18n>Tasks</ng-container>@if (tasksService.needsAttentionTasks.length > 0) {
<span><span class="badge bg-danger ms-2 d-inline">{{tasksService.failedFileTasks.length}}</span></span> <span><span class="badge bg-danger ms-2 d-inline">{{tasksService.needsAttentionTasks.length}}</span></span>
}</span> }</span>
@if (tasksService.failedFileTasks.length > 0 && slimSidebarEnabled) { @if (tasksService.needsAttentionTasks.length > 0 && slimSidebarEnabled) {
<span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.failedFileTasks.length}}</span> <span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.needsAttentionTasks.length}}</span>
} }
</a> </a>
</li> </li>

View File

@@ -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 { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SearchService } from 'src/app/services/rest/search.service' import { SearchService } from 'src/app/services/rest/search.service'
import { SettingsService } from 'src/app/services/settings.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 { ToastService } from 'src/app/services/toast.service'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component' import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
@@ -97,6 +98,7 @@ describe('AppFrameComponent', () => {
let savedViewSpy let savedViewSpy
let modalService: NgbModal let modalService: NgbModal
let maybeRefreshSpy let maybeRefreshSpy
let tasksService: TasksService
beforeEach(async () => { beforeEach(async () => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@@ -174,6 +176,7 @@ describe('AppFrameComponent', () => {
openDocumentsService = TestBed.inject(OpenDocumentsService) openDocumentsService = TestBed.inject(OpenDocumentsService)
modalService = TestBed.inject(NgbModal) modalService = TestBed.inject(NgbModal)
router = TestBed.inject(Router) router = TestBed.inject(Router)
tasksService = TestBed.inject(TasksService)
jest jest
.spyOn(settingsService, 'displayName', 'get') .spyOn(settingsService, 'displayName', 'get')
@@ -444,6 +447,16 @@ describe('AppFrameComponent', () => {
expect(maybeRefreshSpy).toHaveBeenCalled() 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', () => { it('should indicate attributes management availability when any permission is granted', () => {
jest jest
.spyOn(permissionsService, 'currentUserCan') .spyOn(permissionsService, 'currentUserCan')

View File

@@ -45,9 +45,8 @@ export interface PaperlessTask extends ObjectWithId {
date_done?: Date date_done?: Date
duration_seconds?: number duration_seconds?: number
wait_time_seconds?: number wait_time_seconds?: number
input_data: Record<string, unknown> input_data: Record<string, any>
result_data?: Record<string, unknown> result_data?: Record<string, any>
result_message?: string
related_document_ids: number[] related_document_ids: number[]
acknowledged: boolean acknowledged: boolean
owner?: number owner?: number

View File

@@ -5,7 +5,11 @@ import {
} from '@angular/common/http/testing' } from '@angular/common/http/testing'
import { TestBed } from '@angular/core/testing' import { TestBed } from '@angular/core/testing'
import { environment } from 'src/environments/environment' 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' import { TasksService } from './tasks.service'
describe('TasksService', () => { describe('TasksService', () => {
@@ -33,7 +37,7 @@ describe('TasksService', () => {
it('calls tasks api endpoint on reload', () => { it('calls tasks api endpoint on reload', () => {
tasksService.reload() tasksService.reload()
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}tasks/?task_type=consume_file&acknowledged=false` `${environment.apiBaseUrl}tasks/?acknowledged=false`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
}) })
@@ -42,7 +46,7 @@ describe('TasksService', () => {
tasksService.loading = true tasksService.loading = true
tasksService.reload() tasksService.reload()
httpTestingController.expectNone( httpTestingController.expectNone(
`${environment.apiBaseUrl}tasks/?task_type=consume_file&acknowledged=false` `${environment.apiBaseUrl}tasks/?acknowledged=false`
) )
}) })
@@ -58,17 +62,16 @@ describe('TasksService', () => {
req.flush([]) req.flush([])
// reload is then called // reload is then called
httpTestingController httpTestingController
.expectOne( .expectOne(`${environment.apiBaseUrl}tasks/?acknowledged=false`)
`${environment.apiBaseUrl}tasks/?task_type=consume_file&acknowledged=false`
)
.flush([]) .flush([])
}) })
it('sorts tasks returned from api', () => { it('groups mixed task types by status when reloading', () => {
expect(tasksService.total).toEqual(0) expect(tasksService.total).toEqual(0)
const mockTasks = [ const mockTasks = [
{ {
task_type: PaperlessTaskType.ConsumeFile, task_type: PaperlessTaskType.ConsumeFile,
trigger_source: PaperlessTaskTriggerSource.FolderConsume,
status: PaperlessTaskStatus.Success, status: PaperlessTaskStatus.Success,
acknowledged: false, acknowledged: false,
task_id: '1234', task_id: '1234',
@@ -77,38 +80,42 @@ describe('TasksService', () => {
related_document_ids: [], related_document_ids: [],
}, },
{ {
task_type: PaperlessTaskType.ConsumeFile, task_type: PaperlessTaskType.SanityCheck,
trigger_source: PaperlessTaskTriggerSource.System,
status: PaperlessTaskStatus.Failure, status: PaperlessTaskStatus.Failure,
acknowledged: false, acknowledged: false,
task_id: '1235', task_id: '1235',
input_data: { filename: 'file2.pdf' }, input_data: {},
date_created: new Date(), date_created: new Date(),
related_document_ids: [], related_document_ids: [],
}, },
{ {
task_type: PaperlessTaskType.ConsumeFile, task_type: PaperlessTaskType.MailFetch,
trigger_source: PaperlessTaskTriggerSource.Scheduled,
status: PaperlessTaskStatus.Pending, status: PaperlessTaskStatus.Pending,
acknowledged: false, acknowledged: false,
task_id: '1236', task_id: '1236',
input_data: { filename: 'file3.pdf' }, input_data: {},
date_created: new Date(), date_created: new Date(),
related_document_ids: [], related_document_ids: [],
}, },
{ {
task_type: PaperlessTaskType.ConsumeFile, task_type: PaperlessTaskType.LlmIndex,
trigger_source: PaperlessTaskTriggerSource.WebUI,
status: PaperlessTaskStatus.Started, status: PaperlessTaskStatus.Started,
acknowledged: false, acknowledged: false,
task_id: '1237', task_id: '1237',
input_data: { filename: 'file4.pdf' }, input_data: {},
date_created: new Date(), date_created: new Date(),
related_document_ids: [], related_document_ids: [],
}, },
{ {
task_type: PaperlessTaskType.ConsumeFile, task_type: PaperlessTaskType.EmptyTrash,
trigger_source: PaperlessTaskTriggerSource.Manual,
status: PaperlessTaskStatus.Success, status: PaperlessTaskStatus.Success,
acknowledged: false, acknowledged: false,
task_id: '1238', task_id: '1238',
input_data: { filename: 'file5.pdf' }, input_data: {},
date_created: new Date(), date_created: new Date(),
related_document_ids: [], related_document_ids: [],
}, },
@@ -117,7 +124,7 @@ describe('TasksService', () => {
tasksService.reload() tasksService.reload()
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}tasks/?task_type=consume_file&acknowledged=false` `${environment.apiBaseUrl}tasks/?acknowledged=false`
) )
req.flush(mockTasks) req.flush(mockTasks)
@@ -129,6 +136,57 @@ describe('TasksService', () => {
expect(tasksService.startedFileTasks).toHaveLength(1) 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', () => { it('supports running tasks', () => {
tasksService.run(PaperlessTaskType.SanityCheck).subscribe((res) => { tasksService.run(PaperlessTaskType.SanityCheck).subscribe((res) => {
expect(res).toEqual({ expect(res).toEqual({

View File

@@ -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() { public reload() {
if (this.loading) return if (this.loading) return
this.loading = true this.loading = true
this.http this.http
.get<PaperlessTask[]>( .get<PaperlessTask[]>(
`${this.baseUrl}${this.endpoint}/?task_type=${PaperlessTaskType.ConsumeFile}&acknowledged=false` `${this.baseUrl}${this.endpoint}/?acknowledged=false`
) )
.pipe(takeUntil(this.unsubscribeNotifer), first()) .pipe(takeUntil(this.unsubscribeNotifer), first())
.subscribe((r) => { .subscribe((r) => {

View File

@@ -119,6 +119,10 @@ table .btn-link {
font-size: 1em; font-size: 1em;
} }
.fs-7 {
font-size: 0.75rem !important;
}
.bg-body { .bg-body {
background-color: var(--bs-body-bg); background-color: var(--bs-body-bg);
} }
@@ -128,6 +132,10 @@ table .btn-link {
color: var(--pngx-primary-text-contrast); color: var(--pngx-primary-text-contrast);
} }
.bg-darker {
background-color: var(--pngx-bg-darker) !important;
}
.navbar-brand { .navbar-brand {
color: var(--pngx-primary-text-contrast) !important; color: var(--pngx-primary-text-contrast) !important;
} }