Merge pull request #2228 from enea4science/feature/DURACOM-131

[DURACOM-131] Show output files in separate lines and a refresh spinner if status is running
This commit is contained in:
Tim Donohue
2023-06-06 13:52:28 -05:00
committed by GitHub
3 changed files with 199 additions and 31 deletions

View File

@@ -1,10 +1,15 @@
<div class="container" *ngVar="(processRD$ | async)?.payload as process"> <div class="container" *ngIf="(processRD$ | async)?.payload as process">
<div class="d-flex"> <div class="row">
<h2 class="flex-grow-1">{{'process.detail.title' | translate:{ <div class="col-10">
id: process?.processId, <h2 class="flex-grow-1">
name: process?.scriptName {{ 'process.detail.title' | translate:{ id: process?.processId, name: process?.scriptName } }}
} }}</h2> </h2>
</div> </div>
<div *ngIf="refreshCounter$ | async as seconds" class="col-2 refresh-counter">
Refreshing in {{ seconds }}s <i class="fas fa-sync-alt fa-spin"></i>
</div>
</div>
<ds-process-detail-field id="process-name" [title]="'process.detail.script'"> <ds-process-detail-field id="process-name" [title]="'process.detail.script'">
<div>{{ process?.scriptName }}</div> <div>{{ process?.scriptName }}</div>
</ds-process-detail-field> </ds-process-detail-field>
@@ -17,10 +22,12 @@
<div *ngVar="(filesRD$ | async)?.payload?.page as files"> <div *ngVar="(filesRD$ | async)?.payload?.page as files">
<ds-process-detail-field *ngIf="files && files?.length > 0" id="process-files" <ds-process-detail-field *ngIf="files && files?.length > 0" id="process-files"
[title]="'process.detail.output-files'"> [title]="'process.detail.output-files'">
<div class="d-flex flex-column">
<ds-themed-file-download-link *ngFor="let file of files; let last=last;" [bitstream]="file"> <ds-themed-file-download-link *ngFor="let file of files; let last=last;" [bitstream]="file">
<span>{{getFileName(file)}}</span> <span>{{getFileName(file)}}</span>
<span>({{(file?.sizeBytes) | dsFileSize }})</span> <span>({{(file?.sizeBytes) | dsFileSize }})</span>
</ds-themed-file-download-link> </ds-themed-file-download-link>
</div>
</ds-process-detail-field> </ds-process-detail-field>
</div> </div>
@@ -70,7 +77,7 @@
<ng-template #deleteModal > <ng-template #deleteModal >
<div *ngVar="(processRD$ | async)?.payload as process"> <div *ngIf="(processRD$ | async)?.payload as process">
<div class="modal-header"> <div class="modal-header">
<div> <div>

View File

