mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-04-21 23:39:28 +00:00
Compare commits
24 Commits
chore/inde
...
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',
|
||||
content: $localize`File Tasks shows you documents that have been consumed, are waiting to be, or may have failed during the process.`,
|
||||
content: $localize`Tasks helps you track background work, what needs attention, and what recently completed.`,
|
||||
route: '/tasks',
|
||||
backdropConfig: {
|
||||
offset: 0,
|
||||
|
||||
@@ -1,41 +1,16 @@
|
||||
<pngx-page-header
|
||||
title="File Tasks"
|
||||
title="Tasks"
|
||||
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
|
||||
>
|
||||
<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">
|
||||
<i-bs name="x" class="me-1"></i-bs><ng-container i18n>Clear selection</ng-container>
|
||||
</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}}
|
||||
</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">
|
||||
<input class="form-check-input" type="checkbox" role="switch" [(ngModel)]="autoRefreshEnabled">
|
||||
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
|
||||
@@ -48,133 +23,250 @@
|
||||
<div class="visually-hidden" i18n>Loading...</div>
|
||||
}
|
||||
|
||||
<ng-template let-tasks="tasks" #tasksTemplate>
|
||||
<table class="table table-striped align-middle border shadow-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">
|
||||
<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();">
|
||||
<label class="form-check-label" for="all-tasks"></label>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" i18n>Name</th>
|
||||
<th scope="col" class="d-none d-lg-table-cell" i18n>Created</th>
|
||||
@if (activeTab !== 'started' && activeTab !== 'queued') {
|
||||
<th scope="col" class="d-none d-lg-table-cell" i18n>Results</th>
|
||||
<div class="task-controls mb-3 btn-toolbar">
|
||||
<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>
|
||||
}
|
||||
<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 | slice: (page-1) * pageSize : page * pageSize; track task.id) {
|
||||
<tr (click)="toggleSelected(task, $event); $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); $event.stopPropagation();">
|
||||
<label class="form-check-label" for="task{{task.id}}"></label>
|
||||
</div>
|
||||
</td>
|
||||
<td class="overflow-auto name-col">{{ task.input_data?.filename }}</td>
|
||||
<td class="d-none d-lg-table-cell">{{ task.date_created | customDate:'short' }}</td>
|
||||
@if (activeTab !== 'started' && activeTab !== 'queued') {
|
||||
<td class="d-none d-lg-table-cell">
|
||||
@if (task.result_message?.length > 50) {
|
||||
<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">{{ task.result_message | slice:0:50 }}…</span>
|
||||
</div>
|
||||
}
|
||||
@if (task.result_message?.length <= 50) {
|
||||
<span class="small d-none d-md-inline-block font-monospace text-muted">{{ task.result_message }}</span>
|
||||
}
|
||||
<ng-template #resultPopover>
|
||||
<pre class="small mb-0">{{ task.result_message | slice:0:300 }}@if (task.result_message.length > 300) {
|
||||
…
|
||||
}</pre>
|
||||
@if (task.result_message?.length > 300) {
|
||||
<br/><em>(<ng-container i18n>click for full output</ng-container>)</em>
|
||||
}
|
||||
</ng-template>
|
||||
</td>
|
||||
</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>
|
||||
}
|
||||
<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>
|
||||
</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>
|
||||
<tr>
|
||||
<td class="p-0" [class.border-0]="expandedTask !== task.id" colspan="5">
|
||||
<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>
|
||||
<th scope="col">
|
||||
<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">•</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)) {
|
||||
…
|
||||
}</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>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</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>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTab" class="nav-tabs" (hidden)="duringTabChange()" (navChange)="beforeTabChange()">
|
||||
<li ngbNavItem="failed">
|
||||
<a ngbNavLink i18n>Failed@if (tasksService.failedFileTasks.length > 0) {
|
||||
<span class="badge bg-danger ms-2">{{tasksService.failedFileTasks.length}}</span>
|
||||
}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:currentTasks}"></ng-container>
|
||||
</ng-template>
|
||||
</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>
|
||||
@if (visibleSections.length > 0) {
|
||||
@for (section of visibleSections; track section) {
|
||||
<div class="mb-4">
|
||||
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks: tasksForSection(section), section: section}"></ng-container>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="alert alert-secondary fst-italic" i18n>No tasks match the current filters.</div>
|
||||
}
|
||||
|
||||
@@ -37,3 +37,7 @@ pre {
|
||||
.z-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 { Router } from '@angular/router'
|
||||
import { RouterTestingModule } from '@angular/router/testing'
|
||||
import {
|
||||
NgbModal,
|
||||
NgbModalRef,
|
||||
NgbModule,
|
||||
NgbNavItem,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgbModal, NgbModalRef, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { throwError } from 'rxjs'
|
||||
import { routes } from 'src/app/app-routing.module'
|
||||
@@ -33,7 +28,7 @@ import { ToastService } from 'src/app/services/toast.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { TasksComponent, TaskTab } from './tasks.component'
|
||||
import { TasksComponent, TaskSection } from './tasks.component'
|
||||
|
||||
const tasks: PaperlessTask[] = [
|
||||
{
|
||||
@@ -48,8 +43,10 @@ const tasks: PaperlessTask[] = [
|
||||
trigger_source_display: 'Folder Consume',
|
||||
status: PaperlessTaskStatus.Failure,
|
||||
status_display: 'Failure',
|
||||
result_message:
|
||||
'test.pd: Not consuming test.pdf: It is a duplicate of test (#100)',
|
||||
result_data: {
|
||||
error_message:
|
||||
'test.pd: Not consuming test.pdf: It is a duplicate of test (#100)',
|
||||
},
|
||||
acknowledged: false,
|
||||
related_document_ids: [],
|
||||
},
|
||||
@@ -65,8 +62,7 @@ const tasks: PaperlessTask[] = [
|
||||
trigger_source_display: 'Folder Consume',
|
||||
status: PaperlessTaskStatus.Failure,
|
||||
status_display: 'Failure',
|
||||
result_message:
|
||||
'191092.pd: Not consuming 191092.pdf: It is a duplicate of 191092 (#311)',
|
||||
result_data: { duplicate_of: 311 },
|
||||
acknowledged: false,
|
||||
related_document_ids: [],
|
||||
},
|
||||
@@ -82,7 +78,7 @@ const tasks: PaperlessTask[] = [
|
||||
trigger_source_display: 'Folder Consume',
|
||||
status: PaperlessTaskStatus.Pending,
|
||||
status_display: 'Pending',
|
||||
result_message: null,
|
||||
result_data: null,
|
||||
acknowledged: false,
|
||||
related_document_ids: [],
|
||||
},
|
||||
@@ -98,7 +94,7 @@ const tasks: PaperlessTask[] = [
|
||||
trigger_source_display: 'Email Consume',
|
||||
status: PaperlessTaskStatus.Success,
|
||||
status_display: 'Success',
|
||||
result_message: 'Success. New document id 422 created',
|
||||
result_data: { document_id: 422, duplicate_of: 99 },
|
||||
acknowledged: false,
|
||||
related_document_ids: [422],
|
||||
},
|
||||
@@ -114,7 +110,7 @@ const tasks: PaperlessTask[] = [
|
||||
trigger_source_display: 'Folder Consume',
|
||||
status: PaperlessTaskStatus.Success,
|
||||
status_display: 'Success',
|
||||
result_message: 'Success. New document id 421 created',
|
||||
result_data: { document_id: 421 },
|
||||
acknowledged: false,
|
||||
related_document_ids: [421],
|
||||
},
|
||||
@@ -130,7 +126,23 @@ const tasks: PaperlessTask[] = [
|
||||
trigger_source_display: 'Email Consume',
|
||||
status: PaperlessTaskStatus.Started,
|
||||
status_display: 'Started',
|
||||
result_message: null,
|
||||
result_data: null,
|
||||
acknowledged: false,
|
||||
related_document_ids: [],
|
||||
},
|
||||
{
|
||||
id: 461,
|
||||
task_id: 'bb79efb3-1e78-4f31-b4be-0966620b0ce1',
|
||||
input_data: { dry_run: false, scope: 'global' },
|
||||
date_created: new Date('2023-06-07T03:54:35.694916Z'),
|
||||
date_done: null,
|
||||
task_type: PaperlessTaskType.SanityCheck,
|
||||
task_type_display: 'Sanity Check',
|
||||
trigger_source: PaperlessTaskTriggerSource.System,
|
||||
trigger_source_display: 'System',
|
||||
status: PaperlessTaskStatus.Started,
|
||||
status_display: 'Started',
|
||||
result_data: { issues_found: 0 },
|
||||
acknowledged: false,
|
||||
related_document_ids: [],
|
||||
},
|
||||
@@ -185,59 +197,142 @@ describe('TasksComponent', () => {
|
||||
jest.useFakeTimers()
|
||||
fixture.detectChanges()
|
||||
httpTestingController
|
||||
.expectOne(
|
||||
`${environment.apiBaseUrl}tasks/?task_type=consume_file&acknowledged=false`
|
||||
)
|
||||
.expectOne(`${environment.apiBaseUrl}tasks/?acknowledged=false`)
|
||||
.flush(tasks)
|
||||
})
|
||||
|
||||
it('should display file tasks in 4 tabs by status', () => {
|
||||
const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavItem))
|
||||
it('should display task sections with counts', () => {
|
||||
expect(component.selectedSection).toBe(TaskSection.All)
|
||||
expect(component.selectedTaskType).toBeNull()
|
||||
expect(component.selectedTriggerSource).toBeNull()
|
||||
|
||||
let currentTasksLength = tasks.filter(
|
||||
(t) => t.status === PaperlessTaskStatus.Failure
|
||||
).length
|
||||
component.activeTab = TaskTab.Failed
|
||||
fixture.detectChanges()
|
||||
expect(tabButtons[0].nativeElement.textContent).toEqual(
|
||||
`Failed${currentTasksLength}`
|
||||
)
|
||||
expect(
|
||||
fixture.debugElement.queryAll(By.css('table input[type="checkbox"]'))
|
||||
).toHaveLength(currentTasksLength + 1)
|
||||
|
||||
currentTasksLength = tasks.filter(
|
||||
(t) => t.status === PaperlessTaskStatus.Success
|
||||
).length
|
||||
component.activeTab = TaskTab.Completed
|
||||
fixture.detectChanges()
|
||||
expect(tabButtons[1].nativeElement.textContent).toEqual(
|
||||
`Complete${currentTasksLength}`
|
||||
)
|
||||
const viewScope = fixture.debugElement.query(By.css('.task-view-scope'))
|
||||
const text = viewScope.nativeElement.textContent
|
||||
|
||||
currentTasksLength = tasks.filter(
|
||||
(t) => t.status === PaperlessTaskStatus.Started
|
||||
).length
|
||||
component.activeTab = TaskTab.Started
|
||||
fixture.detectChanges()
|
||||
expect(tabButtons[2].nativeElement.textContent).toEqual(
|
||||
`Started${currentTasksLength}`
|
||||
)
|
||||
expect(text).toContain('All')
|
||||
expect(text).toContain('Needs attention')
|
||||
expect(text).toContain('2')
|
||||
expect(text).toContain('In progress')
|
||||
expect(text).toContain('3')
|
||||
expect(text).toContain('Recently completed')
|
||||
})
|
||||
|
||||
currentTasksLength = tasks.filter(
|
||||
(t) => t.status === PaperlessTaskStatus.Pending
|
||||
).length
|
||||
component.activeTab = TaskTab.Queued
|
||||
it('should filter visible sections by selected status', () => {
|
||||
component.setSection(TaskSection.InProgress)
|
||||
fixture.detectChanges()
|
||||
expect(tabButtons[3].nativeElement.textContent).toEqual(
|
||||
`Queued${currentTasksLength}`
|
||||
|
||||
expect(component.visibleSections).toEqual([TaskSection.InProgress])
|
||||
expect(fixture.nativeElement.textContent).toContain('In progress')
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Recent completed')
|
||||
})
|
||||
|
||||
it('should filter tasks by task type', () => {
|
||||
component.setSection(TaskSection.InProgress)
|
||||
component.setTaskType(PaperlessTaskType.SanityCheck)
|
||||
|
||||
expect(component.tasksForSection(TaskSection.InProgress)).toHaveLength(1)
|
||||
expect(component.tasksForSection(TaskSection.InProgress)[0].task_type).toBe(
|
||||
PaperlessTaskType.SanityCheck
|
||||
)
|
||||
})
|
||||
|
||||
it('should to go page 1 between tab switch', () => {
|
||||
component.page = 10
|
||||
component.duringTabChange()
|
||||
expect(component.page).toEqual(1)
|
||||
it('should filter tasks by trigger source', () => {
|
||||
component.setSection(TaskSection.InProgress)
|
||||
component.setTriggerSource(PaperlessTaskTriggerSource.EmailConsume)
|
||||
|
||||
expect(component.tasksForSection(TaskSection.InProgress)).toHaveLength(1)
|
||||
expect(
|
||||
component.tasksForSection(TaskSection.InProgress)[0].trigger_source
|
||||
).toBe(PaperlessTaskTriggerSource.EmailConsume)
|
||||
})
|
||||
|
||||
it('should reset all active filters together', () => {
|
||||
component.setSection(TaskSection.InProgress)
|
||||
component.setTaskType(PaperlessTaskType.SanityCheck)
|
||||
component.setTriggerSource(PaperlessTaskTriggerSource.System)
|
||||
component.filterText = 'system'
|
||||
jest.advanceTimersByTime(150)
|
||||
|
||||
expect(component.isFiltered).toBe(true)
|
||||
|
||||
component.resetFilters()
|
||||
|
||||
expect(component.selectedSection).toBe(TaskSection.InProgress)
|
||||
expect(component.selectedTaskType).toBeNull()
|
||||
expect(component.selectedTriggerSource).toBeNull()
|
||||
expect(component.filterText).toBe('')
|
||||
expect(component.isFiltered).toBe(false)
|
||||
})
|
||||
|
||||
it('should keep header controls focused on actions and auto refresh', () => {
|
||||
fixture.detectChanges()
|
||||
|
||||
const header = fixture.debugElement.query(By.css('pngx-page-header'))
|
||||
const headerText = header.nativeElement.textContent
|
||||
|
||||
expect(headerText).toContain('Dismiss visible')
|
||||
expect(headerText).toContain('Auto refresh')
|
||||
expect(headerText).not.toContain('All types')
|
||||
expect(headerText).not.toContain('All sources')
|
||||
expect(headerText).not.toContain('Reset filters')
|
||||
})
|
||||
|
||||
it('should render the view scope row above the filter bar', () => {
|
||||
fixture.detectChanges()
|
||||
|
||||
const controls = fixture.debugElement.query(By.css('.task-controls'))
|
||||
const viewScope = controls.query(By.css('.task-view-scope'))
|
||||
const search = controls.query(By.css('.task-search'))
|
||||
|
||||
expect(viewScope).not.toBeNull()
|
||||
expect(search).not.toBeNull()
|
||||
expect(
|
||||
viewScope.nativeElement.compareDocumentPosition(search.nativeElement) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should expose stable task type options and disable empty ones', () => {
|
||||
expect(component.taskTypeOptions.map((option) => option.value)).toContain(
|
||||
PaperlessTaskType.TrainClassifier
|
||||
)
|
||||
expect(
|
||||
component.isTaskTypeOptionDisabled(PaperlessTaskType.TrainClassifier)
|
||||
).toBe(true)
|
||||
expect(
|
||||
component.isTaskTypeOptionDisabled(PaperlessTaskType.ConsumeFile)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('should fall back to the raw selected task type label when no option matches', () => {
|
||||
component.selectedTaskType = 'unknown_task_type' as PaperlessTaskType
|
||||
|
||||
expect(component.selectedTaskTypeLabel).toBe('unknown_task_type')
|
||||
})
|
||||
|
||||
it('should expose stable trigger source options and disable empty ones', () => {
|
||||
expect(
|
||||
component.triggerSourceOptions.map((option) => option.value)
|
||||
).toContain(PaperlessTaskTriggerSource.ApiUpload)
|
||||
expect(
|
||||
component.isTriggerSourceOptionDisabled(
|
||||
PaperlessTaskTriggerSource.ApiUpload
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
component.isTriggerSourceOptionDisabled(
|
||||
PaperlessTaskTriggerSource.EmailConsume
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('should fall back to the raw selected trigger source label when no option matches', () => {
|
||||
component.selectedTriggerSource =
|
||||
'unknown_trigger_source' as PaperlessTaskTriggerSource
|
||||
|
||||
expect(component.selectedTriggerSourceLabel).toBe('unknown_trigger_source')
|
||||
})
|
||||
|
||||
it('should support expanding / collapsing one task at a time', () => {
|
||||
@@ -249,6 +344,31 @@ describe('TasksComponent', () => {
|
||||
expect(component.expandedTask).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should show structured task details when expanded', () => {
|
||||
component.setSection(TaskSection.InProgress)
|
||||
component.expandTask(tasks[6])
|
||||
fixture.detectChanges()
|
||||
|
||||
const detailText = fixture.nativeElement.textContent
|
||||
|
||||
expect(detailText).toContain('Input data')
|
||||
expect(detailText).toContain('Result data')
|
||||
expect(detailText).toContain('"scope": "global"')
|
||||
expect(detailText).toContain('"issues_found": 0')
|
||||
})
|
||||
|
||||
it('should show duplicate warnings and duplicate details when present', () => {
|
||||
component.setSection(TaskSection.Completed)
|
||||
component.expandTask(tasks[3])
|
||||
fixture.detectChanges()
|
||||
|
||||
const content = fixture.nativeElement.textContent
|
||||
|
||||
expect(content).toContain('Duplicate of document #99')
|
||||
expect(content).toContain('Duplicate')
|
||||
expect(content).toContain('Open')
|
||||
})
|
||||
|
||||
it('should support dismiss single task', () => {
|
||||
const dismissSpy = jest.spyOn(tasksService, 'dismissTasks')
|
||||
component.dismissTask(tasks[0])
|
||||
@@ -259,7 +379,7 @@ describe('TasksComponent', () => {
|
||||
component.toggleSelected(tasks[0])
|
||||
component.toggleSelected(tasks[1])
|
||||
component.toggleSelected(tasks[3])
|
||||
component.toggleSelected(tasks[3]) // uncheck, for coverage
|
||||
component.toggleSelected(tasks[3])
|
||||
const selected = new Set([tasks[0].id, tasks[1].id])
|
||||
expect(component.selectedTasks).toEqual(selected)
|
||||
let modal: NgbModalRef
|
||||
@@ -308,31 +428,50 @@ describe('TasksComponent', () => {
|
||||
expect(component.selectedTasks.size).toBe(0)
|
||||
})
|
||||
|
||||
it('should support dismiss all tasks', () => {
|
||||
it('should support dismiss visible tasks', () => {
|
||||
component.setSection(TaskSection.NeedsAttention)
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||
const dismissSpy = jest.spyOn(tasksService, 'dismissTasks')
|
||||
component.dismissTasks()
|
||||
expect(modal).not.toBeUndefined()
|
||||
modal.componentInstance.confirmClicked.emit()
|
||||
expect(dismissSpy).toHaveBeenCalledWith(new Set(tasks.map((t) => t.id)))
|
||||
expect(dismissSpy).toHaveBeenCalledWith(new Set([467, 466]))
|
||||
})
|
||||
|
||||
it('should support toggle all tasks', () => {
|
||||
it('should dismiss the currently visible scoped and filtered tasks', () => {
|
||||
component.setSection(TaskSection.InProgress)
|
||||
component.setTaskType(PaperlessTaskType.SanityCheck)
|
||||
component.setTriggerSource(PaperlessTaskTriggerSource.System)
|
||||
|
||||
const dismissSpy = jest.spyOn(tasksService, 'dismissTasks')
|
||||
|
||||
component.dismissTasks()
|
||||
|
||||
expect(dismissSpy).toHaveBeenCalledWith(new Set([461]))
|
||||
})
|
||||
|
||||
it('should support toggling a full section', () => {
|
||||
component.setSection(TaskSection.NeedsAttention)
|
||||
fixture.detectChanges()
|
||||
|
||||
const toggleCheck = fixture.debugElement.query(
|
||||
By.css('table input[type=checkbox]')
|
||||
)
|
||||
toggleCheck.nativeElement.dispatchEvent(new MouseEvent('click'))
|
||||
fixture.detectChanges()
|
||||
expect(component.selectedTasks).toEqual(
|
||||
new Set(
|
||||
tasks
|
||||
.filter((t) => t.status === PaperlessTaskStatus.Failure)
|
||||
.map((t) => t.id)
|
||||
)
|
||||
By.css('#all-tasks-needs_attention')
|
||||
)
|
||||
expect(toggleCheck).not.toBeNull()
|
||||
toggleCheck.nativeElement.dispatchEvent(new MouseEvent('click'))
|
||||
fixture.detectChanges()
|
||||
expect(component.selectedTasks).toEqual(new Set([467, 466]))
|
||||
})
|
||||
|
||||
it('should remove a full section from selection when toggled off', () => {
|
||||
component.setSection(TaskSection.NeedsAttention)
|
||||
component.selectedTasks = new Set([467, 466])
|
||||
|
||||
component.toggleSection(TaskSection.NeedsAttention, {
|
||||
target: { checked: false },
|
||||
} as PointerEvent)
|
||||
|
||||
expect(component.selectedTasks).toEqual(new Set())
|
||||
})
|
||||
|
||||
@@ -355,57 +494,127 @@ describe('TasksComponent', () => {
|
||||
})
|
||||
|
||||
it('should filter tasks by file name', () => {
|
||||
fixture.detectChanges()
|
||||
const input = fixture.debugElement.query(
|
||||
By.css('pngx-page-header input[type=text]')
|
||||
By.css('.task-search input[type=text]')
|
||||
)
|
||||
expect(input).not.toBeNull()
|
||||
input.nativeElement.value = '191092'
|
||||
input.nativeElement.dispatchEvent(new Event('input'))
|
||||
jest.advanceTimersByTime(150) // debounce time
|
||||
jest.advanceTimersByTime(150)
|
||||
fixture.detectChanges()
|
||||
expect(component.filterText).toEqual('191092')
|
||||
expect(
|
||||
fixture.debugElement.queryAll(By.css('table tbody tr')).length
|
||||
).toEqual(2) // 1 task x 2 lines
|
||||
expect(component.tasksForSection(TaskSection.NeedsAttention)).toHaveLength(
|
||||
1
|
||||
)
|
||||
})
|
||||
|
||||
it('should match task type and source in name filtering', () => {
|
||||
component.setSection(TaskSection.InProgress)
|
||||
component.filterText = 'system'
|
||||
jest.advanceTimersByTime(150)
|
||||
|
||||
expect(component.tasksForSection(TaskSection.InProgress)).toHaveLength(1)
|
||||
expect(component.tasksForSection(TaskSection.InProgress)[0].task_type).toBe(
|
||||
PaperlessTaskType.SanityCheck
|
||||
)
|
||||
})
|
||||
|
||||
it('should fall back to task type when filename is unavailable', () => {
|
||||
component.setSection(TaskSection.InProgress)
|
||||
fixture.detectChanges()
|
||||
|
||||
const nameColumn = fixture.debugElement.queryAll(
|
||||
By.css('tbody td.name-col')
|
||||
)
|
||||
const sanityTaskRow = nameColumn.find((cell) =>
|
||||
cell.nativeElement.textContent.includes('Sanity Check')
|
||||
)
|
||||
|
||||
expect(sanityTaskRow.nativeElement.textContent).toContain('Sanity Check')
|
||||
expect(sanityTaskRow.nativeElement.textContent).toContain('System')
|
||||
})
|
||||
|
||||
it('should filter tasks by result', () => {
|
||||
component.activeTab = TaskTab.Failed
|
||||
fixture.detectChanges()
|
||||
component.setSection(TaskSection.NeedsAttention)
|
||||
component.filterTargetID = 1
|
||||
fixture.detectChanges()
|
||||
const input = fixture.debugElement.query(
|
||||
By.css('pngx-page-header input[type=text]')
|
||||
By.css('.task-search input[type=text]')
|
||||
)
|
||||
expect(input).not.toBeNull()
|
||||
input.nativeElement.value = 'duplicate'
|
||||
input.nativeElement.dispatchEvent(new Event('input'))
|
||||
jest.advanceTimersByTime(150) // debounce time
|
||||
jest.advanceTimersByTime(150)
|
||||
fixture.detectChanges()
|
||||
expect(component.filterText).toEqual('duplicate')
|
||||
expect(component.tasksForSection(TaskSection.NeedsAttention)).toHaveLength(
|
||||
2
|
||||
)
|
||||
})
|
||||
|
||||
it('should prefer explicit reason in the result message', () => {
|
||||
expect(
|
||||
fixture.debugElement.queryAll(By.css('table tbody tr')).length
|
||||
).toEqual(4) // 2 tasks x 2 lines
|
||||
component.taskResultMessage({
|
||||
...tasks[0],
|
||||
result_data: { reason: 'Manual review required', duplicate_of: 311 },
|
||||
})
|
||||
).toBe('Manual review required')
|
||||
})
|
||||
|
||||
it('should return null preview and popover text when there is no result message', () => {
|
||||
expect(component.taskResultPreview(tasks[2])).toBeNull()
|
||||
expect(component.taskResultPopoverMessage(tasks[2])).toBe('')
|
||||
expect(component.taskResultMessageOverflowsPopover(tasks[2])).toBe(false)
|
||||
})
|
||||
|
||||
it('should navigate to a duplicate document details page', () => {
|
||||
const routerSpy = jest.spyOn(router, 'navigate')
|
||||
|
||||
component.openDuplicateDocument(99)
|
||||
|
||||
expect(routerSpy).toHaveBeenCalledWith(['documents', 99, 'details'])
|
||||
})
|
||||
|
||||
it('should report when a result message overflows the popover limit', () => {
|
||||
const longMessage = 'x'.repeat(350)
|
||||
const task = {
|
||||
...tasks[0],
|
||||
result_data: { error_message: longMessage },
|
||||
}
|
||||
|
||||
expect(component.taskResultPopoverMessage(task)).toBe(
|
||||
longMessage.slice(0, 300)
|
||||
)
|
||||
expect(component.taskResultMessageOverflowsPopover(task)).toBe(true)
|
||||
})
|
||||
|
||||
it('should support keyboard events for filtering', () => {
|
||||
fixture.detectChanges()
|
||||
const input = fixture.debugElement.query(
|
||||
By.css('pngx-page-header input[type=text]')
|
||||
By.css('.task-search input[type=text]')
|
||||
)
|
||||
expect(input).not.toBeNull()
|
||||
input.nativeElement.value = '191092'
|
||||
input.nativeElement.dispatchEvent(
|
||||
new KeyboardEvent('keyup', { key: 'Enter' })
|
||||
)
|
||||
expect(component.filterText).toEqual('191092') // no debounce needed
|
||||
expect(component.filterText).toEqual('191092')
|
||||
input.nativeElement.dispatchEvent(
|
||||
new KeyboardEvent('keyup', { key: 'Escape' })
|
||||
)
|
||||
expect(component.filterText).toEqual('')
|
||||
})
|
||||
|
||||
it('should reset filter and target on tab switch', () => {
|
||||
component.filterText = '191092'
|
||||
component.filterTargetID = 1
|
||||
component.activeTab = TaskTab.Completed
|
||||
component.beforeTabChange()
|
||||
expect(component.filterText).toEqual('')
|
||||
expect(component.filterTargetID).toEqual(0)
|
||||
it('should keep clearing selection independent from resetting filters', () => {
|
||||
component.setTaskType(PaperlessTaskType.ConsumeFile)
|
||||
component.toggleSelected(tasks[0])
|
||||
expect(component.selectedTasks.size).toBe(1)
|
||||
|
||||
component.clearSelection()
|
||||
|
||||
expect(component.selectedTasks.size).toBe(0)
|
||||
expect(component.selectedTaskType).toBe(PaperlessTaskType.ConsumeFile)
|
||||
expect(component.isFiltered).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NgTemplateOutlet, SlicePipe } from '@angular/common'
|
||||
import { JsonPipe, NgTemplateOutlet } from '@angular/common'
|
||||
import { Component, inject, OnDestroy, OnInit } from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { Router } from '@angular/router'
|
||||
@@ -6,8 +6,6 @@ import {
|
||||
NgbCollapseModule,
|
||||
NgbDropdownModule,
|
||||
NgbModal,
|
||||
NgbNavModule,
|
||||
NgbPaginationModule,
|
||||
NgbPopoverModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
@@ -20,7 +18,12 @@ import {
|
||||
takeUntil,
|
||||
timer,
|
||||
} from 'rxjs'
|
||||
import { PaperlessTask } from 'src/app/data/paperless-task'
|
||||
import {
|
||||
PaperlessTask,
|
||||
PaperlessTaskStatus,
|
||||
PaperlessTaskTriggerSource,
|
||||
PaperlessTaskType,
|
||||
} from 'src/app/data/paperless-task'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
import { TasksService } from 'src/app/services/tasks.service'
|
||||
@@ -29,11 +32,11 @@ import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dial
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
||||
|
||||
export enum TaskTab {
|
||||
Queued = 'queued',
|
||||
Started = 'started',
|
||||
export enum TaskSection {
|
||||
All = 'all',
|
||||
NeedsAttention = 'needs_attention',
|
||||
InProgress = 'in_progress',
|
||||
Completed = 'completed',
|
||||
Failed = 'failed',
|
||||
}
|
||||
|
||||
enum TaskFilterTargetID {
|
||||
@@ -46,6 +49,82 @@ const FILTER_TARGETS = [
|
||||
{ id: TaskFilterTargetID.Result, name: $localize`Result` },
|
||||
]
|
||||
|
||||
const SECTION_LABELS = {
|
||||
[TaskSection.All]: $localize`All`,
|
||||
[TaskSection.NeedsAttention]: $localize`Needs attention`,
|
||||
[TaskSection.InProgress]: $localize`In progress`,
|
||||
[TaskSection.Completed]: $localize`Recently completed`,
|
||||
}
|
||||
|
||||
const TASK_TYPE_OPTIONS: Array<{
|
||||
value: PaperlessTaskType
|
||||
label: string
|
||||
}> = [
|
||||
{
|
||||
value: PaperlessTaskType.ConsumeFile,
|
||||
label: $localize`Consume File`,
|
||||
},
|
||||
{
|
||||
value: PaperlessTaskType.TrainClassifier,
|
||||
label: $localize`Train Classifier`,
|
||||
},
|
||||
{
|
||||
value: PaperlessTaskType.SanityCheck,
|
||||
label: $localize`Sanity Check`,
|
||||
},
|
||||
{ value: PaperlessTaskType.MailFetch, label: $localize`Mail Fetch` },
|
||||
{ value: PaperlessTaskType.LlmIndex, label: $localize`LLM Index` },
|
||||
{
|
||||
value: PaperlessTaskType.EmptyTrash,
|
||||
label: $localize`Empty Trash`,
|
||||
},
|
||||
{
|
||||
value: PaperlessTaskType.CheckWorkflows,
|
||||
label: $localize`Check Workflows`,
|
||||
},
|
||||
{
|
||||
value: PaperlessTaskType.BulkUpdate,
|
||||
label: $localize`Bulk Update`,
|
||||
},
|
||||
{
|
||||
value: PaperlessTaskType.ReprocessDocument,
|
||||
label: $localize`Reprocess Document`,
|
||||
},
|
||||
{
|
||||
value: PaperlessTaskType.BuildShareLink,
|
||||
label: $localize`Build Share Link`,
|
||||
},
|
||||
{
|
||||
value: PaperlessTaskType.BulkDelete,
|
||||
label: $localize`Bulk Delete`,
|
||||
},
|
||||
]
|
||||
|
||||
const TRIGGER_SOURCE_OPTIONS: Array<{
|
||||
value: PaperlessTaskTriggerSource
|
||||
label: string
|
||||
}> = [
|
||||
{
|
||||
value: PaperlessTaskTriggerSource.Scheduled,
|
||||
label: $localize`Scheduled`,
|
||||
},
|
||||
{ value: PaperlessTaskTriggerSource.WebUI, label: $localize`Web UI` },
|
||||
{
|
||||
value: PaperlessTaskTriggerSource.ApiUpload,
|
||||
label: $localize`API Upload`,
|
||||
},
|
||||
{
|
||||
value: PaperlessTaskTriggerSource.FolderConsume,
|
||||
label: $localize`Folder Consume`,
|
||||
},
|
||||
{
|
||||
value: PaperlessTaskTriggerSource.EmailConsume,
|
||||
label: $localize`Email Consume`,
|
||||
},
|
||||
{ value: PaperlessTaskTriggerSource.System, label: $localize`System` },
|
||||
{ value: PaperlessTaskTriggerSource.Manual, label: $localize`Manual` },
|
||||
]
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-tasks',
|
||||
templateUrl: './tasks.component.html',
|
||||
@@ -54,14 +133,12 @@ const FILTER_TARGETS = [
|
||||
PageHeaderComponent,
|
||||
IfPermissionsDirective,
|
||||
CustomDatePipe,
|
||||
SlicePipe,
|
||||
JsonPipe,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgTemplateOutlet,
|
||||
NgbCollapseModule,
|
||||
NgbDropdownModule,
|
||||
NgbNavModule,
|
||||
NgbPaginationModule,
|
||||
NgbPopoverModule,
|
||||
NgxBootstrapIconsModule,
|
||||
],
|
||||
@@ -75,15 +152,18 @@ export class TasksComponent
|
||||
private readonly router = inject(Router)
|
||||
private readonly toastService = inject(ToastService)
|
||||
|
||||
public activeTab: TaskTab
|
||||
readonly TaskSection = TaskSection
|
||||
readonly sections = [
|
||||
TaskSection.NeedsAttention,
|
||||
TaskSection.InProgress,
|
||||
TaskSection.Completed,
|
||||
]
|
||||
public selectedTasks: Set<number> = new Set()
|
||||
public togggleAll: boolean = false
|
||||
public expandedTask: number
|
||||
|
||||
public pageSize: number = 25
|
||||
public page: number = 1
|
||||
|
||||
public autoRefreshEnabled: boolean = true
|
||||
public selectedSection: TaskSection = TaskSection.All
|
||||
public selectedTaskType: PaperlessTaskType | null = null
|
||||
public selectedTriggerSource: PaperlessTaskTriggerSource | null = null
|
||||
|
||||
private _filterText: string = ''
|
||||
get filterText() {
|
||||
@@ -95,20 +175,81 @@ export class TasksComponent
|
||||
|
||||
public filterTargetID: TaskFilterTargetID = TaskFilterTargetID.Name
|
||||
public get filterTargetName(): string {
|
||||
return this.filterTargets.find((t) => t.id == this.filterTargetID).name
|
||||
return FILTER_TARGETS.find((t) => t.id == this.filterTargetID).name
|
||||
}
|
||||
private filterDebounce: Subject<string> = new Subject<string>()
|
||||
|
||||
public get filterTargets(): Array<{ id: number; name: string }> {
|
||||
return [TaskTab.Failed, TaskTab.Completed].includes(this.activeTab)
|
||||
? FILTER_TARGETS
|
||||
: FILTER_TARGETS.slice(0, 1)
|
||||
return FILTER_TARGETS
|
||||
}
|
||||
|
||||
public get taskTypeOptions(): Array<{
|
||||
value: PaperlessTaskType
|
||||
label: string
|
||||
}> {
|
||||
return TASK_TYPE_OPTIONS
|
||||
}
|
||||
|
||||
public get triggerSourceOptions(): Array<{
|
||||
value: PaperlessTaskTriggerSource
|
||||
label: string
|
||||
}> {
|
||||
return TRIGGER_SOURCE_OPTIONS
|
||||
}
|
||||
|
||||
public get selectedTaskTypeLabel(): string {
|
||||
if (this.selectedTaskType === null) {
|
||||
return $localize`All types`
|
||||
}
|
||||
|
||||
return (
|
||||
this.taskTypeOptions.find(
|
||||
(option) => option.value === this.selectedTaskType
|
||||
)?.label ?? this.selectedTaskType
|
||||
)
|
||||
}
|
||||
|
||||
public get selectedTriggerSourceLabel(): string {
|
||||
if (this.selectedTriggerSource === null) {
|
||||
return $localize`All sources`
|
||||
}
|
||||
|
||||
return (
|
||||
this.triggerSourceOptions.find(
|
||||
(option) => option.value === this.selectedTriggerSource
|
||||
)?.label ?? this.selectedTriggerSource
|
||||
)
|
||||
}
|
||||
|
||||
get dismissButtonText(): string {
|
||||
return this.selectedTasks.size > 0
|
||||
? $localize`Dismiss selected`
|
||||
: $localize`Dismiss all`
|
||||
: $localize`Dismiss visible`
|
||||
}
|
||||
|
||||
get visibleSections(): TaskSection[] {
|
||||
const sections =
|
||||
this.selectedSection === TaskSection.All
|
||||
? this.sections
|
||||
: [this.selectedSection]
|
||||
|
||||
return sections.filter(
|
||||
(section) => this.tasksForSection(section).length > 0
|
||||
)
|
||||
}
|
||||
|
||||
get visibleTasks(): PaperlessTask[] {
|
||||
return this.visibleSections.flatMap((section) =>
|
||||
this.tasksForSection(section)
|
||||
)
|
||||
}
|
||||
|
||||
get isFiltered(): boolean {
|
||||
return (
|
||||
this.selectedTaskType !== null ||
|
||||
this.selectedTriggerSource !== null ||
|
||||
this._filterText.length > 0
|
||||
)
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
@@ -143,14 +284,16 @@ export class TasksComponent
|
||||
|
||||
dismissTasks(task: PaperlessTask = undefined) {
|
||||
let tasks = task ? new Set([task.id]) : new Set(this.selectedTasks.values())
|
||||
if (!task && tasks.size == 0)
|
||||
tasks = new Set(this.tasksService.allFileTasks.map((t) => t.id))
|
||||
if (!task && tasks.size == 0) {
|
||||
tasks = new Set(this.visibleTasks.map((t) => t.id))
|
||||
}
|
||||
|
||||
if (tasks.size > 1) {
|
||||
let modal = this.modalService.open(ConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
modal.componentInstance.title = $localize`Confirm Dismiss All`
|
||||
modal.componentInstance.messageBold = $localize`Dismiss all ${tasks.size} tasks?`
|
||||
modal.componentInstance.title = $localize`Confirm Dismiss`
|
||||
modal.componentInstance.messageBold = $localize`Dismiss ${tasks.size} tasks?`
|
||||
modal.componentInstance.btnClass = 'btn-warning'
|
||||
modal.componentInstance.btnCaption = $localize`Dismiss`
|
||||
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
||||
@@ -164,7 +307,7 @@ export class TasksComponent
|
||||
})
|
||||
this.clearSelection()
|
||||
})
|
||||
} else {
|
||||
} else if (tasks.size === 1) {
|
||||
this.tasksService.dismissTasks(tasks).subscribe({
|
||||
error: (e) =>
|
||||
this.toastService.showError($localize`Error dismissing task`, e),
|
||||
@@ -188,77 +331,167 @@ export class TasksComponent
|
||||
: this.selectedTasks.add(task.id)
|
||||
}
|
||||
|
||||
get currentTasks(): PaperlessTask[] {
|
||||
let tasks: PaperlessTask[] = []
|
||||
switch (this.activeTab) {
|
||||
case TaskTab.Queued:
|
||||
tasks = this.tasksService.queuedFileTasks
|
||||
break
|
||||
case TaskTab.Started:
|
||||
tasks = this.tasksService.startedFileTasks
|
||||
break
|
||||
case TaskTab.Completed:
|
||||
tasks = this.tasksService.completedFileTasks
|
||||
break
|
||||
case TaskTab.Failed:
|
||||
tasks = this.tasksService.failedFileTasks
|
||||
break
|
||||
toggleSection(section: TaskSection, event: PointerEvent) {
|
||||
const sectionTasks = this.tasksForSection(section)
|
||||
if ((event.target as HTMLInputElement).checked) {
|
||||
sectionTasks.forEach((task) => this.selectedTasks.add(task.id))
|
||||
} else {
|
||||
sectionTasks.forEach((task) => this.selectedTasks.delete(task.id))
|
||||
}
|
||||
if (this._filterText.length) {
|
||||
tasks = tasks.filter((t) => {
|
||||
if (this.filterTargetID == TaskFilterTargetID.Name) {
|
||||
return (t.input_data?.filename as string)
|
||||
?.toLowerCase()
|
||||
.includes(this._filterText.toLowerCase())
|
||||
} else if (this.filterTargetID == TaskFilterTargetID.Result) {
|
||||
return t.result_message
|
||||
?.toLowerCase()
|
||||
.includes(this._filterText.toLowerCase())
|
||||
}
|
||||
})
|
||||
}
|
||||
return tasks
|
||||
}
|
||||
|
||||
toggleAll(event: PointerEvent) {
|
||||
if ((event.target as HTMLInputElement).checked) {
|
||||
this.selectedTasks = new Set(this.currentTasks.map((t) => t.id))
|
||||
} else {
|
||||
this.clearSelection()
|
||||
areAllSelected(tasks: PaperlessTask[]): boolean {
|
||||
return (
|
||||
tasks.length > 0 && tasks.every((task) => this.selectedTasks.has(task.id))
|
||||
)
|
||||
}
|
||||
|
||||
taskDisplayName(task: PaperlessTask): string {
|
||||
return task.input_data?.filename?.toString() || task.task_type_display
|
||||
}
|
||||
|
||||
taskShowsSeparateTypeLabel(task: PaperlessTask): boolean {
|
||||
return this.taskDisplayName(task) !== task.task_type_display
|
||||
}
|
||||
|
||||
taskResultMessage(task: PaperlessTask): string | null {
|
||||
if (!task.result_data) {
|
||||
return null
|
||||
}
|
||||
|
||||
const documentId = task.result_data?.['document_id']
|
||||
if (typeof documentId === 'number') {
|
||||
return `Success. New document id ${documentId} created`
|
||||
}
|
||||
|
||||
const reason = task.result_data?.['reason']
|
||||
if (typeof reason === 'string') {
|
||||
return reason
|
||||
}
|
||||
|
||||
const duplicateOf = task.result_data?.['duplicate_of']
|
||||
if (typeof duplicateOf === 'number') {
|
||||
return `Duplicate of document #${duplicateOf}`
|
||||
}
|
||||
|
||||
const errorMessage = task.result_data?.['error_message']
|
||||
if (typeof errorMessage === 'string') {
|
||||
return errorMessage
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
taskResultPreview(task: PaperlessTask): string | null {
|
||||
const message = this.taskResultMessage(task)
|
||||
if (!message) {
|
||||
return null
|
||||
}
|
||||
|
||||
return message.length > 50 ? `${message.slice(0, 50)}...` : message
|
||||
}
|
||||
|
||||
taskHasLongResultMessage(task: PaperlessTask): boolean {
|
||||
return (this.taskResultMessage(task)?.length ?? 0) > 50
|
||||
}
|
||||
|
||||
taskHasResultMessage(task: PaperlessTask): boolean {
|
||||
return !!this.taskResultMessage(task)
|
||||
}
|
||||
|
||||
duplicateDocumentId(task: PaperlessTask): number | null {
|
||||
const duplicateOf = task.result_data?.['duplicate_of']
|
||||
return typeof duplicateOf === 'number' ? duplicateOf : null
|
||||
}
|
||||
|
||||
duplicateTaskLabel(task: PaperlessTask): string {
|
||||
return $localize`Duplicate of document #${this.duplicateDocumentId(task)}`
|
||||
}
|
||||
|
||||
openDuplicateDocument(documentId: number) {
|
||||
this.router.navigate(['documents', documentId, 'details'])
|
||||
}
|
||||
|
||||
taskResultPopoverMessage(task: PaperlessTask): string {
|
||||
return this.taskResultMessage(task)?.slice(0, 300) ?? ''
|
||||
}
|
||||
|
||||
taskResultMessageOverflowsPopover(task: PaperlessTask): boolean {
|
||||
return (this.taskResultMessage(task)?.length ?? 0) > 300
|
||||
}
|
||||
|
||||
tasksForSection(section: TaskSection): PaperlessTask[] {
|
||||
let tasks = this.tasksService.allFileTasks.filter((task) =>
|
||||
this.taskBelongsToSection(task, section)
|
||||
)
|
||||
|
||||
return tasks.filter((task) => this.taskMatchesCurrentFilters(task))
|
||||
}
|
||||
|
||||
sectionLabel(section: TaskSection): string {
|
||||
return SECTION_LABELS[section]
|
||||
}
|
||||
|
||||
sectionCount(section: TaskSection): number {
|
||||
return this.tasksService.allFileTasks.filter((task) =>
|
||||
this.taskBelongsToSection(task, section)
|
||||
).length
|
||||
}
|
||||
|
||||
sectionShowsResults(section: TaskSection): boolean {
|
||||
return section !== TaskSection.InProgress
|
||||
}
|
||||
|
||||
setSection(section: TaskSection) {
|
||||
this.selectedSection = section
|
||||
this.clearSelection()
|
||||
}
|
||||
|
||||
setTaskType(taskType: PaperlessTaskType | null) {
|
||||
this.selectedTaskType = taskType
|
||||
this.clearSelection()
|
||||
}
|
||||
|
||||
setTriggerSource(triggerSource: PaperlessTaskTriggerSource | null) {
|
||||
this.selectedTriggerSource = triggerSource
|
||||
this.clearSelection()
|
||||
}
|
||||
|
||||
taskTypeOptionCount(taskType: PaperlessTaskType | null): number {
|
||||
return this.tasksForOptionCounts({ taskType }).length
|
||||
}
|
||||
|
||||
triggerSourceOptionCount(
|
||||
triggerSource: PaperlessTaskTriggerSource | null
|
||||
): number {
|
||||
return this.tasksForOptionCounts({ triggerSource }).length
|
||||
}
|
||||
|
||||
isTaskTypeOptionDisabled(taskType: PaperlessTaskType | null): boolean {
|
||||
return this.taskTypeOptionCount(taskType) === 0
|
||||
}
|
||||
|
||||
isTriggerSourceOptionDisabled(
|
||||
triggerSource: PaperlessTaskTriggerSource | null
|
||||
): boolean {
|
||||
return this.triggerSourceOptionCount(triggerSource) === 0
|
||||
}
|
||||
|
||||
clearSelection() {
|
||||
this.togggleAll = false
|
||||
this.selectedTasks.clear()
|
||||
}
|
||||
|
||||
duringTabChange() {
|
||||
this.page = 1
|
||||
}
|
||||
|
||||
beforeTabChange() {
|
||||
this.resetFilter()
|
||||
this.filterTargetID = TaskFilterTargetID.Name
|
||||
}
|
||||
|
||||
get activeTabLocalized(): string {
|
||||
switch (this.activeTab) {
|
||||
case TaskTab.Queued:
|
||||
return $localize`queued`
|
||||
case TaskTab.Started:
|
||||
return $localize`started`
|
||||
case TaskTab.Completed:
|
||||
return $localize`completed`
|
||||
case TaskTab.Failed:
|
||||
return $localize`failed`
|
||||
}
|
||||
}
|
||||
|
||||
public resetFilter() {
|
||||
this._filterText = ''
|
||||
}
|
||||
|
||||
public resetFilters() {
|
||||
this.selectedTaskType = null
|
||||
this.selectedTriggerSource = null
|
||||
this.resetFilter()
|
||||
this.clearSelection()
|
||||
}
|
||||
|
||||
filterInputKeyup(event: KeyboardEvent) {
|
||||
if (event.key == 'Enter') {
|
||||
this._filterText = (event.target as HTMLInputElement).value
|
||||
@@ -266,4 +499,87 @@ export class TasksComponent
|
||||
this.resetFilter()
|
||||
}
|
||||
}
|
||||
|
||||
private taskBelongsToSection(
|
||||
task: PaperlessTask,
|
||||
section: TaskSection
|
||||
): boolean {
|
||||
switch (section) {
|
||||
case TaskSection.NeedsAttention:
|
||||
return [
|
||||
PaperlessTaskStatus.Failure,
|
||||
PaperlessTaskStatus.Revoked,
|
||||
].includes(task.status)
|
||||
case TaskSection.InProgress:
|
||||
return [
|
||||
PaperlessTaskStatus.Pending,
|
||||
PaperlessTaskStatus.Started,
|
||||
].includes(task.status)
|
||||
case TaskSection.Completed:
|
||||
return task.status === PaperlessTaskStatus.Success
|
||||
}
|
||||
}
|
||||
|
||||
private taskMatchesCurrentFilters(task: PaperlessTask): boolean {
|
||||
return this.taskMatchesFilters(task, {
|
||||
taskType: this.selectedTaskType,
|
||||
triggerSource: this.selectedTriggerSource,
|
||||
})
|
||||
}
|
||||
|
||||
private taskMatchesFilters(
|
||||
task: PaperlessTask,
|
||||
{
|
||||
taskType,
|
||||
triggerSource,
|
||||
}: {
|
||||
taskType: PaperlessTaskType | null
|
||||
triggerSource: PaperlessTaskTriggerSource | null
|
||||
}
|
||||
): boolean {
|
||||
if (taskType !== null && task.task_type !== taskType) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (triggerSource !== null && task.trigger_source !== triggerSource) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!this._filterText.length) {
|
||||
return true
|
||||
}
|
||||
|
||||
const query = this._filterText.toLowerCase()
|
||||
|
||||
if (this.filterTargetID == TaskFilterTargetID.Name) {
|
||||
return [
|
||||
this.taskDisplayName(task),
|
||||
task.task_type_display,
|
||||
task.trigger_source_display,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.some((value) => value.toLowerCase().includes(query))
|
||||
}
|
||||
|
||||
return this.taskResultMessage(task)?.toLowerCase().includes(query) ?? false
|
||||
}
|
||||
|
||||
private tasksForOptionCounts({
|
||||
taskType = this.selectedTaskType,
|
||||
triggerSource = this.selectedTriggerSource,
|
||||
}: {
|
||||
taskType?: PaperlessTaskType | null
|
||||
triggerSource?: PaperlessTaskTriggerSource | null
|
||||
}): PaperlessTask[] {
|
||||
const sections =
|
||||
this.selectedSection === TaskSection.All
|
||||
? this.sections
|
||||
: [this.selectedSection]
|
||||
|
||||
return this.tasksService.allFileTasks.filter(
|
||||
(task) =>
|
||||
sections.some((section) => this.taskBelongsToSection(task, section)) &&
|
||||
this.taskMatchesFilters(task, { taskType, triggerSource })
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,13 +294,13 @@
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }"
|
||||
tourAnchor="tour.file-tasks">
|
||||
<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">
|
||||
<i-bs class="me-2" name="list-task"></i-bs><span><ng-container i18n>File Tasks</ng-container>@if (tasksService.failedFileTasks.length > 0) {
|
||||
<span><span class="badge bg-danger ms-2 d-inline">{{tasksService.failedFileTasks.length}}</span></span>
|
||||
<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.needsAttentionTasks.length}}</span></span>
|
||||
}</span>
|
||||
@if (tasksService.failedFileTasks.length > 0 && slimSidebarEnabled) {
|
||||
<span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.failedFileTasks.length}}</span>
|
||||
@if (tasksService.needsAttentionTasks.length > 0 && slimSidebarEnabled) {
|
||||
<span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.needsAttentionTasks.length}}</span>
|
||||
}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -94,18 +94,12 @@ main {
|
||||
}
|
||||
|
||||
.sidebar.slim:not(.animating) {
|
||||
transition: none;
|
||||
|
||||
li.nav-item span,
|
||||
.sidebar-heading span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar.slim:not(.animating) ~ main.col-slim {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.sidebar.animating {
|
||||
li.nav-item span,
|
||||
.sidebar-heading span {
|
||||
|
||||
@@ -36,6 +36,7 @@ import { RemoteVersionService } from 'src/app/services/rest/remote-version.servi
|
||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||
import { SearchService } from 'src/app/services/rest/search.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { TasksService } from 'src/app/services/tasks.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
||||
@@ -97,6 +98,7 @@ describe('AppFrameComponent', () => {
|
||||
let savedViewSpy
|
||||
let modalService: NgbModal
|
||||
let maybeRefreshSpy
|
||||
let tasksService: TasksService
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
@@ -174,6 +176,7 @@ describe('AppFrameComponent', () => {
|
||||
openDocumentsService = TestBed.inject(OpenDocumentsService)
|
||||
modalService = TestBed.inject(NgbModal)
|
||||
router = TestBed.inject(Router)
|
||||
tasksService = TestBed.inject(TasksService)
|
||||
|
||||
jest
|
||||
.spyOn(settingsService, 'displayName', 'get')
|
||||
@@ -444,6 +447,16 @@ describe('AppFrameComponent', () => {
|
||||
expect(maybeRefreshSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show tasks badge for needs-attention tasks', () => {
|
||||
jest
|
||||
.spyOn(tasksService, 'needsAttentionTasks', 'get')
|
||||
.mockReturnValue([{} as any, {} as any])
|
||||
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Tasks2')
|
||||
})
|
||||
|
||||
it('should indicate attributes management availability when any permission is granted', () => {
|
||||
jest
|
||||
.spyOn(permissionsService, 'currentUserCan')
|
||||
|
||||
@@ -45,9 +45,8 @@ export interface PaperlessTask extends ObjectWithId {
|
||||
date_done?: Date
|
||||
duration_seconds?: number
|
||||
wait_time_seconds?: number
|
||||
input_data: Record<string, unknown>
|
||||
result_data?: Record<string, unknown>
|
||||
result_message?: string
|
||||
input_data: Record<string, any>
|
||||
result_data?: Record<string, any>
|
||||
related_document_ids: number[]
|
||||
acknowledged: boolean
|
||||
owner?: number
|
||||
|
||||
@@ -5,7 +5,11 @@ import {
|
||||
} from '@angular/common/http/testing'
|
||||
import { TestBed } from '@angular/core/testing'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { PaperlessTaskStatus, PaperlessTaskType } from '../data/paperless-task'
|
||||
import {
|
||||
PaperlessTaskStatus,
|
||||
PaperlessTaskTriggerSource,
|
||||
PaperlessTaskType,
|
||||
} from '../data/paperless-task'
|
||||
import { TasksService } from './tasks.service'
|
||||
|
||||
describe('TasksService', () => {
|
||||
@@ -33,7 +37,7 @@ describe('TasksService', () => {
|
||||
it('calls tasks api endpoint on reload', () => {
|
||||
tasksService.reload()
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}tasks/?task_type=consume_file&acknowledged=false`
|
||||
`${environment.apiBaseUrl}tasks/?acknowledged=false`
|
||||
)
|
||||
expect(req.request.method).toEqual('GET')
|
||||
})
|
||||
@@ -42,7 +46,7 @@ describe('TasksService', () => {
|
||||
tasksService.loading = true
|
||||
tasksService.reload()
|
||||
httpTestingController.expectNone(
|
||||
`${environment.apiBaseUrl}tasks/?task_type=consume_file&acknowledged=false`
|
||||
`${environment.apiBaseUrl}tasks/?acknowledged=false`
|
||||
)
|
||||
})
|
||||
|
||||
@@ -58,17 +62,16 @@ describe('TasksService', () => {
|
||||
req.flush([])
|
||||
// reload is then called
|
||||
httpTestingController
|
||||
.expectOne(
|
||||
`${environment.apiBaseUrl}tasks/?task_type=consume_file&acknowledged=false`
|
||||
)
|
||||
.expectOne(`${environment.apiBaseUrl}tasks/?acknowledged=false`)
|
||||
.flush([])
|
||||
})
|
||||
|
||||
it('sorts tasks returned from api', () => {
|
||||
it('groups mixed task types by status when reloading', () => {
|
||||
expect(tasksService.total).toEqual(0)
|
||||
const mockTasks = [
|
||||
{
|
||||
task_type: PaperlessTaskType.ConsumeFile,
|
||||
trigger_source: PaperlessTaskTriggerSource.FolderConsume,
|
||||
status: PaperlessTaskStatus.Success,
|
||||
acknowledged: false,
|
||||
task_id: '1234',
|
||||
@@ -77,38 +80,42 @@ describe('TasksService', () => {
|
||||
related_document_ids: [],
|
||||
},
|
||||
{
|
||||
task_type: PaperlessTaskType.ConsumeFile,
|
||||
task_type: PaperlessTaskType.SanityCheck,
|
||||
trigger_source: PaperlessTaskTriggerSource.System,
|
||||
status: PaperlessTaskStatus.Failure,
|
||||
acknowledged: false,
|
||||
task_id: '1235',
|
||||
input_data: { filename: 'file2.pdf' },
|
||||
input_data: {},
|
||||
date_created: new Date(),
|
||||
related_document_ids: [],
|
||||
},
|
||||
{
|
||||
task_type: PaperlessTaskType.ConsumeFile,
|
||||
task_type: PaperlessTaskType.MailFetch,
|
||||
trigger_source: PaperlessTaskTriggerSource.Scheduled,
|
||||
status: PaperlessTaskStatus.Pending,
|
||||
acknowledged: false,
|
||||
task_id: '1236',
|
||||
input_data: { filename: 'file3.pdf' },
|
||||
input_data: {},
|
||||
date_created: new Date(),
|
||||
related_document_ids: [],
|
||||
},
|
||||
{
|
||||
task_type: PaperlessTaskType.ConsumeFile,
|
||||
task_type: PaperlessTaskType.LlmIndex,
|
||||
trigger_source: PaperlessTaskTriggerSource.WebUI,
|
||||
status: PaperlessTaskStatus.Started,
|
||||
acknowledged: false,
|
||||
task_id: '1237',
|
||||
input_data: { filename: 'file4.pdf' },
|
||||
input_data: {},
|
||||
date_created: new Date(),
|
||||
related_document_ids: [],
|
||||
},
|
||||
{
|
||||
task_type: PaperlessTaskType.ConsumeFile,
|
||||
task_type: PaperlessTaskType.EmptyTrash,
|
||||
trigger_source: PaperlessTaskTriggerSource.Manual,
|
||||
status: PaperlessTaskStatus.Success,
|
||||
acknowledged: false,
|
||||
task_id: '1238',
|
||||
input_data: { filename: 'file5.pdf' },
|
||||
input_data: {},
|
||||
date_created: new Date(),
|
||||
related_document_ids: [],
|
||||
},
|
||||
@@ -117,7 +124,7 @@ describe('TasksService', () => {
|
||||
tasksService.reload()
|
||||
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}tasks/?task_type=consume_file&acknowledged=false`
|
||||
`${environment.apiBaseUrl}tasks/?acknowledged=false`
|
||||
)
|
||||
|
||||
req.flush(mockTasks)
|
||||
@@ -129,6 +136,57 @@ describe('TasksService', () => {
|
||||
expect(tasksService.startedFileTasks).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('includes revoked tasks in needs attention', () => {
|
||||
const mockTasks = [
|
||||
{
|
||||
task_type: PaperlessTaskType.SanityCheck,
|
||||
trigger_source: PaperlessTaskTriggerSource.System,
|
||||
status: PaperlessTaskStatus.Failure,
|
||||
acknowledged: false,
|
||||
task_id: '1235',
|
||||
input_data: {},
|
||||
date_created: new Date(),
|
||||
related_document_ids: [],
|
||||
},
|
||||
{
|
||||
task_type: PaperlessTaskType.MailFetch,
|
||||
trigger_source: PaperlessTaskTriggerSource.Scheduled,
|
||||
status: PaperlessTaskStatus.Revoked,
|
||||
acknowledged: false,
|
||||
task_id: '1236',
|
||||
input_data: {},
|
||||
date_created: new Date(),
|
||||
related_document_ids: [],
|
||||
},
|
||||
{
|
||||
task_type: PaperlessTaskType.EmptyTrash,
|
||||
trigger_source: PaperlessTaskTriggerSource.Manual,
|
||||
status: PaperlessTaskStatus.Success,
|
||||
acknowledged: false,
|
||||
task_id: '1238',
|
||||
input_data: {},
|
||||
date_created: new Date(),
|
||||
related_document_ids: [],
|
||||
},
|
||||
]
|
||||
|
||||
tasksService.reload()
|
||||
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}tasks/?acknowledged=false`
|
||||
)
|
||||
|
||||
req.flush(mockTasks)
|
||||
|
||||
expect(tasksService.needsAttentionTasks).toHaveLength(2)
|
||||
expect(tasksService.needsAttentionTasks.map((task) => task.status)).toEqual(
|
||||
expect.arrayContaining([
|
||||
PaperlessTaskStatus.Failure,
|
||||
PaperlessTaskStatus.Revoked,
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('supports running tasks', () => {
|
||||
tasksService.run(PaperlessTaskType.SanityCheck).subscribe((res) => {
|
||||
expect(res).toEqual({
|
||||
|
||||
@@ -56,13 +56,21 @@ export class TasksService {
|
||||
)
|
||||
}
|
||||
|
||||
public get needsAttentionTasks(): PaperlessTask[] {
|
||||
return this.fileTasks.filter((t) =>
|
||||
[PaperlessTaskStatus.Failure, PaperlessTaskStatus.Revoked].includes(
|
||||
t.status
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
public reload() {
|
||||
if (this.loading) return
|
||||
this.loading = true
|
||||
|
||||
this.http
|
||||
.get<PaperlessTask[]>(
|
||||
`${this.baseUrl}${this.endpoint}/?task_type=${PaperlessTaskType.ConsumeFile}&acknowledged=false`
|
||||
`${this.baseUrl}${this.endpoint}/?acknowledged=false`
|
||||
)
|
||||
.pipe(takeUntil(this.unsubscribeNotifer), first())
|
||||
.subscribe((r) => {
|
||||
|
||||
@@ -119,6 +119,10 @@ table .btn-link {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.fs-7 {
|
||||
font-size: 0.75rem !important;
|
||||
}
|
||||
|
||||
.bg-body {
|
||||
background-color: var(--bs-body-bg);
|
||||
}
|
||||
@@ -128,6 +132,10 @@ table .btn-link {
|
||||
color: var(--pngx-primary-text-contrast);
|
||||
}
|
||||
|
||||
.bg-darker {
|
||||
background-color: var(--pngx-bg-darker) !important;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
color: var(--pngx-primary-text-contrast) !important;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -101,9 +100,9 @@ def needs_rebuild(index_dir: Path) -> bool:
|
||||
"""
|
||||
Check if the search index needs rebuilding.
|
||||
|
||||
Reads .index_settings.json to compare the stored schema version and
|
||||
search language against the current configuration. Returns True if the
|
||||
file is missing, unparsable, or either value mismatches.
|
||||
Compares the current schema version and search language configuration
|
||||
against sentinel files to determine if the index is compatible with
|
||||
the current paperless-ngx version and settings.
|
||||
|
||||
Args:
|
||||
index_dir: Path to the search index directory
|
||||
@@ -111,19 +110,24 @@ def needs_rebuild(index_dir: Path) -> bool:
|
||||
Returns:
|
||||
True if the index needs rebuilding, False if it's up to date
|
||||
"""
|
||||
settings_file = index_dir / ".index_settings.json"
|
||||
if not settings_file.exists():
|
||||
version_file = index_dir / ".schema_version"
|
||||
if not version_file.exists():
|
||||
return True
|
||||
try:
|
||||
data = json.loads(settings_file.read_text())
|
||||
if data.get("schema_version") != SCHEMA_VERSION:
|
||||
if int(version_file.read_text().strip()) != SCHEMA_VERSION:
|
||||
logger.info("Search index schema version mismatch - rebuilding.")
|
||||
return True
|
||||
if "language" not in data or data["language"] != settings.SEARCH_LANGUAGE:
|
||||
logger.info("Search index language changed - rebuilding.")
|
||||
return True
|
||||
except ValueError:
|
||||
return True
|
||||
|
||||
language_file = index_dir / ".schema_language"
|
||||
if not language_file.exists():
|
||||
logger.info("Search index language sentinel missing - rebuilding.")
|
||||
return True
|
||||
if language_file.read_text().strip() != (settings.SEARCH_LANGUAGE or ""):
|
||||
logger.info("Search index language changed - rebuilding.")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@@ -145,16 +149,9 @@ def wipe_index(index_dir: Path) -> None:
|
||||
|
||||
|
||||
def _write_sentinels(index_dir: Path) -> None:
|
||||
"""Write .index_settings.json so the next index open can skip rebuilding."""
|
||||
settings_file = index_dir / ".index_settings.json"
|
||||
settings_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"language": settings.SEARCH_LANGUAGE,
|
||||
},
|
||||
),
|
||||
)
|
||||
"""Write schema version and language sentinel files so the next index open can skip rebuilding."""
|
||||
(index_dir / ".schema_version").write_text(str(SCHEMA_VERSION))
|
||||
(index_dir / ".schema_language").write_text(settings.SEARCH_LANGUAGE or "")
|
||||
|
||||
|
||||
def open_or_rebuild_index(index_dir: Path | None = None) -> tantivy.Index:
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
@@ -19,7 +18,7 @@ pytestmark = pytest.mark.search
|
||||
class TestNeedsRebuild:
|
||||
"""needs_rebuild covers all sentinel-file states that require a full reindex."""
|
||||
|
||||
def test_returns_true_when_settings_file_missing(self, index_dir: Path) -> None:
|
||||
def test_returns_true_when_version_file_missing(self, index_dir: Path) -> None:
|
||||
assert needs_rebuild(index_dir) is True
|
||||
|
||||
def test_returns_false_when_version_and_language_match(
|
||||
@@ -28,51 +27,37 @@ class TestNeedsRebuild:
|
||||
settings: SettingsWrapper,
|
||||
) -> None:
|
||||
settings.SEARCH_LANGUAGE = "en"
|
||||
(index_dir / ".index_settings.json").write_text(
|
||||
json.dumps({"schema_version": SCHEMA_VERSION, "language": "en"}),
|
||||
)
|
||||
(index_dir / ".schema_version").write_text(str(SCHEMA_VERSION))
|
||||
(index_dir / ".schema_language").write_text("en")
|
||||
assert needs_rebuild(index_dir) is False
|
||||
|
||||
def test_returns_true_on_schema_version_mismatch(
|
||||
self,
|
||||
index_dir: Path,
|
||||
settings: SettingsWrapper,
|
||||
) -> None:
|
||||
settings.SEARCH_LANGUAGE = None
|
||||
(index_dir / ".index_settings.json").write_text(
|
||||
json.dumps({"schema_version": SCHEMA_VERSION - 1, "language": None}),
|
||||
)
|
||||
def test_returns_true_on_schema_version_mismatch(self, index_dir: Path) -> None:
|
||||
(index_dir / ".schema_version").write_text(str(SCHEMA_VERSION - 1))
|
||||
assert needs_rebuild(index_dir) is True
|
||||
|
||||
def test_returns_true_when_version_is_not_an_integer(
|
||||
def test_returns_true_when_version_file_not_an_integer(
|
||||
self,
|
||||
index_dir: Path,
|
||||
settings: SettingsWrapper,
|
||||
) -> None:
|
||||
settings.SEARCH_LANGUAGE = None
|
||||
(index_dir / ".index_settings.json").write_text(
|
||||
json.dumps({"schema_version": "not-a-number", "language": None}),
|
||||
)
|
||||
(index_dir / ".schema_version").write_text("not-a-number")
|
||||
assert needs_rebuild(index_dir) is True
|
||||
|
||||
def test_returns_true_when_language_key_missing(
|
||||
def test_returns_true_when_language_sentinel_missing(
|
||||
self,
|
||||
index_dir: Path,
|
||||
settings: SettingsWrapper,
|
||||
) -> None:
|
||||
settings.SEARCH_LANGUAGE = "en"
|
||||
(index_dir / ".index_settings.json").write_text(
|
||||
json.dumps({"schema_version": SCHEMA_VERSION}),
|
||||
)
|
||||
(index_dir / ".schema_version").write_text(str(SCHEMA_VERSION))
|
||||
# .schema_language intentionally absent
|
||||
assert needs_rebuild(index_dir) is True
|
||||
|
||||
def test_returns_true_when_language_differs(
|
||||
def test_returns_true_when_language_sentinel_content_differs(
|
||||
self,
|
||||
index_dir: Path,
|
||||
settings: SettingsWrapper,
|
||||
) -> None:
|
||||
settings.SEARCH_LANGUAGE = "de"
|
||||
(index_dir / ".index_settings.json").write_text(
|
||||
json.dumps({"schema_version": SCHEMA_VERSION, "language": "en"}),
|
||||
)
|
||||
(index_dir / ".schema_version").write_text(str(SCHEMA_VERSION))
|
||||
(index_dir / ".schema_language").write_text("en")
|
||||
assert needs_rebuild(index_dir) is True
|
||||
|
||||
Reference in New Issue
Block a user