Merge pull request #4055 from paperless-ngx/feature-frontend-handle-slowness

Enhancement: frontend better handle slow backend requests
This commit is contained in:
shamoon 2023-09-08 19:59:33 -07:00 committed by GitHub
commit e3352ea426
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 133 additions and 56 deletions

View File

@ -1,5 +1,5 @@
<div class="btn-group w-100" ngbDropdown role="group"> <div class="btn-group w-100" ngbDropdown role="group">
<button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="dateBefore || dateAfter ? 'btn-primary' : 'btn-outline-primary'"> <button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="dateBefore || dateAfter ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled">
{{title}} {{title}}
<app-clearable-badge [selected]="isActive" (cleared)="reset()"></app-clearable-badge><span class="visually-hidden">selected</span> <app-clearable-badge [selected]="isActive" (cleared)="reset()"></app-clearable-badge><span class="visually-hidden">selected</span>
</button> </button>

View File

@ -85,6 +85,9 @@ export class DateDropdownComponent implements OnInit, OnDestroy {
@Output() @Output()
datesSet = new EventEmitter<DateSelection>() datesSet = new EventEmitter<DateSelection>()
@Input()
disabled: boolean = false
get isActive(): boolean { get isActive(): boolean {
return ( return (
this.relativeDate !== null || this.relativeDate !== null ||

View File

@ -1,5 +1,5 @@
<div class="btn-group w-100" ngbDropdown role="group"> <div class="btn-group w-100" ngbDropdown role="group">
<button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="isActive ? 'btn-primary' : 'btn-outline-primary'"> <button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="isActive ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled">
<svg class="toolbaricon" fill="currentColor"> <svg class="toolbaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#person-fill-lock" /> <use xlink:href="assets/bootstrap-icons.svg#person-fill-lock" />
</svg> </svg>

View File

@ -4,11 +4,10 @@ import {
OnDestroy, OnDestroy,
OnInit, OnInit,
QueryList, QueryList,
ViewChild,
ViewChildren, ViewChildren,
} from '@angular/core' } from '@angular/core'
import { Router } from '@angular/router' import { Router } from '@angular/router'
import { Subscription } from 'rxjs' import { Subject, takeUntil } from 'rxjs'
import { PaperlessDocument } from 'src/app/data/paperless-document' import { PaperlessDocument } from 'src/app/data/paperless-document'
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view' import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'
import { ConsumerStatusService } from 'src/app/services/consumer-status.service' import { ConsumerStatusService } from 'src/app/services/consumer-status.service'
@ -49,7 +48,7 @@ export class SavedViewWidgetComponent
documents: PaperlessDocument[] = [] documents: PaperlessDocument[] = []
subscription: Subscription unsubscribeNotifier: Subject<any> = new Subject()
@ViewChildren('popover') popovers: QueryList<NgbPopover> @ViewChildren('popover') popovers: QueryList<NgbPopover>
popover: NgbPopover popover: NgbPopover
@ -59,15 +58,17 @@ export class SavedViewWidgetComponent
ngOnInit(): void { ngOnInit(): void {
this.reload() this.reload()
this.subscription = this.consumerStatusService this.consumerStatusService
.onDocumentConsumptionFinished() .onDocumentConsumptionFinished()
.subscribe((status) => { .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
this.reload() this.reload()
}) })
} }
ngOnDestroy(): void { ngOnDestroy(): void {
this.subscription.unsubscribe() this.unsubscribeNotifier.next(true)
this.unsubscribeNotifier.complete()
} }
reload() { reload() {
@ -81,6 +82,7 @@ export class SavedViewWidgetComponent
this.savedView.filter_rules, this.savedView.filter_rules,
{ truncate_content: true } { truncate_content: true }
) )
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((result) => { .subscribe((result) => {
this.loading = false this.loading = false
this.documents = result.results this.documents = result.results

View File

@ -185,7 +185,7 @@ export class DocumentListComponent
} }
ngOnDestroy() { ngOnDestroy() {
// unsubscribes all this.list.cancelPending()
this.unsubscribeNotifier.next(this) this.unsubscribeNotifier.next(this)
this.unsubscribeNotifier.complete() this.unsubscribeNotifier.complete()
} }

View File

@ -2,16 +2,23 @@
</app-page-header> </app-page-header>
<ul ngbNav #nav="ngbNav" [(activeId)]="activeLog" (activeIdChange)="reloadLogs()" class="nav-tabs"> <ul ngbNav #nav="ngbNav" [(activeId)]="activeLog" (activeIdChange)="reloadLogs()" class="nav-tabs">
<li *ngFor="let logFile of logFiles" [ngbNavItem]="logFile"> <li *ngFor="let logFile of logFiles" [ngbNavItem]="logFile">
<a ngbNavLink>{{logFile}}.log</a> <a ngbNavLink>{{logFile}}.log</a>
</li> </li>
<div *ngIf="isLoading && !logFiles.length" class="pb-2">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
<ng-container i18n>Loading...</ng-container>
</div>
</ul> </ul>
<div [ngbNavOutlet]="nav" class="mt-2"></div> <div [ngbNavOutlet]="nav" class="mt-2"></div>
<div class="bg-dark p-3 text-light font-monospace log-container" #logContainer> <div class="bg-dark p-3 text-light font-monospace log-container" #logContainer>
<div *ngIf="isLoading && logFiles.length">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
<ng-container i18n>Loading...</ng-container>
</div>
<p <p
class="m-0 p-0 log-entry-{{getLogLevel(log)}}" class="m-0 p-0 log-entry-{{getLogLevel(log)}}"
*ngFor="let log of logs">{{log}}</p> *ngFor="let log of logs">{{log}}</p>

View File

@ -4,7 +4,9 @@ import {
OnInit, OnInit,
AfterViewChecked, AfterViewChecked,
ViewChild, ViewChild,
OnDestroy,
} from '@angular/core' } from '@angular/core'
import { Subject, takeUntil } from 'rxjs'
import { LogService } from 'src/app/services/rest/log.service' import { LogService } from 'src/app/services/rest/log.service'
@Component({ @Component({
@ -12,40 +14,60 @@ import { LogService } from 'src/app/services/rest/log.service'
templateUrl: './logs.component.html', templateUrl: './logs.component.html',
styleUrls: ['./logs.component.scss'], styleUrls: ['./logs.component.scss'],
}) })
export class LogsComponent implements OnInit, AfterViewChecked { export class LogsComponent implements OnInit, AfterViewChecked, OnDestroy {
constructor(private logService: LogService) {} constructor(private logService: LogService) {}
logs: string[] = [] public logs: string[] = []
logFiles: string[] = [] public logFiles: string[] = []
activeLog: string public activeLog: string
private unsubscribeNotifier: Subject<any> = new Subject()
public isLoading: boolean = false
@ViewChild('logContainer') logContainer: ElementRef @ViewChild('logContainer') logContainer: ElementRef
ngOnInit(): void { ngOnInit(): void {
this.logService.list().subscribe((result) => { this.isLoading = true
this.logFiles = result this.logService
if (this.logFiles.length > 0) { .list()
this.activeLog = this.logFiles[0] .pipe(takeUntil(this.unsubscribeNotifier))
this.reloadLogs() .subscribe((result) => {
} this.logFiles = result
}) this.isLoading = false
if (this.logFiles.length > 0) {
this.activeLog = this.logFiles[0]
this.reloadLogs()
}
})
} }
ngAfterViewChecked() { ngAfterViewChecked() {
this.scrollToBottom() this.scrollToBottom()
} }
ngOnDestroy(): void {
this.unsubscribeNotifier.next(true)
this.unsubscribeNotifier.complete()
}
reloadLogs() { reloadLogs() {
this.logService.get(this.activeLog).subscribe({ this.isLoading = true
next: (result) => { this.logService
this.logs = result .get(this.activeLog)
}, .pipe(takeUntil(this.unsubscribeNotifier))
error: () => { .subscribe({
this.logs = [] next: (result) => {
}, this.logs = result
}) this.isLoading = false
},
error: () => {
this.logs = []
this.isLoading = false
},
})
} }
getLogLevel(log: string) { getLogLevel(log: string) {

View File

@ -24,6 +24,12 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr *ngIf="isLoading">
<td colspan="5">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
<ng-container i18n>Loading...</ng-container>
</td>
</tr>
<tr *ngFor="let object of data"> <tr *ngFor="let object of data">
<td scope="row">{{ object.name }}</td> <td scope="row">{{ object.name }}</td>
<td scope="row" class="d-none d-sm-table-cell">{{ getMatching(object) }}</td> <td scope="row" class="d-none d-sm-table-cell">{{ getMatching(object) }}</td>
@ -69,7 +75,7 @@
</tbody> </tbody>
</table> </table>
<div class="d-flex"> <div class="d-flex" *ngIf="!isLoading">
<div i18n *ngIf="collectionSize > 0">{collectionSize, plural, =1 {One {{typeName}}} other {{{collectionSize || 0}} total {{typeNamePlural}}}}</div> <div i18n *ngIf="collectionSize > 0">{collectionSize, plural, =1 {One {{typeName}}} other {{{collectionSize || 0}} total {{typeNamePlural}}}}</div>
<ngb-pagination *ngIf="collectionSize > 20" class="ms-auto" [pageSize]="25" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination> <ngb-pagination *ngIf="collectionSize > 20" class="ms-auto" [pageSize]="25" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
</div> </div>

View File

@ -7,7 +7,7 @@ import {
} from '@angular/core' } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { Subject, Subscription } from 'rxjs' import { Subject, Subscription } from 'rxjs'
import { debounceTime, distinctUntilChanged } from 'rxjs/operators' import { debounceTime, distinctUntilChanged, takeUntil } from 'rxjs/operators'
import { import {
MatchingModel, MatchingModel,
MATCHING_ALGORITHMS, MATCHING_ALGORITHMS,
@ -76,8 +76,10 @@ export abstract class ManagementListComponent<T extends ObjectWithId>
public sortField: string public sortField: string
public sortReverse: boolean public sortReverse: boolean
public isLoading: boolean = false
private nameFilterDebounce: Subject<string> private nameFilterDebounce: Subject<string>
private subscription: Subscription private unsubscribeNotifier: Subject<any> = new Subject()
private _nameFilter: string private _nameFilter: string
ngOnInit(): void { ngOnInit(): void {
@ -85,8 +87,12 @@ export abstract class ManagementListComponent<T extends ObjectWithId>
this.nameFilterDebounce = new Subject<string>() this.nameFilterDebounce = new Subject<string>()
this.subscription = this.nameFilterDebounce this.nameFilterDebounce
.pipe(debounceTime(400), distinctUntilChanged()) .pipe(
takeUntil(this.unsubscribeNotifier),
debounceTime(400),
distinctUntilChanged()
)
.subscribe((title) => { .subscribe((title) => {
this._nameFilter = title this._nameFilter = title
this.page = 1 this.page = 1
@ -95,7 +101,8 @@ export abstract class ManagementListComponent<T extends ObjectWithId>
} }
ngOnDestroy() { ngOnDestroy() {
this.subscription.unsubscribe() this.unsubscribeNotifier.next(true)
this.unsubscribeNotifier.complete()
} }
getMatching(o: MatchingModel) { getMatching(o: MatchingModel) {
@ -119,6 +126,7 @@ export abstract class ManagementListComponent<T extends ObjectWithId>
} }
reloadData() { reloadData() {
this.isLoading = true
this.service this.service
.listFiltered( .listFiltered(
this.page, this.page,
@ -128,9 +136,11 @@ export abstract class ManagementListComponent<T extends ObjectWithId>
this._nameFilter, this._nameFilter,
true true
) )
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((c) => { .subscribe((c) => {
this.data = c.results this.data = c.results
this.collectionSize = c.count this.collectionSize = c.count
this.isLoading = false
}) })
} }
@ -192,19 +202,22 @@ export abstract class ManagementListComponent<T extends ObjectWithId>
activeModal.componentInstance.btnCaption = $localize`Delete` activeModal.componentInstance.btnCaption = $localize`Delete`
activeModal.componentInstance.confirmClicked.subscribe(() => { activeModal.componentInstance.confirmClicked.subscribe(() => {
activeModal.componentInstance.buttonsEnabled = false activeModal.componentInstance.buttonsEnabled = false
this.service.delete(object).subscribe({ this.service
next: () => { .delete(object)
activeModal.close() .pipe(takeUntil(this.unsubscribeNotifier))
this.reloadData() .subscribe({
}, next: () => {
error: (error) => { activeModal.close()
activeModal.componentInstance.buttonsEnabled = true this.reloadData()
this.toastService.showError( },
$localize`Error while deleting element`, error: (error) => {
error activeModal.componentInstance.buttonsEnabled = true
) this.toastService.showError(
}, $localize`Error while deleting element`,
}) error
)
},
})
}) })
} }

View File

@ -1,7 +1,7 @@
import { Component, OnInit, OnDestroy } from '@angular/core' import { Component, OnInit, OnDestroy } from '@angular/core'
import { Router } from '@angular/router' import { Router } from '@angular/router'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { Subject, first } from 'rxjs' import { first } from 'rxjs'
import { PaperlessTask } from 'src/app/data/paperless-task' import { PaperlessTask } from 'src/app/data/paperless-task'
import { TasksService } from 'src/app/services/tasks.service' import { TasksService } from 'src/app/services/tasks.service'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
@ -18,7 +18,6 @@ export class TasksComponent
{ {
public activeTab: string public activeTab: string
public selectedTasks: Set<number> = new Set() public selectedTasks: Set<number> = new Set()
private unsubscribeNotifer = new Subject()
public expandedTask: number public expandedTask: number
public pageSize: number = 25 public pageSize: number = 25
@ -43,7 +42,7 @@ export class TasksComponent
} }
ngOnDestroy() { ngOnDestroy() {
this.unsubscribeNotifer.next(true) this.tasksService.cancelPending()
} }
dismissTask(task: PaperlessTask) { dismissTask(task: PaperlessTask) {

View File

@ -103,6 +103,7 @@ describe('DocumentListViewService', () => {
}) })
afterEach(() => { afterEach(() => {
documentListViewService.cancelPending()
httpTestingController.verify() httpTestingController.verify()
sessionStorage.clear() sessionStorage.clear()
}) })
@ -425,4 +426,13 @@ describe('DocumentListViewService', () => {
}) })
expect(documentListViewService.selected.size).toEqual(3) expect(documentListViewService.selected.size).toEqual(3)
}) })
it('should cancel on reload the list', () => {
const cancelSpy = jest.spyOn(documentListViewService, 'cancelPending')
documentListViewService.reload()
httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=9`
)
expect(cancelSpy).toHaveBeenCalled()
})
}) })

View File

@ -1,6 +1,6 @@
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { ParamMap, Router } from '@angular/router' import { ParamMap, Router } from '@angular/router'
import { Observable, first } from 'rxjs' import { Observable, Subject, first, takeUntil } from 'rxjs'
import { FilterRule } from '../data/filter-rule' import { FilterRule } from '../data/filter-rule'
import { import {
filterRulesDiffer, filterRulesDiffer,
@ -82,6 +82,8 @@ export class DocumentListViewService {
currentPageSize: number = this.settings.get(SETTINGS_KEYS.DOCUMENT_LIST_SIZE) currentPageSize: number = this.settings.get(SETTINGS_KEYS.DOCUMENT_LIST_SIZE)
private unsubscribeNotifier: Subject<any> = new Subject()
private listViewStates: Map<number, ListViewState> = new Map() private listViewStates: Map<number, ListViewState> = new Map()
private _activeSavedViewId: number = null private _activeSavedViewId: number = null
@ -143,6 +145,10 @@ export class DocumentListViewService {
return this.listViewStates.get(this._activeSavedViewId) return this.listViewStates.get(this._activeSavedViewId)
} }
public cancelPending(): void {
this.unsubscribeNotifier.next(true)
}
activateSavedView(view: PaperlessSavedView) { activateSavedView(view: PaperlessSavedView) {
this.rangeSelectionAnchorIndex = this.lastRangeSelectionToIndex = null this.rangeSelectionAnchorIndex = this.lastRangeSelectionToIndex = null
if (view) { if (view) {
@ -210,6 +216,7 @@ export class DocumentListViewService {
} }
reload(onFinish?, updateQueryParams: boolean = true) { reload(onFinish?, updateQueryParams: boolean = true) {
this.cancelPending()
this.isReloading = true this.isReloading = true
this.error = null this.error = null
let activeListViewState = this.activeListViewState let activeListViewState = this.activeListViewState
@ -222,6 +229,7 @@ export class DocumentListViewService {
activeListViewState.filterRules, activeListViewState.filterRules,
{ truncate_content: true } { truncate_content: true }
) )
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: (result) => { next: (result) => {
this.initialized = true this.initialized = true

View File

@ -1,6 +1,7 @@
import { HttpClient } from '@angular/common/http' import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { first } from 'rxjs/operators' import { Subject } from 'rxjs'
import { first, takeUntil } from 'rxjs/operators'
import { import {
PaperlessTask, PaperlessTask,
PaperlessTaskStatus, PaperlessTaskStatus,
@ -14,10 +15,12 @@ import { environment } from 'src/environments/environment'
export class TasksService { export class TasksService {
private baseUrl: string = environment.apiBaseUrl private baseUrl: string = environment.apiBaseUrl
loading: boolean public loading: boolean
private fileTasks: PaperlessTask[] = [] private fileTasks: PaperlessTask[] = []
private unsubscribeNotifer: Subject<any> = new Subject()
public get total(): number { public get total(): number {
return this.fileTasks.length return this.fileTasks.length
} }
@ -51,7 +54,7 @@ export class TasksService {
this.http this.http
.get<PaperlessTask[]>(`${this.baseUrl}tasks/`) .get<PaperlessTask[]>(`${this.baseUrl}tasks/`)
.pipe(first()) .pipe(takeUntil(this.unsubscribeNotifer), first())
.subscribe((r) => { .subscribe((r) => {
this.fileTasks = r.filter((t) => t.type == PaperlessTaskType.File) // they're all File tasks, for now this.fileTasks = r.filter((t) => t.type == PaperlessTaskType.File) // they're all File tasks, for now
this.loading = false this.loading = false
@ -63,9 +66,13 @@ export class TasksService {
.post(`${this.baseUrl}acknowledge_tasks/`, { .post(`${this.baseUrl}acknowledge_tasks/`, {
tasks: [...task_ids], tasks: [...task_ids],
}) })
.pipe(first()) .pipe(takeUntil(this.unsubscribeNotifer), first())
.subscribe((r) => { .subscribe((r) => {
this.reload() this.reload()
}) })
} }
public cancelPending(): void {
this.unsubscribeNotifer.next(true)
}
} }