Merge branch 'dev' into feature/ocr-templates

This commit is contained in:
shamoon authored and GitHub committed 2026-10-29 15:46:31 -07:00
commit 45b8977623
167 files changed
+46845 -33694

No files matched your search

@@ -19,7 +19,6 @@ import {
PaperlessTaskTriggerSource,
PaperlessTaskType,
} from 'src/app/data/paperless-task'
import { Results } from 'src/app/data/results'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
@@ -153,11 +152,6 @@ const tasks: PaperlessTask[] = [
},
]
const paginatedTasks: Results<PaperlessTask> = {
count: tasks.length,
results: tasks,
}
const sectionCountResponse = {
all: 7,
needs_attention: 2,
@@ -204,7 +198,7 @@ describe('TasksComponent', () => {
}).compileComponents()
tasksService = TestBed.inject(TasksService)
reloadSpy = jest.spyOn(tasksService, 'reload')
reloadSpy = jest.spyOn(tasksService, 'reload').mockImplementation(() => {})
httpTestingController = TestBed.inject(HttpTestingController)
modalService = TestBed.inject(NgbModal)
router = TestBed.inject(Router)
@@ -214,15 +208,6 @@ describe('TasksComponent', () => {
jest.useFakeTimers()
fixture.detectChanges()
httpTestingController
.expectOne(
(req) =>
req.url === `${environment.apiBaseUrl}tasks/` &&
req.params.get('acknowledged') === 'false' &&
req.params.get('page_size') === '1000'
)
.flush(paginatedTasks)
httpTestingController
.expectOne(
(req) =>
@@ -355,11 +355,11 @@
ngbPopover="Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="list-task"></i-bs><span class="nav-link-label"><ng-container i18n>Tasks</ng-container></span>
@if (tasksService.needsAttentionTasks.length > 0 && !slimSidebarEnabled) {
<span class="badge bg-danger ms-2 d-inline flex-shrink-0">{{tasksService.needsAttentionTasks.length}}</span>
@if (tasksService.needsAttentionTasksCount > 0 && !slimSidebarEnabled) {
<span class="badge bg-danger ms-2 d-inline flex-shrink-0">{{tasksService.needsAttentionTasksCount}}</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>
@if (tasksService.needsAttentionTasksCount > 0 && slimSidebarEnabled) {
<span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.needsAttentionTasksCount}}</span>
}
</a>
</li>
@@ -602,8 +602,8 @@ describe('AppFrameComponent', () => {
it('should show tasks badge for needs-attention tasks', () => {
jest
.spyOn(tasksService, 'needsAttentionTasks', 'get')
.mockReturnValue([{} as any, {} as any])
.spyOn(tasksService, 'needsAttentionTasksCount', 'get')
.mockReturnValue(2)
fixture.detectChanges()
@@ -33,21 +33,54 @@
<ng-template #comparisonValueTemplate let-atom="atom">
@if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Date) {
<input class="form-control" placeholder="yyyy-mm-dd"
[(ngModel)]="atom.value"
[ngModelOptions]="{standalone: true}"
ngbDatepicker
#d="ngbDatepicker"
[footerTemplate]="datePickerFooterTemplate" />
<button class="btn btn-sm btn-outline-secondary rounded-end" (click)="d.toggle()" type="button" aria-label="Open date picker" i18n-aria-label>
<i-bs name="calendar-event"></i-bs>
</button>
<ng-template #datePickerFooterTemplate>
<div class="btn-group-xs border-top p-2 d-flex">
<button type="button" class="btn btn-primary" (click)="atom.value = today; d.close()" i18n>Today</button>
<button type="button" class="btn btn-secondary ms-auto" (click)="d.close()" i18n>Close</button>
@let relativeDate = getRelativeDate(atom);
<div class="d-flex gap-2 w-100 ms-0 mt-1">
<fieldset class="btn-group btn-group-sm flex-shrink-0 date-mode" aria-label="Date mode" i18n-aria-label>
<input type="radio" class="btn-check" [id]="atom.id + '_fixed'" [name]="atom.id + '_dateMode'" [checked]="!relativeDate" (change)="toggleRelativeDate(atom)" [disabled]="disabled" aria-label="Fixed date" i18n-aria-label>
<label class="btn btn-outline-secondary text-nowrap" [for]="atom.id + '_fixed'" title="Fixed date" i18n-title>
<i-bs name="calendar"></i-bs><span class="d-none d-md-inline ms-2" i18n>Fixed</span>
</label>
<input type="radio" class="btn-check" [id]="atom.id + '_relative'" [name]="atom.id + '_dateMode'" [checked]="!!relativeDate" (change)="toggleRelativeDate(atom)" [disabled]="disabled" aria-label="Relative date" i18n-aria-label>
<label class="btn btn-outline-secondary text-nowrap" [for]="atom.id + '_relative'" title="Relative date" i18n-title>
<i-bs name="clock-history"></i-bs><span class="d-none d-md-inline ms-2" i18n>Relative</span>
</label>
</fieldset>
<div class="input-group input-group-sm flex-grow-1">
@if (relativeDate) {
@if (relativeDate.unit.includes('{n}')) {
<input class="form-control w-25 flex-grow-0" type="number" min="0" aria-label="Amount" i18n-aria-label
[ngModel]="relativeDate.amount"
(ngModelChange)="updateRelativeDate(atom, { amount: $event })"
[ngModelOptions]="{standalone: true}" />
}
<label class="visually-hidden" [for]="atom.id + '_unit'" i18n>Unit</label>
<select class="form-select" [id]="atom.id + '_unit'"
[ngModel]="relativeDate.unit"
(ngModelChange)="updateRelativeDate(atom, { unit: $event })"
[ngModelOptions]="{standalone: true}">
@for (unit of relativeDateUnits; track unit.id) {
<option [ngValue]="unit.id">{{unit.label}}</option>
}
</select>
} @else {
<input class="form-control" placeholder="yyyy-mm-dd"
[(ngModel)]="atom.value"
[ngModelOptions]="{standalone: true}"
ngbDatepicker
#d="ngbDatepicker"
[footerTemplate]="datePickerFooterTemplate" />
<button class="btn btn-sm btn-outline-secondary" (click)="d.toggle()" type="button" aria-label="Open date picker" i18n-aria-label>
<i-bs name="calendar-event"></i-bs>
</button>
<ng-template #datePickerFooterTemplate>
<div class="btn-group-xs border-top p-2 d-flex">
<button type="button" class="btn btn-primary" (click)="atom.value = today; d.close()" i18n>Today</button>
<button type="button" class="btn btn-secondary ms-auto" (click)="d.close()" i18n>Close</button>
</div>
</ng-template>
}
</div>
</ng-template>
</div>
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Float || getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Integer) {
<input class="w-25 form-control rounded-end" type="number" [(ngModel)]="atom.value" [ngModelOptions]="{standalone: true}" [disabled]="disabled">
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Boolean) {
@@ -83,6 +116,7 @@
</ng-template>
<ng-template #queryAtom let-atom="atom">
<label class="visually-hidden" [for]="atom.id + '_operator'" i18n>Operator</label>
<div class="input-group input-group-sm">
<ng-select #fieldSelects
class="paperless-input-select"
@@ -96,7 +130,7 @@
[appendTo]="selectAppendTo"
(mousedown)="$event.stopImmediatePropagation()"
></ng-select>
<select class="w-25 form-select" [(ngModel)]="atom.operator" [ngModelOptions]="{standalone: true}" [disabled]="disabled">
<select class="w-25 form-select" [id]="atom.id + '_operator'" [class.rounded-end]="hasDateValueRow(atom)" [(ngModel)]="atom.operator" [ngModelOptions]="{standalone: true}" [disabled]="disabled">
@for (operator of getOperatorsForField(atom.field); track operator.label) {
<option [ngValue]="operator.value">{{operator.label}}</option>
}
@@ -151,10 +185,10 @@
<input class="w-25 form-control rounded-end" type="text" [(ngModel)]="atom.value" [ngModelOptions]="{standalone: true}" [disabled]="disabled">
}
}
<button class="btn btn-link btn-sm text-danger pe-0" type="button" (click)="removeElement(atom)" [disabled]="disabled" aria-label="Remove query" i18n-aria-label>
<i-bs name="x-circle"></i-bs>
</button>
</div>
<button class="btn btn-link btn-sm text-danger pe-0 align-self-center" type="button" (click)="removeElement(atom)" [disabled]="disabled" aria-label="Remove query" i18n-aria-label>
<i-bs name="x-circle"></i-bs>
</button>
</ng-template>
<ng-template #queryExpression let-expression="expression">
@@ -5,6 +5,11 @@
}
}
:host {
--ng-select-border-radius: var(--bs-border-radius);
--ng-select-height: calc(1.5em + 0.5rem + calc(var(--bs-border-width) * 2));
}
:host ::ng-deep .ng-select-container {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
@@ -41,3 +46,13 @@
min-width: 140px;
}
}
.date-mode .btn {
--bs-btn-color: var(--bs-secondary-color);
--bs-btn-hover-bg: transparent;
--bs-btn-hover-color: var(--bs-body-color);
--bs-btn-hover-border-color: var(--bs-border-color);
--bs-btn-active-bg: transparent;
--bs-btn-active-color: var(--bs-primary);
--bs-btn-active-border-color: var(--bs-border-color);
}
@@ -3,7 +3,7 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'
import { LOCALE_ID } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { NgbDateAdapter, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select'
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { of } from 'rxjs'
@@ -11,6 +11,7 @@ import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import {
CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP,
CustomFieldQueryLogicalOperator,
CustomFieldQueryOperator,
CustomFieldQueryOperatorGroups,
} from 'src/app/data/custom-field-query'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
@@ -19,6 +20,7 @@ import {
CustomFieldQueryElement,
CustomFieldQueryExpression,
} from 'src/app/utils/custom-field-query-element'
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
import {
CustomFieldQueriesModel,
CustomFieldsQueryDropdownComponent,
@@ -48,6 +50,12 @@ const customFields = [
data_type: CustomFieldDataType.Monetary,
extra_data: { default_currency: 'EUR' },
},
{
id: 4,
name: 'Test Date Field',
data_type: CustomFieldDataType.Date,
extra_data: {},
},
]
describe('CustomFieldsQueryDropdownComponent', () => {
@@ -69,6 +77,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
{ provide: LOCALE_ID, useValue: 'de' },
{ provide: NgbDateAdapter, useClass: ISODateAdapter },
],
}).compileComponents()
@@ -237,6 +246,74 @@ describe('CustomFieldsQueryDropdownComponent', () => {
expect(component.selectionModel.queries.length).toBe(1)
})
it('should build relative date values from amount and unit', async () => {
component.useDropdown = false
const atom = new CustomFieldQueryAtom([
4,
CustomFieldQueryOperator.GreaterThanOrEqual,
'2026-10-22',
])
component.selectionModel.addAtom(atom)
fixture.detectChanges()
await fixture.whenStable()
const query = (selector: string) =>
fixture.nativeElement.querySelector(selector)
expect(query('[ngbDatepicker]')).not.toBeNull()
expect(query('input[id$="_fixed"]').checked).toBe(true)
query('input[id$="_relative"]').click()
fixture.detectChanges()
await fixture.whenStable()
expect(atom.value).toBeNull()
const amount: HTMLInputElement = query('input[aria-label="Amount"]')
const unit: HTMLSelectElement = query('select[id$="_unit"]')
amount.value = '30'
amount.dispatchEvent(new Event('input'))
expect(atom.serialize()).toEqual([4, 'gte', '30 days ago'])
unit.selectedIndex = 6
unit.dispatchEvent(new Event('change'))
expect(atom.serialize()).toEqual([4, 'gte', 'in 30 weeks'])
unit.selectedIndex = 0
unit.dispatchEvent(new Event('change'))
fixture.detectChanges()
expect(atom.serialize()).toEqual([4, 'gte', 'today'])
expect(query('input[aria-label="Amount"]')).toBeNull()
query('input[id$="_fixed"]').click()
fixture.detectChanges()
expect(atom.value).toBeNull()
expect(query('[ngbDatepicker]')).not.toBeNull()
})
it('should restore a saved relative date value', async () => {
component.useDropdown = false
const atom = new CustomFieldQueryAtom([
4,
CustomFieldQueryOperator.Exact,
'in 2 months',
])
component.selectionModel.addAtom(atom)
fixture.detectChanges()
await fixture.whenStable()
expect(component.getRelativeDate(atom)).toEqual({
amount: 2,
unit: 'in {n} months',
})
expect(
fixture.nativeElement.querySelector('input[aria-label="Amount"]').value
).toBe('2')
const today = new CustomFieldQueryAtom([
4,
CustomFieldQueryOperator.Exact,
'today',
])
expect(component.getRelativeDate(today)).toEqual({
amount: null,
unit: 'today',
})
})
describe('CustomFieldQueriesModel', () => {
let model: CustomFieldQueriesModel
@@ -49,6 +49,26 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
import { DocumentLinkComponent } from '../input/document-link/document-link.component'
interface RelativeDate {
// The expression sent to the API, with {n} standing in for the amount
amount: number | null
unit: string
}
function parseRelativeDate(
value: string,
unit: string
): RelativeDate | undefined {
const [prefix, suffix] = unit.split('{n}')
if (suffix === undefined) {
return value === unit ? { amount: null, unit } : undefined
}
if (!value.startsWith(prefix) || !value.endsWith(suffix)) return undefined
const amount = value.slice(prefix.length, value.length - suffix.length)
if (!amount || !Number.isInteger(+amount) || +amount < 0) return undefined
return { amount: +amount, unit }
}
export class CustomFieldQueriesModel {
private readonly _queries = signal<CustomFieldQueryElement[]>([])
private rootSubscriptions: Subscription[] = []
@@ -305,6 +325,67 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
public readonly today: string = new Date().toLocaleDateString('en-CA')
public readonly relativeDateUnits = [
{ id: 'today', label: $localize`Today` },
{ id: '{n} days ago', label: $localize`days ago` },
{ id: '{n} weeks ago', label: $localize`weeks ago` },
{ id: '{n} months ago', label: $localize`months ago` },
{ id: '{n} years ago', label: $localize`years ago` },
{ id: 'in {n} days', label: $localize`days from now` },
{ id: 'in {n} weeks', label: $localize`weeks from now` },
{ id: 'in {n} months', label: $localize`months from now` },
{ id: 'in {n} years', label: $localize`years from now` },
]
private readonly relativeDates = new Map<string, RelativeDate>()
getRelativeDate(atom: CustomFieldQueryAtom): RelativeDate | undefined {
if (!this.relativeDates.has(atom.id) && typeof atom.value === 'string') {
for (const { id } of this.relativeDateUnits) {
const relativeDate = parseRelativeDate(atom.value, id)
if (relativeDate) {
this.relativeDates.set(atom.id, relativeDate)
break
}
}
}
return this.relativeDates.get(atom.id)
}
hasDateValueRow(atom: CustomFieldQueryAtom): boolean {
return (
this.getCustomFieldByID(atom.field)?.data_type ===
CustomFieldDataType.Date &&
atom.operator !== CustomFieldQueryOperator.Exists &&
atom.operator !== CustomFieldQueryOperator.IsNull
)
}
toggleRelativeDate(atom: CustomFieldQueryAtom) {
if (this.getRelativeDate(atom)) {
this.relativeDates.delete(atom.id)
atom.value = null
} else {
this.relativeDates.set(atom.id, { amount: null, unit: '{n} days ago' })
this.updateRelativeDate(atom, {})
}
}
updateRelativeDate(
atom: CustomFieldQueryAtom,
change: Partial<RelativeDate>
) {
const { amount, unit } = Object.assign(
this.relativeDates.get(atom.id),
change
)
if (!unit.includes('{n}')) {
atom.value = unit
} else {
atom.value = amount === null ? null : unit.replace('{n}', `${amount}`)
}
}
public customFieldSearchFn = (term: string, field: CustomField): boolean =>
matchesSearchText(field?.name, term)
@@ -105,7 +105,7 @@
(click)="$event.stopPropagation()"
/>
} @else {
<span class="input-group-text version-label">
<span class="input-group-text overflow-hidden version-label">
@if (version.version_label) {
{{ version.version_label }}
} @else {
+22 -82
View File
@@ -9,11 +9,7 @@ import {
} from '@angular/common/http/testing'
import { TestBed } from '@angular/core/testing'
import { environment } from 'src/environments/environment'
import {
PaperlessTaskStatus,
PaperlessTaskTriggerSource,
PaperlessTaskType,
} from '../data/paperless-task'
import { PaperlessTaskType } from '../data/paperless-task'
import { TasksService } from './tasks.service'
describe('TasksService', () => {
@@ -38,23 +34,23 @@ describe('TasksService', () => {
httpTestingController.verify()
})
it('calls tasks api endpoint on reload', () => {
it('calls tasks status counts api endpoint on reload', () => {
tasksService.reload()
const req = httpTestingController.expectOne(
(req: HttpRequest<unknown>) =>
req.url === `${environment.apiBaseUrl}tasks/` &&
req.params.get('acknowledged') === 'false' &&
req.params.get('page_size') === '1000'
req.url === `${environment.apiBaseUrl}tasks/status_counts/` &&
req.params.get('acknowledged') === 'false'
)
expect(req.request.method).toEqual('GET')
req.flush({ count: 0, results: [] })
req.flush({ all: 5, needs_attention: 3, in_progress: 1, completed: 1 })
expect(tasksService.needsAttentionTasksCount).toEqual(3)
})
it('cancels an in-progress reload when reloading again', () => {
tasksService.reload()
const staleReload = httpTestingController.expectOne(
(req: HttpRequest<unknown>) =>
req.url === `${environment.apiBaseUrl}tasks/`
req.url === `${environment.apiBaseUrl}tasks/status_counts/`
)
tasksService.reload()
@@ -62,9 +58,9 @@ describe('TasksService', () => {
httpTestingController
.expectOne(
(req: HttpRequest<unknown>) =>
req.url === `${environment.apiBaseUrl}tasks/`
req.url === `${environment.apiBaseUrl}tasks/status_counts/`
)
.flush({ count: 0, results: [] })
.flush({ all: 0, needs_attention: 0, in_progress: 0, completed: 0 })
})
it('continues reloading after a reload request fails', () => {
@@ -72,7 +68,7 @@ describe('TasksService', () => {
httpTestingController
.expectOne(
(req: HttpRequest<unknown>) =>
req.url === `${environment.apiBaseUrl}tasks/`
req.url === `${environment.apiBaseUrl}tasks/status_counts/`
)
.flush('error', { status: 500, statusText: 'error' })
@@ -82,16 +78,16 @@ describe('TasksService', () => {
httpTestingController
.expectOne(
(req: HttpRequest<unknown>) =>
req.url === `${environment.apiBaseUrl}tasks/`
req.url === `${environment.apiBaseUrl}tasks/status_counts/`
)
.flush({ count: 0, results: [] })
.flush({ all: 0, needs_attention: 0, in_progress: 0, completed: 0 })
})
it('reloads after dismissing a task while a reload is already in progress', () => {
tasksService.reload()
const staleReload = httpTestingController.expectOne(
(req: HttpRequest<unknown>) =>
req.url === `${environment.apiBaseUrl}tasks/` &&
req.url === `${environment.apiBaseUrl}tasks/status_counts/` &&
req.params.get('acknowledged') === 'false'
)
@@ -104,12 +100,12 @@ describe('TasksService', () => {
httpTestingController
.expectOne(
(req: HttpRequest<unknown>) =>
req.url === `${environment.apiBaseUrl}tasks/` &&
req.url === `${environment.apiBaseUrl}tasks/status_counts/` &&
req.params.get('acknowledged') === 'false'
)
.flush({ count: 0, results: [] })
.flush({ all: 50, needs_attention: 42, in_progress: 3, completed: 5 })
expect(tasksService.needsAttentionTasks).toHaveLength(0)
expect(tasksService.needsAttentionTasksCount).toEqual(42)
})
it('calls acknowledge_tasks api endpoint on dismiss and reloads', () => {
@@ -126,11 +122,10 @@ describe('TasksService', () => {
httpTestingController
.expectOne(
(req: HttpRequest<unknown>) =>
req.url === `${environment.apiBaseUrl}tasks/` &&
req.params.get('acknowledged') === 'false' &&
req.params.get('page_size') === '1000'
req.url === `${environment.apiBaseUrl}tasks/status_counts/` &&
req.params.get('acknowledged') === 'false'
)
.flush({ count: 0, results: [] })
.flush({ all: 0, needs_attention: 0, in_progress: 0, completed: 0 })
})
it('calls acknowledge_tasks api endpoint on dismiss all and reloads', () => {
@@ -147,65 +142,10 @@ describe('TasksService', () => {
httpTestingController
.expectOne(
(req: HttpRequest<unknown>) =>
req.url === `${environment.apiBaseUrl}tasks/` &&
req.params.get('acknowledged') === 'false' &&
req.params.get('page_size') === '1000'
req.url === `${environment.apiBaseUrl}tasks/status_counts/` &&
req.params.get('acknowledged') === 'false'
)
.flush({ count: 0, results: [] })
})
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(
(req: HttpRequest<unknown>) =>
req.url === `${environment.apiBaseUrl}tasks/` &&
req.params.get('acknowledged') === 'false' &&
req.params.get('page_size') === '1000'
)
req.flush({ count: mockTasks.length, results: mockTasks })
expect(tasksService.needsAttentionTasks).toHaveLength(2)
expect(tasksService.needsAttentionTasks.map((task) => task.status)).toEqual(
expect.arrayContaining([
PaperlessTaskStatus.Failure,
PaperlessTaskStatus.Revoked,
])
)
.flush({ all: 0, needs_attention: 0, in_progress: 0, completed: 0 })
})
it('supports running tasks', () => {
+23 -33
View File
@@ -5,14 +5,12 @@ import {
catchError,
finalize,
first,
map,
switchMap,
takeUntil,
tap,
} from 'rxjs/operators'
import {
PaperlessTask,
PaperlessTaskStatus,
PaperlessTaskStatusCounts,
PaperlessTaskType,
} from 'src/app/data/paperless-task'
@@ -27,50 +25,42 @@ export class TasksService {
private baseUrl: string = environment.apiBaseUrl
private endpoint: string = 'tasks'
private readonly defaultReloadPageSize = 1000
public loading: boolean = false
private readonly tasks = signal<PaperlessTask[]>([])
private readonly tasksStatusCounts = signal<PaperlessTaskStatusCounts>({
all: 0,
needs_attention: 0,
in_progress: 0,
completed: 0,
})
private readonly reloadNotifier = new Subject<void>()
private unsubscribeNotifer: Subject<any> = new Subject()
constructor() {
this.reloadNotifier
.pipe(
switchMap(() => {
this.loading = true
return this.http
.get<Results<PaperlessTask>>(`${this.baseUrl}${this.endpoint}/`, {
params: {
acknowledged: 'false',
page_size: this.defaultReloadPageSize,
},
})
.pipe(
map((response) => response.results),
takeUntil(this.unsubscribeNotifer),
catchError(() => EMPTY),
finalize(() => {
this.loading = false
})
)
})
)
.subscribe((tasks) => {
this.tasks.set(tasks)
.pipe(switchMap(() => this.fetchUnacknowledgedTasksCounts()))
.subscribe((taskStatusCounts) => {
this.tasksStatusCounts.set(taskStatusCounts)
})
}
public get needsAttentionTasks(): PaperlessTask[] {
return this.tasks().filter((t) =>
[PaperlessTaskStatus.Failure, PaperlessTaskStatus.Revoked].includes(
t.status
)
private fetchUnacknowledgedTasksCounts(): Observable<PaperlessTaskStatusCounts> {
this.loading = true
return this.statusCounts({ acknowledged: false }).pipe(
takeUntil(this.unsubscribeNotifer),
catchError(() => EMPTY),
finalize(() => {
this.loading = false
})
)
}
public get needsAttentionTasksCount(): number {
return this.tasksStatusCounts().needs_attention
}
public reload() {
this.reloadNotifier.next()
}
@@ -105,7 +95,7 @@ export class TasksService {
public dismissTasks(task_ids: Set<number>): Observable<any> {
return this.http
.post(`${this.baseUrl}tasks/acknowledge/`, {
.post(`${this.baseUrl}${this.endpoint}/acknowledge/`, {
tasks: [...task_ids],
})
.pipe(
@@ -119,7 +109,7 @@ export class TasksService {
public dismissAllTasks(): Observable<any> {
return this.http
.post(`${this.baseUrl}tasks/acknowledge/`, {
.post(`${this.baseUrl}${this.endpoint}/acknowledge/`, {
all: true,
})
.pipe(
+1 -1
View File
@@ -8,7 +8,7 @@ export const environment = {
apiVersion: '10', // match src/paperless/settings.py
appTitle: DEFAULT_APP_TITLE,
tag: 'prod',
version: '3.3.4',
version: '3.4.0',
webSocketHost: window.location.host,
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
webSocketBaseUrl: base_url.pathname + 'ws/',
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff