mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-05-31 19:04:52 +00:00
Enhancement: new Tasks UI (#12614)
This commit is contained in:
@@ -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 })
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user