@@ -35,6 +35,7 @@ import { NotificationsServiceStub } from '../../shared/testing/notifications-ser
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { NotificationsService } from '../../shared/notifications/notifications.service'; import { NotificationsService } from '../../shared/notifications/notifications.service';
import { getProcessListRoute } from '../process-page-routing.paths'; import { getProcessListRoute } from '../process-page-routing.paths';
import {ProcessStatus} from '../processes/process-status.model';
describe('ProcessDetailComponent', () => { describe('ProcessDetailComponent', () => {
let component: ProcessDetailComponent; let component: ProcessDetailComponent;
@@ -44,6 +45,7 @@ describe('ProcessDetailComponent', () => {
let nameService: DSONameService; let nameService: DSONameService;
let bitstreamDataService: BitstreamDataService; let bitstreamDataService: BitstreamDataService;
let httpClient: HttpClient; let httpClient: HttpClient;
let route: ActivatedRoute;
let process: Process; let process: Process;
let fileName: string; let fileName: string;
@@ -106,7 +108,8 @@ describe('ProcessDetailComponent', () => {
}); });
processService = jasmine.createSpyObj('processService', { processService = jasmine.createSpyObj('processService', {
getFiles: createSuccessfulRemoteDataObject$(createPaginatedList(files)), getFiles: createSuccessfulRemoteDataObject$(createPaginatedList(files)),
delete: createSuccessfulRemoteDataObject$(null) delete: createSuccessfulRemoteDataObject$(null),
findById: createSuccessfulRemoteDataObject$(process),
}); });
bitstreamDataService = jasmine.createSpyObj('bitstreamDataService', { bitstreamDataService = jasmine.createSpyObj('bitstreamDataService', {
findByHref: createSuccessfulRemoteDataObject$(logBitstream) findByHref: createSuccessfulRemoteDataObject$(logBitstream)
@@ -127,6 +130,13 @@ describe('ProcessDetailComponent', () => {
router = jasmine.createSpyObj('router', { router = jasmine.createSpyObj('router', {
navigateByUrl:{} navigateByUrl:{}
}); });
route = jasmine.createSpyObj('route', {
data: observableOf({ process: createSuccessfulRemoteDataObject(process) }),
snapshot: {
params: { id: process.processId }
}
});
} }
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
@@ -263,4 +273,92 @@ describe('ProcessDetailComponent', () => {
}); });
}); });
describe('refresh counter', () => {
const queryRefreshCounter = () => fixture.debugElement.query(By.css('.refresh-counter'));
describe('if process is completed', () => {
beforeEach(() => {
process.processStatus = ProcessStatus.COMPLETED;
route.data = observableOf({process: createSuccessfulRemoteDataObject(process)});
});
it('should not show', () => {
spyOn(component, 'startRefreshTimer');
const refreshCounter = queryRefreshCounter();
expect(refreshCounter).toBeNull();
expect(component.startRefreshTimer).not.toHaveBeenCalled();
});
});
describe('if process is not finished', () => {
beforeEach(() => {
process.processStatus = ProcessStatus.RUNNING;
route.data = observableOf({process: createSuccessfulRemoteDataObject(process)});
fixture.detectChanges();
component.stopRefreshTimer();
});
it('should call startRefreshTimer', () => {
spyOn(component, 'startRefreshTimer');
component.ngOnInit();
fixture.detectChanges(); // subscribe to process observable with async pipe
expect(component.startRefreshTimer).toHaveBeenCalled();
});
it('should call refresh method every 5 seconds, until process is completed', fakeAsync(() => {
spyOn(component, 'refresh');
spyOn(component, 'stopRefreshTimer');
process.processStatus = ProcessStatus.COMPLETED;
// set findbyId to return a completed process
(processService.findById as jasmine.Spy).and.returnValue(observableOf(createSuccessfulRemoteDataObject(process)));
component.ngOnInit();
fixture.detectChanges(); // subscribe to process observable with async pipe
expect(component.refresh).not.toHaveBeenCalled();
expect(component.refreshCounter$.value).toBe(0);
tick(1001); // 1 second + 1 ms by the setTimeout
expect(component.refreshCounter$.value).toBe(5); // 5 - 0
tick(2001); // 2 seconds + 1 ms by the setTimeout
expect(component.refreshCounter$.value).toBe(3); // 5 - 2
tick(2001); // 2 seconds + 1 ms by the setTimeout
expect(component.refreshCounter$.value).toBe(1); // 3 - 2
tick(1001); // 1 second + 1 ms by the setTimeout
expect(component.refreshCounter$.value).toBe(0); // 1 - 1
tick(1000); // 1 second
expect(component.refresh).toHaveBeenCalledTimes(1);
expect(component.stopRefreshTimer).toHaveBeenCalled();
expect(component.refreshCounter$.value).toBe(0);
tick(1001); // 1 second + 1 ms by the setTimeout
// startRefreshTimer not called again
expect(component.refreshCounter$.value).toBe(0);
discardPeriodicTasks(); // discard any periodic tasks that have not yet executed
}));
it('should show if refreshCounter is different from 0', () => {
component.refreshCounter$.next(1);
fixture.detectChanges();
const refreshCounter = queryRefreshCounter();
expect(refreshCounter).not.toBeNull();
});
});
});
}); });

View File

