mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-04-21 23:39:28 +00:00
Compare commits
24 Commits
dev
...
feature-up
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01edf9218c | ||
|
|
7046837740 | ||
|
|
a7c3336ba9 | ||
|
|
ee62851d0f | ||
|
|
775212c86f | ||
|
|
d8d8b18830 | ||
|
|
1b984b7222 | ||
|
|
4ac47634ac | ||
|
|
e96f93b589 | ||
|
|
6c2d7adf76 | ||
|
|
d8e2ab9e71 | ||
|
|
ab76eddd85 | ||
|
|
4695df348c | ||
|
|
b46ccbe3d9 | ||
|
|
b2bc5a18ca | ||
|
|
df98f471ae | ||
|
|
e67fbc7bc4 | ||
|
|
8f8c3b072e | ||
|
|
ce1661aa57 | ||
|
|
b8cc0e32ad | ||
|
|
74b0331a74 | ||
|
|
4a837d3854 | ||
|
|
8fdd194f94 | ||
|
|
0e707391f1 |
@@ -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,
|
||||||
|
|||||||
@@ -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,19 +23,113 @@
|
|||||||
<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">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
class="btn-check"
|
||||||
|
[checked]="selectedSection === TaskSection.All"
|
||||||
|
id="section-all"
|
||||||
|
(click)="setSection(TaskSection.All)"
|
||||||
|
(keydown)="setSection(TaskSection.All)" />
|
||||||
|
<label class="btn btn-outline-primary" for="section-all">
|
||||||
|
<ng-container i18n>All</ng-container>
|
||||||
|
</label>
|
||||||
|
@for (section of sections; track section) {
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ngbDropdown>
|
||||||
|
<button class="btn btn-sm btn-outline-primary me-3" ngbDropdownToggle>{{selectedTaskTypeLabel}}</button>
|
||||||
|
<div class="dropdown-menu shadow" ngbDropdownMenu>
|
||||||
|
<button ngbDropdownItem [class.active]="selectedTaskType === null" (click)="setTaskType(null)" i18n>All types</button>
|
||||||
|
@for (option of taskTypeOptions; track option.value) {
|
||||||
|
<button ngbDropdownItem [class.active]="selectedTaskType === option.value" [disabled]="isTaskTypeOptionDisabled(option.value)" (click)="setTaskType(option.value)">{{option.label}}</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div ngbDropdown>
|
||||||
|
<button class="btn btn-sm btn-outline-primary me-3" ngbDropdownToggle>{{selectedTriggerSourceLabel}}</button>
|
||||||
|
<div class="dropdown-menu shadow" ngbDropdownMenu>
|
||||||
|
<button ngbDropdownItem [class.active]="selectedTriggerSource === null" (click)="setTriggerSource(null)" i18n>All sources</button>
|
||||||
|
@for (option of triggerSourceOptions; track option.value) {
|
||||||
|
<button ngbDropdownItem [class.active]="selectedTriggerSource === option.value" [disabled]="isTriggerSourceOptionDisabled(option.value)" (click)="setTriggerSource(option.value)">{{option.label}}</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-inline d-flex align-items-center flex-grow-1 task-search">
|
||||||
|
<div class="input-group input-group-sm flex-fill w-auto flex-nowrap">
|
||||||
|
<span class="input-group-text text-muted" i18n>Filter by</span>
|
||||||
|
<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>
|
||||||
|
@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>
|
||||||
|
|
||||||
|
@if (isFiltered) {
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ng-template let-tasks="tasks" let-section="section" #tasksTemplate>
|
||||||
|
<div class="section-header d-flex align-items-center justify-content-between mb-2">
|
||||||
|
<div>
|
||||||
|
<h5 class="mb-0">{{ sectionLabel(section) }}</h5>
|
||||||
|
<div class="small text-muted">
|
||||||
|
<ng-container i18n>{tasks.length, plural, =1 {1 task} other {{{tasks.length}} tasks}}</ng-container>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card border table-responsive mb-3">
|
||||||
|
<table class="table table-striped align-middle shadow-sm mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">
|
<th scope="col">
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input type="checkbox" class="form-check-input" id="all-tasks" [disabled]="currentTasks.length === 0" [(ngModel)]="togggleAll" (click)="toggleAll($event); $event.stopPropagation();">
|
<input
|
||||||
<label class="form-check-label" for="all-tasks"></label>
|
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>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" i18n>Name</th>
|
<th scope="col" i18n>Name</th>
|
||||||
<th scope="col" class="d-none d-lg-table-cell" i18n>Created</th>
|
<th scope="col" class="d-none d-lg-table-cell" i18n>Created</th>
|
||||||
@if (activeTab !== 'started' && activeTab !== 'queued') {
|
@if (sectionShowsResults(section)) {
|
||||||
<th scope="col" class="d-none d-lg-table-cell" i18n>Results</th>
|
<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" class="d-table-cell d-lg-none" i18n>Info</th>
|
||||||
@@ -68,32 +137,53 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (task of tasks | slice: (page-1) * pageSize : page * pageSize; track task.id) {
|
@for (task of tasks; track task.id) {
|
||||||
<tr (click)="toggleSelected(task, $event); $event.stopPropagation();">
|
<tr (click)="toggleSelected(task); $event.stopPropagation();" (keydown)="toggleSelected(task); $event.stopPropagation();">
|
||||||
<td>
|
<td>
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input type="checkbox" class="form-check-input" id="task{{task.id}}" [checked]="selectedTasks.has(task.id)" (click)="toggleSelected(task, $event); $event.stopPropagation();">
|
<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>
|
<label class="form-check-label" for="task{{task.id}}"></label>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="overflow-auto name-col">{{ task.input_data?.filename }}</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">•</span>
|
||||||
|
}
|
||||||
|
<span>{{ task.trigger_source_display }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td class="d-none d-lg-table-cell">{{ task.date_created | customDate:'short' }}</td>
|
<td class="d-none d-lg-table-cell">{{ task.date_created | customDate:'short' }}</td>
|
||||||
@if (activeTab !== 'started' && activeTab !== 'queued') {
|
@if (sectionShowsResults(section)) {
|
||||||
<td class="d-none d-lg-table-cell">
|
<td class="d-none d-lg-table-cell">
|
||||||
@if (task.result_message?.length > 50) {
|
@if (taskHasLongResultMessage(task)) {
|
||||||
<div class="result" (click)="expandTask(task); $event.stopPropagation();"
|
<div class="result" (click)="expandTask(task); $event.stopPropagation();"
|
||||||
[ngbPopover]="resultPopover" popoverClass="shadow small mobile" triggers="mouseenter:mouseleave" container="body">
|
[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 }}…</span>
|
<span class="small d-none d-md-inline-block font-monospace text-muted">{{ taskResultPreview(task) }}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (task.result_message?.length <= 50) {
|
@if (taskHasResultMessage(task) && !taskHasLongResultMessage(task)) {
|
||||||
<span class="small d-none d-md-inline-block font-monospace text-muted">{{ task.result_message }}</span>
|
<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>
|
<ng-template #resultPopover>
|
||||||
<pre class="small mb-0">{{ task.result_message | slice:0:300 }}@if (task.result_message.length > 300) {
|
<pre class="small mb-0">{{ taskResultPopoverMessage(task) }}@if (taskResultMessageOverflowsPopover(task)) {
|
||||||
…
|
…
|
||||||
}</pre>
|
}</pre>
|
||||||
@if (task.result_message?.length > 300) {
|
@if (taskResultMessageOverflowsPopover(task)) {
|
||||||
<br/><em>(<ng-container i18n>click for full output</ng-container>)</em>
|
<br/><em>(<ng-container i18n>click for full output</ng-container>)</em>
|
||||||
}
|
}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
@@ -120,61 +210,63 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="p-0" [class.border-0]="expandedTask !== task.id" colspan="5">
|
<td class="p-0" [class.border-0]="expandedTask !== task.id" [attr.colspan]="sectionShowsResults(section) ? 5 : 4">
|
||||||
<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 #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> ({{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>
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
error_message:
|
||||||
'test.pd: Not consuming test.pdf: It is a duplicate of test (#100)',
|
'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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 {
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
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 })
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user