diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 5aa462d5e0e..d262bfd0d67 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -6,6 +6,7 @@ import { EffectsModule } from '@ngrx/effects'; import { Action, StoreConfig, StoreModule } from '@ngrx/store'; import { MyDSpaceGuard } from '../+my-dspace-page/my-dspace.guard'; +import { ProcessOutput } from '../process-page/processes/process-output.model'; import { isNotEmpty } from '../shared/empty.util'; import { FormBuilderService } from '../shared/form/builder/form-builder.service'; @@ -70,6 +71,7 @@ import { LookupRelationService } from './data/lookup-relation.service'; import { MappedCollectionsReponseParsingService } from './data/mapped-collections-reponse-parsing.service'; import { MyDSpaceResponseParsingService } from './data/mydspace-response-parsing.service'; import { ObjectUpdatesService } from './data/object-updates/object-updates.service'; +import { ProcessOutputDataService } from './data/process-output-data.service'; import { RelationshipTypeService } from './data/relationship-type.service'; import { RelationshipService } from './data/relationship.service'; import { ResourcePolicyService } from './resource-policy/resource-policy.service'; @@ -281,6 +283,7 @@ const PROVIDERS = [ ItemTypeDataService, WorkflowActionDataService, ProcessDataService, + ProcessOutputDataService, ScriptDataService, ProcessFilesResponseParsingService, FeatureDataService, @@ -347,6 +350,7 @@ export const models = ExternalSourceEntry, Script, Process, + ProcessOutput, Version, VersionHistory, WorkflowAction, diff --git a/src/app/core/data/process-output-data.service.ts b/src/app/core/data/process-output-data.service.ts new file mode 100644 index 00000000000..24f33a85b79 --- /dev/null +++ b/src/app/core/data/process-output-data.service.ts @@ -0,0 +1,72 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { ProcessOutput } from '../../process-page/processes/process-output.model'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { dataService } from '../cache/builders/build-decorators'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { CoreState } from '../core.reducers'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { PROCESS_OUTPUT_TYPE } from '../shared/process-output.resource-type'; +import { DataService } from './data.service'; +import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; +import { RemoteData } from './remote-data'; +import { RequestService } from './request.service'; + +/* tslint:disable:max-classes-per-file */ +/** + * A private DataService implementation to delegate specific methods to. + */ +class DataServiceImpl extends DataService { + protected linkPath = 'processes'; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer) { + super(); + } +} + +/** + * A service to retrieve output from processes from the REST API. + */ +@Injectable() +@dataService(PROCESS_OUTPUT_TYPE) +export class ProcessOutputDataService { + /** + * A private DataService instance to delegate specific methods to. + */ + private dataService: DataServiceImpl; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer) { + this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator); + } + + /** + * Returns an observable of {@link RemoteData} of a {@link ProcessOutput}, based on an href, with a list of {@link FollowLinkConfig}, + * to automatically resolve {@link HALLink}s of the {@link ProcessOutput} + * @param href The url of {@link ProcessOutput} we want to retrieve + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + findByHref(href: string, ...linksToFollow: Array>): Observable> { + return this.dataService.findByHref(href, ...linksToFollow); + } +} +/* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/shared/process-output.resource-type.ts b/src/app/core/shared/process-output.resource-type.ts new file mode 100644 index 00000000000..2e707d0bdad --- /dev/null +++ b/src/app/core/shared/process-output.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from './resource-type'; + +/** + * The resource type for ProcessOutput + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const PROCESS_OUTPUT_TYPE = new ResourceType('processOutput'); diff --git a/src/app/process-page/detail/process-detail.component.html b/src/app/process-page/detail/process-detail.component.html index 9cb1f1e6afa..e13770a0a38 100644 --- a/src/app/process-page/detail/process-detail.component.html +++ b/src/app/process-page/detail/process-detail.component.html @@ -34,9 +34,19 @@

{{'process.detail.title' | translate:{id: process?.proce
{{ process.processStatus }}
- - - + + + +
{{ (outputLogs$ | async)?.join('\n') }}
+

+ {{ 'process.detail.logs.none' | translate }} +

+
- {{'process.detail.back' | translate}} + diff --git a/src/app/process-page/detail/process-detail.component.spec.ts b/src/app/process-page/detail/process-detail.component.spec.ts index dff481fdc61..8d4a3f0e621 100644 --- a/src/app/process-page/detail/process-detail.component.spec.ts +++ b/src/app/process-page/detail/process-detail.component.spec.ts @@ -1,5 +1,7 @@ +import { ProcessOutputDataService } from '../../core/data/process-output-data.service'; +import { ProcessOutput } from '../processes/process-output.model'; import { ProcessDetailComponent } from './process-detail.component'; -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; import { VarDirective } from '../../shared/utils/var.directive'; import { TranslateModule } from '@ngx-translate/core'; import { RouterTestingModule } from '@angular/router/testing'; @@ -21,13 +23,20 @@ describe('ProcessDetailComponent', () => { let fixture: ComponentFixture; let processService: ProcessDataService; + let processOutputService: ProcessOutputDataService; let nameService: DSONameService; let process: Process; let fileName: string; let files: Bitstream[]; + let processOutput; + function init() { + processOutput = Object.assign(new ProcessOutput(), { + logs: ['Process started', 'Process completed'] + } + ); process = Object.assign(new Process(), { processId: 1, scriptName: 'script-name', @@ -40,7 +49,15 @@ describe('ProcessDetailComponent', () => { name: '-i', value: 'identifier' } - ] + ], + _links: { + self: { + href: 'https://rest.api/processes/1' + }, + output: { + href: 'https://rest.api/processes/1/output' + } + } }); fileName = 'fake-file-name'; files = [ @@ -62,6 +79,9 @@ describe('ProcessDetailComponent', () => { processService = jasmine.createSpyObj('processService', { getFiles: createSuccessfulRemoteDataObject$(createPaginatedList(files)) }); + processOutputService = jasmine.createSpyObj('processOutputService', { + findByHref: createSuccessfulRemoteDataObject$(processOutput) + }); nameService = jasmine.createSpyObj('nameService', { getName: fileName }); @@ -73,8 +93,12 @@ describe('ProcessDetailComponent', () => { declarations: [ProcessDetailComponent, ProcessDetailFieldComponent, VarDirective, FileSizePipe], imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], providers: [ - { provide: ActivatedRoute, useValue: { data: observableOf({ process: createSuccessfulRemoteDataObject(process) }) } }, + { + provide: ActivatedRoute, + useValue: { data: observableOf({ process: createSuccessfulRemoteDataObject(process) }) } + }, { provide: ProcessDataService, useValue: processService }, + { provide: ProcessOutputDataService, useValue: processOutputService }, { provide: DSONameService, useValue: nameService } ], schemas: [NO_ERRORS_SCHEMA] @@ -84,15 +108,16 @@ describe('ProcessDetailComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(ProcessDetailComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); it('should display the script\'s name', () => { + fixture.detectChanges(); const name = fixture.debugElement.query(By.css('#process-name')).nativeElement; expect(name.textContent).toContain(process.scriptName); }); it('should display the process\'s parameters', () => { + fixture.detectChanges(); const args = fixture.debugElement.query(By.css('#process-arguments')).nativeElement; process.parameters.forEach((param) => { expect(args.textContent).toContain(`${param.name} ${param.value}`) @@ -100,8 +125,60 @@ describe('ProcessDetailComponent', () => { }); it('should display the process\'s output files', () => { + fixture.detectChanges(); const processFiles = fixture.debugElement.query(By.css('#process-files')).nativeElement; expect(processFiles.textContent).toContain(fileName); }); + describe('if press show output logs', () => { + beforeEach(fakeAsync(() => { + spyOn(component, 'showProcessOutputLogs').and.callThrough(); + fixture.detectChanges(); + const showOutputButton = fixture.debugElement.query(By.css('#showOutputButton')); + showOutputButton.triggerEventHandler('click', { + preventDefault: () => {/**/ + } + }); + tick(); + })); + it('should trigger showProcessOutputLogs', () => { + expect(component.showProcessOutputLogs).toHaveBeenCalled(); + }); + it('should display the process\'s output logs', () => { + fixture.detectChanges(); + const outputProcess = fixture.debugElement.query(By.css('#process-output pre')); + expect(outputProcess.nativeElement.textContent).toContain('Process started'); + }); + }); + + describe('if press show output logs and process has no output logs (yet)', () => { + beforeEach(fakeAsync(() => { + jasmine.getEnv().allowRespy(true); + const emptyProcessOutput = Object.assign(new ProcessOutput(), { + logs: [] + }); + spyOn(processOutputService, 'findByHref').and.returnValue(createSuccessfulRemoteDataObject$(emptyProcessOutput)); + fixture = TestBed.createComponent(ProcessDetailComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + spyOn(component, 'showProcessOutputLogs').and.callThrough(); + fixture.detectChanges(); + const showOutputButton = fixture.debugElement.query(By.css('#showOutputButton')); + showOutputButton.triggerEventHandler('click', { + preventDefault: () => {/**/ + } + }); + tick(); + fixture.detectChanges(); + })); + it('should not display the process\'s output logs', () => { + const outputProcess = fixture.debugElement.query(By.css('#process-output pre')); + expect(outputProcess).toBeNull(); + }); + it('should display message saying there are no output logs', () => { + const noOutputProcess = fixture.debugElement.query(By.css('#no-output-logs-message')).nativeElement; + expect(noOutputProcess).toBeDefined(); + }); + }); + }); diff --git a/src/app/process-page/detail/process-detail.component.ts b/src/app/process-page/detail/process-detail.component.ts index b0e2c7e378c..f6b628f0f80 100644 --- a/src/app/process-page/detail/process-detail.component.ts +++ b/src/app/process-page/detail/process-detail.component.ts @@ -1,9 +1,13 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, NgZone, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; +import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; import { Observable } from 'rxjs/internal/Observable'; +import { ProcessOutputDataService } from '../../core/data/process-output-data.service'; import { RemoteData } from '../../core/data/remote-data'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { ProcessOutput } from '../processes/process-output.model'; import { Process } from '../processes/process.model'; -import { map, switchMap } from 'rxjs/operators'; +import { finalize, map, switchMap, take } from 'rxjs/operators'; import { getFirstSucceededRemoteDataPayload, redirectToPageNotFoundOn404 } from '../../core/shared/operators'; import { AlertType } from '../../shared/alert/aletr-type'; import { ProcessDataService } from '../../core/data/processes/process-data.service'; @@ -36,10 +40,26 @@ export class ProcessDetailComponent implements OnInit { */ filesRD$: Observable>>; + /** + * The Process's Output logs + */ + outputLogs$: Observable; + + /** + * Boolean on whether or not to show the output logs + */ + showOutputLogs = false; + /** + * When it's retrieving the output logs from backend, to show loading component + */ + retrievingOutputLogs$ = new BehaviorSubject(false); + constructor(protected route: ActivatedRoute, protected router: Router, protected processService: ProcessDataService, - protected nameService: DSONameService) { + protected processOutputService: ProcessOutputDataService, + protected nameService: DSONameService, + private zone: NgZone) { } /** @@ -63,7 +83,30 @@ export class ProcessDetailComponent implements OnInit { * @param bitstream */ getFileName(bitstream: Bitstream) { - return this.nameService.getName(bitstream); + return bitstream instanceof DSpaceObject ? this.nameService.getName(bitstream) : 'unknown'; + } + + /** + * Retrieves the process logs, while setting the loading subject to true. + * Sets the outputLogs when retrieved and sets the showOutputLogs boolean to show them and hide the button. + */ + showProcessOutputLogs() { + this.retrievingOutputLogs$.next(true); + this.zone.runOutsideAngular(() => { + const processOutputRD$: Observable> = this.processRD$.pipe( + getFirstSucceededRemoteDataPayload(), + switchMap((process: Process) => this.processOutputService.findByHref(process._links.output.href)) + ); + this.outputLogs$ = processOutputRD$.pipe( + getFirstSucceededRemoteDataPayload(), + map((processOutput: ProcessOutput) => { + this.showOutputLogs = true; + return processOutput.logs; + }), + finalize(() => this.zone.run(() => this.retrievingOutputLogs$.next(false))), + ) + }); + this.outputLogs$.pipe(take(1)).subscribe(); } } diff --git a/src/app/process-page/processes/process-output.model.ts b/src/app/process-page/processes/process-output.model.ts new file mode 100644 index 00000000000..4ae1731d26a --- /dev/null +++ b/src/app/process-page/processes/process-output.model.ts @@ -0,0 +1,36 @@ +import { PROCESS_OUTPUT_TYPE } from '../../core/shared/process-output.resource-type'; +import { CacheableObject } from '../../core/cache/object-cache.reducer'; +import { HALLink } from '../../core/shared/hal-link.model'; +import { autoserialize, deserialize } from 'cerialize'; +import { excludeFromEquals } from '../../core/utilities/equals.decorators'; +import { ResourceType } from '../../core/shared/resource-type'; +import { typedObject } from '../../core/cache/builders/build-decorators'; + +/** + * Object representing a process output object + */ +@typedObject +export class ProcessOutput implements CacheableObject { + static type = PROCESS_OUTPUT_TYPE; + + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The output strings for this ProcessOutput + */ + @autoserialize + logs: string[]; + + /** + * The {@link HALLink}s for this ProcessOutput + */ + @deserialize + _links: { + self: HALLink, + }; +} diff --git a/src/app/process-page/processes/process.model.ts b/src/app/process-page/processes/process.model.ts index 85de5337e78..891acb626dd 100644 --- a/src/app/process-page/processes/process.model.ts +++ b/src/app/process-page/processes/process.model.ts @@ -1,3 +1,5 @@ +import { PROCESS_OUTPUT_TYPE } from '../../core/shared/process-output.resource-type'; +import { ProcessOutput } from './process-output.model'; import { ProcessStatus } from './process-status.model'; import { ProcessParameter } from './process-parameter.model'; import { CacheableObject } from '../../core/cache/object-cache.reducer'; @@ -85,4 +87,11 @@ export class Process implements CacheableObject { */ @link(SCRIPT) script?: Observable>; + + /** + * The output logs created by this Process + * Will be undefined unless the output {@link HALLink} has been resolved. + */ + @link(PROCESS_OUTPUT_TYPE) + output?: Observable>; } diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index b6a7c7f7aaa..ea3e52f2ed4 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -2130,7 +2130,11 @@ "process.detail.output" : "Process Output", - "process.detail.output.alert" : "Work in progress - Process output is not available yet", + "process.detail.logs.button": "Retrieve process output", + + "process.detail.logs.loading": "Retrieving", + + "process.detail.logs.none": "This process has no output yet", "process.detail.output-files" : "Output Files",