@@ -1,7 +1,7 @@
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Component, NgZone, OnInit } from '@angular/core'; import { Component, Inject, NgZone, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { BehaviorSubject, Observable } from 'rxjs'; import { BehaviorSubject, interval, Observable, shareReplay, Subscription } from 'rxjs';
import { finalize, map, switchMap, take, tap } from 'rxjs/operators'; import { finalize, map, switchMap, take, tap } from 'rxjs/operators';
import { AuthService } from '../../core/auth/auth.service'; import { AuthService } from '../../core/auth/auth.service';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
@@ -26,6 +26,8 @@ import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
import { getProcessListRoute } from '../process-page-routing.paths'; import { getProcessListRoute } from '../process-page-routing.paths';
import { NotificationsService } from '../../shared/notifications/notifications.service'; import { NotificationsService } from '../../shared/notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { followLink } from '../../shared/utils/follow-link-config.model';
import { isPlatformBrowser } from '@angular/common';
@Component({ @Component({
selector: 'ds-process-detail', selector: 'ds-process-detail',
@@ -34,7 +36,7 @@ import { TranslateService } from '@ngx-translate/core';
/** /**
* A component displaying detailed information about a DSpace Process * A component displaying detailed information about a DSpace Process
*/ */
export class ProcessDetailComponent implements OnInit { export class ProcessDetailComponent implements OnInit, OnDestroy {
/** /**
* The AlertType enumeration * The AlertType enumeration
@@ -65,23 +67,29 @@ export class ProcessDetailComponent implements OnInit {
/** /**
* Boolean on whether or not to show the output logs * Boolean on whether or not to show the output logs
*/ */
showOutputLogs; showOutputLogs = false;
/** /**
* When it's retrieving the output logs from backend, to show loading component * When it's retrieving the output logs from backend, to show loading component
*/ */
retrievingOutputLogs$: BehaviorSubject<boolean>; retrievingOutputLogs$ = new BehaviorSubject<boolean>(false);
/** /**
* Date format to use for start and end time of processes * Date format to use for start and end time of processes
*/ */
dateFormat = 'yyyy-MM-dd HH:mm:ss ZZZZ'; dateFormat = 'yyyy-MM-dd HH:mm:ss ZZZZ';
refreshCounter$ = new BehaviorSubject(0);
/** /**
* Reference to NgbModal * Reference to NgbModal
*/ */
protected modalRef: NgbModalRef; protected modalRef: NgbModalRef;
constructor(protected route: ActivatedRoute, private refreshTimerSub?: Subscription;
constructor(
@Inject(PLATFORM_ID) protected platformId: object,
protected route: ActivatedRoute,
protected router: Router, protected router: Router,
protected processService: ProcessDataService, protected processService: ProcessDataService,
protected bitstreamDataService: BitstreamDataService, protected bitstreamDataService: BitstreamDataService,
@@ -92,21 +100,25 @@ export class ProcessDetailComponent implements OnInit {
protected modalService: NgbModal, protected modalService: NgbModal,
protected notificationsService: NotificationsService, protected notificationsService: NotificationsService,
protected translateService: TranslateService protected translateService: TranslateService
) { ) {}
}
/** /**
* Initialize component properties * Initialize component properties
* Display a 404 if the process doesn't exist * Display a 404 if the process doesn't exist
*/ */
ngOnInit(): void { ngOnInit(): void {
this.showOutputLogs = false;
this.retrievingOutputLogs$ = new BehaviorSubject<boolean>(false);
this.processRD$ = this.route.data.pipe( this.processRD$ = this.route.data.pipe(
map((data) => { map((data) => {
if (isPlatformBrowser(this.platformId)) {
if (!this.isProcessFinished(data.process.payload)) {
this.startRefreshTimer();
}
}
return data.process as RemoteData<Process>; return data.process as RemoteData<Process>;
}), }),
redirectOn4xx(this.router, this.authService) redirectOn4xx(this.router, this.authService),
shareReplay(1)
); );
this.filesRD$ = this.processRD$.pipe( this.filesRD$ = this.processRD$.pipe(
@@ -115,6 +127,53 @@ export class ProcessDetailComponent implements OnInit {
); );
} }
refresh() {
this.processRD$ = this.processService.findById(
this.route.snapshot.params.id,
false,
true,
followLink('script')
).pipe(
getFirstSucceededRemoteData(),
redirectOn4xx(this.router, this.authService),
tap((processRemoteData: RemoteData<Process>) => {
if (!this.isProcessFinished(processRemoteData.payload)) {
this.startRefreshTimer();
}
}),
shareReplay(1)
);
this.filesRD$ = this.processRD$.pipe(
getFirstSucceededRemoteDataPayload(),
switchMap((process: Process) => this.processService.getFiles(process.processId))
);
}
startRefreshTimer() {
this.refreshCounter$.next(0);
this.refreshTimerSub = interval(1000).subscribe(
value => {
if (value > 5) {
setTimeout(() => {
this.refresh();
this.stopRefreshTimer();
this.refreshCounter$.next(0);
}, 1);
} else {
this.refreshCounter$.next(5 - value);
}
});
}
stopRefreshTimer() {
if (hasValue(this.refreshTimerSub)) {
this.refreshTimerSub.unsubscribe();
this.refreshTimerSub = undefined;
}
}
/** /**
* Get the name of a bitstream * Get the name of a bitstream
* @param bitstream * @param bitstream
@@ -210,6 +269,7 @@ export class ProcessDetailComponent implements OnInit {
openDeleteModal(content) { openDeleteModal(content) {
this.modalRef = this.modalService.open(content); this.modalRef = this.modalService.open(content);
} }
/** /**
* Close the modal. * Close the modal.
*/ */
@@ -217,4 +277,7 @@ export class ProcessDetailComponent implements OnInit {
this.modalRef.close(); this.modalRef.close();
} }
ngOnDestroy(): void {
this.stopRefreshTimer();
}
} }