diff --git a/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts index 4ff31d67cf..7c6d8918cb 100644 --- a/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts +++ b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts @@ -21,6 +21,10 @@ import { UploaderService } from '../../shared/uploader/uploader.service'; import { HostWindowService } from '../../shared/host-window.service'; import { HostWindowServiceStub } from '../../shared/testing/host-window-service.stub'; import { UploaderComponent } from '../../shared/uploader/uploader.component'; +import { HttpXsrfTokenExtractor } from '@angular/common/http'; +import { CookieService } from '../../core/services/cookie.service'; +import { CookieServiceMock } from '../../shared/mocks/cookie.service.mock'; +import { HttpXsrfTokenExtractorMock } from '../../shared/mocks/http-xsrf-token-extractor.mock'; describe('MyDSpaceNewSubmissionComponent test', () => { @@ -55,6 +59,8 @@ describe('MyDSpaceNewSubmissionComponent test', () => { ChangeDetectorRef, MyDSpaceNewSubmissionComponent, UploaderService, + { provide: HttpXsrfTokenExtractor, useValue: new HttpXsrfTokenExtractorMock('mock-token') }, + { provide: CookieService, useValue: new CookieServiceMock() }, { provide: HostWindowService, useValue: new HostWindowServiceStub(800) }, ], schemas: [NO_ERRORS_SCHEMA] diff --git a/src/app/+my-dspace-page/my-dspace-page.component.html b/src/app/+my-dspace-page/my-dspace-page.component.html index 6f1cc41a1e..55d1e304d0 100644 --- a/src/app/+my-dspace-page/my-dspace-page.component.html +++ b/src/app/+my-dspace-page/my-dspace-page.component.html @@ -6,6 +6,7 @@ [configurationList]="(configurationList$ | async)" [resultCount]="(resultsRD$ | async)?.payload.totalElements" [viewModeList]="viewModeList" + [refreshFilters]="refreshFilters.asObservable()" [inPlaceSearch]="inPlaceSearch">
@@ -39,7 +41,8 @@
+ [context]="context$ | async" + (contentChange)="onResultsContentChange()">
diff --git a/src/app/+my-dspace-page/my-dspace-page.component.ts b/src/app/+my-dspace-page/my-dspace-page.component.ts index 0f08795cdc..5ee2a47d9f 100644 --- a/src/app/+my-dspace-page/my-dspace-page.component.ts +++ b/src/app/+my-dspace-page/my-dspace-page.component.ts @@ -7,7 +7,7 @@ import { OnInit } from '@angular/core'; -import { BehaviorSubject, Observable, Subscription } from 'rxjs'; +import { BehaviorSubject, Observable, Subject, Subscription } from 'rxjs'; import { map, switchMap, tap, } from 'rxjs/operators'; import { PaginatedList } from '../core/data/paginated-list.model'; @@ -101,6 +101,11 @@ export class MyDSpacePageComponent implements OnInit { */ context$: Observable; + /** + * Emit an event every time search sidebars must refresh their contents. + */ + refreshFilters: Subject = new Subject(); + constructor(private service: SearchService, private sidebarService: SidebarService, private windowService: HostWindowService, @@ -148,6 +153,14 @@ export class MyDSpacePageComponent implements OnInit { } + /** + * Handle the contentChange event from within the my dspace content. + * Notify search sidebars to refresh their content. + */ + onResultsContentChange() { + this.refreshFilters.next(); + } + /** * Set the sidebar to a collapsed state */ @@ -184,5 +197,6 @@ export class MyDSpacePageComponent implements OnInit { if (hasValue(this.sub)) { this.sub.unsubscribe(); } + this.refreshFilters.complete(); } } diff --git a/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.html b/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.html index 3a829e6ece..2710285f0d 100644 --- a/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.html +++ b/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.html @@ -5,7 +5,8 @@ [sortConfig]="searchConfig.sort" [objects]="searchResults" [hideGear]="true" - [context]="context"> + [context]="context" + (contentChange)="contentChange.emit()"> diff --git a/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.ts b/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.ts index 35b13c8bae..32b6d9c9f7 100644 --- a/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.ts +++ b/src/app/+my-dspace-page/my-dspace-results/my-dspace-results.component.ts @@ -1,4 +1,4 @@ -import { Component, Input } from '@angular/core'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; import { RemoteData } from '../../core/data/remote-data'; import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { fadeIn, fadeInOut } from '../../shared/animations/fade'; @@ -41,6 +41,12 @@ export class MyDSpaceResultsComponent { * The current context for the search results */ @Input() context: Context; + + /** + * Emit when one of the results has changed. + */ + @Output() contentChange = new EventEmitter(); + /** * A boolean representing if search results entry are separated by a line */ diff --git a/src/app/+my-dspace-page/my-dspace-search.module.ts b/src/app/+my-dspace-page/my-dspace-search.module.ts index 2fe1cd2a55..a97f2207e7 100644 --- a/src/app/+my-dspace-page/my-dspace-search.module.ts +++ b/src/app/+my-dspace-page/my-dspace-search.module.ts @@ -14,12 +14,16 @@ import { ClaimedTaskSearchResultDetailElementComponent } from '../shared/object- import { ItemSearchResultListElementSubmissionComponent } from '../shared/object-list/my-dspace-result-list-element/item-search-result/item-search-result-list-element-submission.component'; import { WorkflowItemSearchResultListElementComponent } from '../shared/object-list/my-dspace-result-list-element/workflow-item-search-result/workflow-item-search-result-list-element.component'; import { PoolSearchResultDetailElementComponent } from '../shared/object-detail/my-dspace-result-detail-element/pool-search-result/pool-search-result-detail-element.component'; +import { ClaimedApprovedSearchResultListElementComponent } from '../shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-approved-search-result/claimed-approved-search-result-list-element.component'; +import { ClaimedDeclinedSearchResultListElementComponent } from '../shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-declined-search-result/claimed-declined-search-result-list-element.component'; const ENTRY_COMPONENTS = [ // put only entry components that use custom decorator WorkspaceItemSearchResultListElementComponent, WorkflowItemSearchResultListElementComponent, ClaimedSearchResultListElementComponent, + ClaimedApprovedSearchResultListElementComponent, + ClaimedDeclinedSearchResultListElementComponent, PoolSearchResultListElementComponent, ItemSearchResultDetailElementComponent, WorkspaceItemSearchResultDetailElementComponent, diff --git a/src/app/core/tasks/claimed-task-data.service.spec.ts b/src/app/core/tasks/claimed-task-data.service.spec.ts index 98a0f5f51e..ab9727592e 100644 --- a/src/app/core/tasks/claimed-task-data.service.spec.ts +++ b/src/app/core/tasks/claimed-task-data.service.spec.ts @@ -8,9 +8,16 @@ import { RemoteDataBuildService } from '../cache/builders/remote-data-build.serv import { NotificationsService } from '../../shared/notifications/notifications.service'; import { CoreState } from '../core.reducers'; import { ClaimedTaskDataService } from './claimed-task-data.service'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { FindListOptions } from '../data/request.models'; +import { RequestParam } from '../cache/models/request-param.model'; +import { getTestScheduler } from 'jasmine-marbles'; +import { TestScheduler } from 'rxjs/testing'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; describe('ClaimedTaskDataService', () => { + let scheduler: TestScheduler; let service: ClaimedTaskDataService; let options: HttpOptions; const taskEndpoint = 'https://rest.api/task'; @@ -45,6 +52,7 @@ describe('ClaimedTaskDataService', () => { } beforeEach(() => { + scheduler = getTestScheduler(); service = initTestService(); options = Object.create({}); let headers = new HttpHeaders(); @@ -68,6 +76,24 @@ describe('ClaimedTaskDataService', () => { }); }); + describe('claimTask', () => { + + it('should call postToEndpoint method', () => { + + spyOn(service, 'postToEndpoint').and.returnValue(observableOf(null)); + + scheduler.schedule(() => service.claimTask('scopeId', 'poolTaskHref').subscribe()); + scheduler.flush(); + + const postToEndpointOptions: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'text/uri-list'); + postToEndpointOptions.headers = headers; + + expect(service.postToEndpoint).toHaveBeenCalledWith(linkPath, 'poolTaskHref', null, postToEndpointOptions); + }); + }); + describe('returnToPoolTask', () => { it('should call deleteById method', () => { const scopeId = '1234'; @@ -79,4 +105,21 @@ describe('ClaimedTaskDataService', () => { expect(service.deleteById).toHaveBeenCalledWith(linkPath, scopeId, options); }); }); + + describe('findByItem', () => { + + it('should call searchTask method', () => { + spyOn((service as any), 'searchTask').and.returnValue(observableOf(createSuccessfulRemoteDataObject$({}))); + + scheduler.schedule(() => service.findByItem('a0db0fde-1d12-4d43-bd0d-0f43df8d823c').subscribe()); + scheduler.flush(); + + const findListOptions = new FindListOptions(); + findListOptions.searchParams = [ + new RequestParam('uuid', 'a0db0fde-1d12-4d43-bd0d-0f43df8d823c') + ]; + + expect(service.searchTask).toHaveBeenCalledWith('findByItem', findListOptions); + }); + }); }); diff --git a/src/app/core/tasks/claimed-task-data.service.ts b/src/app/core/tasks/claimed-task-data.service.ts index 5815dad6e5..9cfd5a44d6 100644 --- a/src/app/core/tasks/claimed-task-data.service.ts +++ b/src/app/core/tasks/claimed-task-data.service.ts @@ -1,4 +1,4 @@ -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; @@ -15,6 +15,11 @@ import { ClaimedTask } from './models/claimed-task-object.model'; import { CLAIMED_TASK } from './models/claimed-task-object.resource-type'; import { ProcessTaskResponse } from './models/process-task-response'; import { TasksService } from './tasks.service'; +import { RemoteData } from '../data/remote-data'; +import { FindListOptions } from '../data/request.models'; +import { RequestParam } from '../cache/models/request-param.model'; +import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { getFirstSucceededRemoteData } from '../shared/operators'; /** * The service handling all REST requests for ClaimedTask @@ -23,7 +28,7 @@ import { TasksService } from './tasks.service'; @dataService(CLAIMED_TASK) export class ClaimedTaskDataService extends TasksService { - protected responseMsToLive = 10 * 1000; + protected responseMsToLive = 1000; /** * The endpoint link name @@ -54,6 +59,24 @@ export class ClaimedTaskDataService extends TasksService { super(); } + /** + * Make a request to claim the given task + * + * @param scopeId + * The task id + * @param poolTaskHref + * The pool task Href + * @return {Observable} + * Emit the server response + */ + public claimTask(scopeId: string, poolTaskHref: string): Observable { + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'text/uri-list'); + options.headers = headers; + return this.postToEndpoint(this.linkPath, poolTaskHref, null, options); + } + /** * Make a request for the given task * @@ -80,4 +103,19 @@ export class ClaimedTaskDataService extends TasksService { return this.deleteById(this.linkPath, scopeId, this.makeHttpOptions()); } + /** + * Search a claimed task by item uuid. + * @param uuid + * The item uuid + * @return {Observable>} + * The server response + */ + public findByItem(uuid: string): Observable> { + const options = new FindListOptions(); + options.searchParams = [ + new RequestParam('uuid', uuid) + ]; + return this.searchTask('findByItem', options).pipe(getFirstSucceededRemoteData()); + } + } diff --git a/src/app/core/tasks/pool-task-data.service.spec.ts b/src/app/core/tasks/pool-task-data.service.spec.ts index 75255d3e0a..7279c96e5c 100644 --- a/src/app/core/tasks/pool-task-data.service.spec.ts +++ b/src/app/core/tasks/pool-task-data.service.spec.ts @@ -8,9 +8,16 @@ import { RemoteDataBuildService } from '../cache/builders/remote-data-build.serv import { NotificationsService } from '../../shared/notifications/notifications.service'; import { CoreState } from '../core.reducers'; import { PoolTaskDataService } from './pool-task-data.service'; +import { getTestScheduler } from 'jasmine-marbles'; +import { TestScheduler } from 'rxjs/testing'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { FindListOptions } from '../data/request.models'; +import { RequestParam } from '../cache/models/request-param.model'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; describe('PoolTaskDataService', () => { + let scheduler: TestScheduler; let service: PoolTaskDataService; let options: HttpOptions; const taskEndpoint = 'https://rest.api/task'; @@ -45,6 +52,7 @@ describe('PoolTaskDataService', () => { } beforeEach(() => { + scheduler = getTestScheduler(); service = initTestService(); options = Object.create({}); let headers = new HttpHeaders(); @@ -52,14 +60,33 @@ describe('PoolTaskDataService', () => { options.headers = headers; }); - describe('claimTask', () => { + describe('findByItem', () => { - it('should call postToEndpoint method', () => { - spyOn(service, 'postToEndpoint'); - const scopeId = '1234'; - service.claimTask(scopeId); + it('should call searchTask method', () => { + spyOn((service as any), 'searchTask').and.returnValue(observableOf(createSuccessfulRemoteDataObject$({}))); + + scheduler.schedule(() => service.findByItem('a0db0fde-1d12-4d43-bd0d-0f43df8d823c').subscribe()); + scheduler.flush(); + + const findListOptions = new FindListOptions(); + findListOptions.searchParams = [ + new RequestParam('uuid', 'a0db0fde-1d12-4d43-bd0d-0f43df8d823c') + ]; + + expect(service.searchTask).toHaveBeenCalledWith('findByItem', findListOptions); + }); + }); + + describe('getPoolTaskEndpointById', () => { + + it('should call getEndpointById method', () => { + spyOn(service, 'getEndpointById').and.returnValue(observableOf(null)); + + scheduler.schedule(() => service.getPoolTaskEndpointById('a0db0fde-1d12-4d43-bd0d-0f43df8d823c').subscribe()); + scheduler.flush(); + + expect(service.getEndpointById).toHaveBeenCalledWith('a0db0fde-1d12-4d43-bd0d-0f43df8d823c'); - expect(service.postToEndpoint).toHaveBeenCalledWith(linkPath, {}, scopeId, options); }); }); }); diff --git a/src/app/core/tasks/pool-task-data.service.ts b/src/app/core/tasks/pool-task-data.service.ts index f08274b5f1..d44e402e7f 100644 --- a/src/app/core/tasks/pool-task-data.service.ts +++ b/src/app/core/tasks/pool-task-data.service.ts @@ -13,8 +13,11 @@ import { RequestService } from '../data/request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { PoolTask } from './models/pool-task-object.model'; import { POOL_TASK } from './models/pool-task-object.resource-type'; -import { ProcessTaskResponse } from './models/process-task-response'; import { TasksService } from './tasks.service'; +import { RemoteData } from '../data/remote-data'; +import { FindListOptions } from '../data/request.models'; +import { RequestParam } from '../cache/models/request-param.model'; +import { getFirstCompletedRemoteData } from '../shared/operators'; /** * The service handling all REST requests for PoolTask @@ -28,7 +31,7 @@ export class PoolTaskDataService extends TasksService { */ protected linkPath = 'pooltasks'; - protected responseMsToLive = 10 * 1000; + protected responseMsToLive = 1000; /** * Initialize instance variables @@ -56,14 +59,30 @@ export class PoolTaskDataService extends TasksService { } /** - * Make a request to claim the given task - * - * @param scopeId - * The task id - * @return {Observable} - * Emit the server response + * Search a pool task by item uuid. + * @param uuid + * The item uuid + * @return {Observable>} + * The server response */ - public claimTask(scopeId: string): Observable { - return this.postToEndpoint(this.linkPath, {}, scopeId, this.makeHttpOptions()); + public findByItem(uuid: string): Observable> { + const options = new FindListOptions(); + options.searchParams = [ + new RequestParam('uuid', uuid) + ]; + return this.searchTask('findByItem', options).pipe(getFirstCompletedRemoteData()); } + + /** + * Get the Href of the pool task + * + * @param poolTaskId + * the poolTask id + * @return {Observable>} + * the Href + */ + public getPoolTaskEndpointById(poolTaskId): Observable { + return this.getEndpointById(poolTaskId); + } + } diff --git a/src/app/core/tasks/tasks.service.spec.ts b/src/app/core/tasks/tasks.service.spec.ts index c6f965b79f..f0c86d2abf 100644 --- a/src/app/core/tasks/tasks.service.spec.ts +++ b/src/app/core/tasks/tasks.service.spec.ts @@ -4,7 +4,7 @@ import { TestScheduler } from 'rxjs/testing'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { TasksService } from './tasks.service'; import { RequestService } from '../data/request.service'; -import { TaskDeleteRequest, TaskPostRequest } from '../data/request.models'; +import { FindListOptions, TaskDeleteRequest, TaskPostRequest } from '../data/request.models'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { TaskObject } from './models/task-object.model'; @@ -17,8 +17,11 @@ import { HttpClient, HttpHeaders } from '@angular/common/http'; import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; import { ChangeAnalyzer } from '../data/change-analyzer'; import { compare, Operation } from 'fast-json-patch'; +import { of as observableOf } from 'rxjs/internal/observable/of'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; +import { of } from 'rxjs'; const LINK_NAME = 'test'; @@ -59,7 +62,7 @@ describe('TasksService', () => { const requestService = getMockRequestService(); const halService: any = new HALEndpointServiceStub(taskEndpoint); const rdbService = getMockRemoteDataBuildService(); - const notificationsService = {} as NotificationsService; + const notificationsService = new NotificationsServiceStub() as any; const http = {} as HttpClient; const comparator = new DummyChangeAnalyzer() as any; const objectCache = { @@ -118,4 +121,38 @@ describe('TasksService', () => { }); }); + describe('searchTask', () => { + + it('should call findByHref with the href generated by getSearchByHref', () => { + + spyOn(service, 'getSearchByHref').and.returnValue(observableOf('generatedHref')); + spyOn(service, 'findByHref').and.returnValue(of(null)); + + const followLinks = {}; + const options = new FindListOptions(); + options.searchParams = []; + + scheduler.schedule(() => service.searchTask('method', options, followLinks as any).subscribe()); + scheduler.flush(); + + expect(service.getSearchByHref).toHaveBeenCalledWith('method', options, followLinks as any); + expect(service.findByHref).toHaveBeenCalledWith('generatedHref', false, true); + }); + }); + + describe('getEndpointById', () => { + + it('should call halService.getEndpoint and then getEndpointByIDHref', () => { + + spyOn(halService, 'getEndpoint').and.returnValue(observableOf('generatedHref')); + spyOn(service, 'getEndpointByIDHref').and.returnValue(null); + + scheduler.schedule(() => service.getEndpointById('scopeId').subscribe()); + scheduler.flush(); + + expect(halService.getEndpoint).toHaveBeenCalledWith(service.getLinkPath()); + expect(service.getEndpointByIDHref).toHaveBeenCalledWith('generatedHref', 'scopeId'); + }); + }); + }); diff --git a/src/app/core/tasks/tasks.service.ts b/src/app/core/tasks/tasks.service.ts index 8f337b7bd2..f23c71e65e 100644 --- a/src/app/core/tasks/tasks.service.ts +++ b/src/app/core/tasks/tasks.service.ts @@ -1,43 +1,29 @@ import { HttpHeaders } from '@angular/common/http'; import { Observable } from 'rxjs'; -import { distinctUntilChanged, filter, map, mergeMap, tap } from 'rxjs/operators'; +import { distinctUntilChanged, filter, find, map, mergeMap, tap } from 'rxjs/operators'; import { DataService } from '../data/data.service'; -import { DeleteRequest, PostRequest, TaskDeleteRequest, TaskPostRequest } from '../data/request.models'; -import { isNotEmpty } from '../../shared/empty.util'; +import { + DeleteRequest, + FindListOptions, + PostRequest, + TaskDeleteRequest, + TaskPostRequest +} from '../data/request.models'; +import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { ProcessTaskResponse } from './models/process-task-response'; -import { getFirstCompletedRemoteData } from '../shared/operators'; +import { getAllCompletedRemoteData, getFirstCompletedRemoteData } from '../shared/operators'; import { CacheableObject } from '../cache/object-cache.reducer'; import { RemoteData } from '../data/remote-data'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; /** * An abstract class that provides methods to handle task requests. */ export abstract class TasksService extends DataService { - /** - * Fetch a RestRequest - * - * @param requestId - * The base endpoint for the type of object - * @return Observable - * server response - */ - protected fetchRequest(requestId: string): Observable { - return this.rdbService.buildFromRequestUUID(requestId).pipe( - getFirstCompletedRemoteData(), - map((response: RemoteData) => { - if (response.hasFailed) { - return new ProcessTaskResponse(false, response.statusCode, response.errorMessage); - } else { - return new ProcessTaskResponse(true, response.statusCode); - } - }) - ); - } - /** * Create the HREF for a specific submission object based on its identifier * @@ -46,7 +32,7 @@ export abstract class TasksService extends DataServic * @param resourceID * The identifier for the object */ - protected getEndpointByIDHref(endpoint, resourceID): string { + getEndpointByIDHref(endpoint, resourceID): string { return isNotEmpty(resourceID) ? `${endpoint}/${resourceID}` : `${endpoint}`; } @@ -90,16 +76,67 @@ export abstract class TasksService extends DataServic */ public deleteById(linkPath: string, scopeId: string, options?: HttpOptions): Observable { const requestId = this.requestService.generateRequestId(); - return this.halService.getEndpoint(linkPath || this.linkPath).pipe( - filter((href: string) => isNotEmpty(href)), - distinctUntilChanged(), - map((endpointURL: string) => this.getEndpointByIDHref(endpointURL, scopeId)), + return this.getEndpointById(scopeId, linkPath).pipe( map((endpointURL: string) => new TaskDeleteRequest(requestId, endpointURL, null, options)), tap((request: DeleteRequest) => this.requestService.send(request)), mergeMap((request: DeleteRequest) => this.fetchRequest(requestId)), distinctUntilChanged()); } + /** + * Get the endpoint of a task by scopeId. + * @param linkPath + * @param scopeId + */ + public getEndpointById(scopeId: string, linkPath?: string): Observable { + return this.halService.getEndpoint(linkPath || this.linkPath).pipe( + filter((href: string) => isNotEmpty(href)), + distinctUntilChanged(), + map((endpointURL: string) => this.getEndpointByIDHref(endpointURL, scopeId))); + } + + /** + * Search a task. + * @param searchMethod + * the search method + * @param options + * the find list options + * @param linksToFollow + * links to follow + */ + public searchTask(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig[]): Observable> { + const hrefObs = this.getSearchByHref(searchMethod, options, ...linksToFollow); + return hrefObs.pipe( + find((href: string) => hasValue(href)), + mergeMap((href) => this.findByHref(href, false, true).pipe( + getAllCompletedRemoteData(), + filter((rd: RemoteData) => !rd.isSuccessStale), + tap(() => this.requestService.setStaleByHrefSubstring(href))) + ) + ); + } + + /** + * Fetch a RestRequest + * + * @param requestId + * The base endpoint for the type of object + * @return Observable + * server response + */ + protected fetchRequest(requestId: string): Observable { + return this.rdbService.buildFromRequestUUID(requestId).pipe( + getFirstCompletedRemoteData(), + map((response: RemoteData) => { + if (response.hasFailed) { + return new ProcessTaskResponse(false, response.statusCode, response.errorMessage); + } else { + return new ProcessTaskResponse(true, response.statusCode); + } + }) + ); + } + /** * Create a new HttpOptions */ diff --git a/src/app/core/xsrf/xsrf.interceptor.spec.ts b/src/app/core/xsrf/xsrf.interceptor.spec.ts index 84f10b9e13..742c4a4a45 100644 --- a/src/app/core/xsrf/xsrf.interceptor.spec.ts +++ b/src/app/core/xsrf/xsrf.interceptor.spec.ts @@ -1,32 +1,20 @@ import { TestBed } from '@angular/core/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; -import { HttpHeaders, HTTP_INTERCEPTORS, HttpResponse, HttpXsrfTokenExtractor, HttpErrorResponse } from '@angular/common/http'; +import { HttpHeaders, HTTP_INTERCEPTORS, HttpXsrfTokenExtractor } from '@angular/common/http'; import { DspaceRestService } from '../dspace-rest/dspace-rest.service'; import { RestRequestMethod } from '../data/rest-request-method'; import { CookieService } from '../services/cookie.service'; import { CookieServiceMock } from '../../shared/mocks/cookie.service.mock'; import { XsrfInterceptor } from './xsrf.interceptor'; - -/** - * A Mock TokenExtractor which just returns whatever token it is initialized with. - * This mock object is injected into our XsrfInterceptor, so that it always finds - * the same fake XSRF token. - */ -class MockTokenExtractor extends HttpXsrfTokenExtractor { - constructor(private token: string | null) { super(); } - - getToken(): string | null { return this.token; } -} +import { HttpXsrfTokenExtractorMock } from '../../shared/mocks/http-xsrf-token-extractor.mock'; describe(`XsrfInterceptor`, () => { let service: DspaceRestService; let httpMock: HttpTestingController; let cookieService: CookieService; - // Create a MockTokenExtractor which always returns "test-token". This will - // be used as the test HttpXsrfTokenExtractor, see below. + // mock XSRF token const testToken = 'test-token'; - const mockTokenExtractor = new MockTokenExtractor(testToken); // Mock payload/statuses are dummy content as we are not testing the results // of any below requests. We are only testing for X-XSRF-TOKEN header. @@ -46,7 +34,7 @@ describe(`XsrfInterceptor`, () => { useClass: XsrfInterceptor, multi: true, }, - { provide: HttpXsrfTokenExtractor, useValue: mockTokenExtractor }, + { provide: HttpXsrfTokenExtractor, useValue: new HttpXsrfTokenExtractorMock(testToken) }, { provide: CookieService, useValue: new CookieServiceMock() } ], }); diff --git a/src/app/core/xsrf/xsrf.interceptor.ts b/src/app/core/xsrf/xsrf.interceptor.ts index 7b5a66f27a..0301a70994 100644 --- a/src/app/core/xsrf/xsrf.interceptor.ts +++ b/src/app/core/xsrf/xsrf.interceptor.ts @@ -6,6 +6,13 @@ import { RESTURLCombiner } from '../url-combiner/rest-url-combiner'; import { CookieService } from '../services/cookie.service'; import { throwError } from 'rxjs'; +// Name of XSRF header we may send in requests to backend (this is a standard name defined by Angular) +export const XSRF_REQUEST_HEADER = 'X-XSRF-TOKEN'; +// Name of XSRF header we may receive in responses from backend +export const XSRF_RESPONSE_HEADER = 'DSPACE-XSRF-TOKEN'; +// Name of cookie where we store the XSRF token +export const XSRF_COOKIE = 'XSRF-TOKEN'; + /** * Custom Http Interceptor intercepting Http Requests & Responses to * exchange XSRF/CSRF tokens with the backend. @@ -43,11 +50,6 @@ export class XsrfInterceptor implements HttpInterceptor { * @param next */ intercept(req: HttpRequest, next: HttpHandler): Observable> { - // Name of XSRF header we may send in requests to backend (this is a standard name defined by Angular) - const requestCsrfHeader = 'X-XSRF-TOKEN'; - // Name of XSRF header we may receive in responses from backend - const responseCsrfHeader = 'DSPACE-XSRF-TOKEN'; - // Ensure EVERY request from Angular includes "withCredentials: true". // This allows Angular to receive & send cookies via a CORS request (to // the backend). ONLY requests with credentials will: @@ -71,8 +73,8 @@ export class XsrfInterceptor implements HttpInterceptor { const token = this.tokenExtractor.getToken() as string; // send token in request's X-XSRF-TOKEN header (anti-CSRF security) to backend - if (token !== null && !req.headers.has(requestCsrfHeader)) { - req = req.clone({ headers: req.headers.set(requestCsrfHeader, token) }); + if (token !== null && !req.headers.has(XSRF_REQUEST_HEADER)) { + req = req.clone({ headers: req.headers.set(XSRF_REQUEST_HEADER, token) }); } } // Pass to next interceptor, but intercept EVERY response event as well @@ -82,9 +84,9 @@ export class XsrfInterceptor implements HttpInterceptor { if (response instanceof HttpResponse) { // For every response that comes back, check for the custom // DSPACE-XSRF-TOKEN header sent from the backend. - if (response.headers.has(responseCsrfHeader)) { + if (response.headers.has(XSRF_RESPONSE_HEADER)) { // value of header is a new XSRF token - this.saveXsrfToken(response.headers.get(responseCsrfHeader)); + this.saveXsrfToken(response.headers.get(XSRF_RESPONSE_HEADER)); } } }), @@ -92,9 +94,9 @@ export class XsrfInterceptor implements HttpInterceptor { if (error instanceof HttpErrorResponse) { // For every error that comes back, also check for the custom // DSPACE-XSRF-TOKEN header sent from the backend. - if (error.headers.has(responseCsrfHeader)) { + if (error.headers.has(XSRF_RESPONSE_HEADER)) { // value of header is a new XSRF token - this.saveXsrfToken(error.headers.get(responseCsrfHeader)); + this.saveXsrfToken(error.headers.get(XSRF_RESPONSE_HEADER)); } } // Return error response as is. @@ -111,7 +113,7 @@ export class XsrfInterceptor implements HttpInterceptor { // Save token value as a *new* value of our client-side XSRF-TOKEN cookie. // This is the cookie that is parsed by Angular's tokenExtractor(), // which we will send back in the X-XSRF-TOKEN header per Angular best practices. - this.cookieService.remove('XSRF-TOKEN'); - this.cookieService.set('XSRF-TOKEN', token); + this.cookieService.remove(XSRF_COOKIE); + this.cookieService.set(XSRF_COOKIE, token); } } diff --git a/src/app/shared/mocks/http-xsrf-token-extractor.mock.ts b/src/app/shared/mocks/http-xsrf-token-extractor.mock.ts new file mode 100644 index 0000000000..78766a0b31 --- /dev/null +++ b/src/app/shared/mocks/http-xsrf-token-extractor.mock.ts @@ -0,0 +1,12 @@ +import { HttpXsrfTokenExtractor } from '@angular/common/http'; + +/** + * A Mock TokenExtractor which just returns whatever token it is initialized with. + * This mock object is injected into our XsrfInterceptor, so that it always finds + * the same fake XSRF token. + */ +export class HttpXsrfTokenExtractorMock extends HttpXsrfTokenExtractor { + constructor(private token: string | null) { super(); } + + getToken(): string | null { return this.token; } +} diff --git a/src/app/shared/mydspace-actions/claimed-task/abstract/claimed-task-actions-abstract.component.ts b/src/app/shared/mydspace-actions/claimed-task/abstract/claimed-task-actions-abstract.component.ts index da03fbc3ad..fb23b4feb1 100644 --- a/src/app/shared/mydspace-actions/claimed-task/abstract/claimed-task-actions-abstract.component.ts +++ b/src/app/shared/mydspace-actions/claimed-task/abstract/claimed-task-actions-abstract.component.ts @@ -1,8 +1,20 @@ -import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { Component, Injector, OnDestroy } from '@angular/core'; import { ClaimedTask } from '../../../../core/tasks/models/claimed-task-object.model'; -import { BehaviorSubject } from 'rxjs'; import { ClaimedTaskDataService } from '../../../../core/tasks/claimed-task-data.service'; -import { ProcessTaskResponse } from '../../../../core/tasks/models/process-task-response'; +import { DSpaceObject} from '../../../../core/shared/dspace-object.model'; +import { Router} from '@angular/router'; +import { NotificationsService} from '../../../notifications/notifications.service'; +import { TranslateService} from '@ngx-translate/core'; +import { SearchService} from '../../../../core/shared/search/search.service'; +import { RequestService} from '../../../../core/data/request.service'; +import { Observable} from 'rxjs'; +import { RemoteData} from '../../../../core/data/remote-data'; +import { WorkflowItem} from '../../../../core/submission/models/workflowitem.model'; +import { switchMap, take } from 'rxjs/operators'; +import { CLAIMED_TASK } from '../../../../core/tasks/models/claimed-task-object.resource-type'; +import { getFirstSucceededRemoteDataPayload } from '../../../../core/shared/operators'; +import { Item } from '../../../../core/shared/item.model'; +import { MyDSpaceReloadableActionsComponent } from '../../mydspace-reloadable-actions'; /** * Abstract component for rendering a claimed task's action @@ -12,31 +24,39 @@ import { ProcessTaskResponse } from '../../../../core/tasks/models/process-task- * - Optionally overwrite createBody if the request body requires more than just the option */ @Component({ - selector: 'ds-calim-task-action-abstract', + selector: 'ds-claimed-task-action-abstract', template: '' }) -export abstract class ClaimedTaskActionsAbstractComponent { +export abstract class ClaimedTaskActionsAbstractComponent extends MyDSpaceReloadableActionsComponent implements OnDestroy { + /** * The workflow task option the child component represents */ abstract option: string; - /** - * The Claimed Task to display an action for - */ - @Input() object: ClaimedTask; + object: ClaimedTask; /** - * Emits the success or failure of a processed action + * Anchor used to reload the pool task. */ - @Output() processCompleted: EventEmitter = new EventEmitter(); + itemUuid: string; + + subs = []; + + protected constructor(protected injector: Injector, + protected router: Router, + protected notificationsService: NotificationsService, + protected translate: TranslateService, + protected searchService: SearchService, + protected requestService: RequestService) { + super(CLAIMED_TASK, injector, router, notificationsService, translate, searchService, requestService); + } /** - * A boolean representing if the operation is pending + * Submit the action on the claimed object. */ - processing$ = new BehaviorSubject(false); - - constructor(protected claimedTaskService: ClaimedTaskDataService) { + submitTask() { + this.subs.push(this.startActionExecution().pipe(take(1)).subscribe()); } /** @@ -49,17 +69,36 @@ export abstract class ClaimedTaskActionsAbstractComponent { }; } - /** - * Submit the task for this option - * While the task is submitting, processing$ is set to true and processCompleted emits the response's status when - * completed - */ - submitTask() { - this.processing$.next(true); - this.claimedTaskService.submitTask(this.object.id, this.createbody()) - .subscribe((res: ProcessTaskResponse) => { - this.processing$.next(false); - this.processCompleted.emit(res.hasSucceeded); - }); + reloadObjectExecution(): Observable | DSpaceObject> { + return this.objectDataService.findByItem(this.itemUuid as string); } + + actionExecution(): Observable { + return this.objectDataService.submitTask(this.object.id, this.createbody()); + } + + initObjects(object: ClaimedTask) { + this.object = object; + } + + /** + * Retrieve the itemUuid. + */ + initReloadAnchor() { + if (!(this.object as any).workflowitem) { + return; + } + this.subs.push(this.object.workflowitem.pipe( + getFirstSucceededRemoteDataPayload(), + switchMap((workflowItem: WorkflowItem) => workflowItem.item.pipe(getFirstSucceededRemoteDataPayload()) + )) + .subscribe((item: Item) => { + this.itemUuid = item.uuid; + })); + } + + ngOnDestroy() { + this.subs.forEach((sub) => sub.unsubscribe()); + } + } diff --git a/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.spec.ts b/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.spec.ts index 622c0bf18d..a785e3b26d 100644 --- a/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.spec.ts +++ b/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.spec.ts @@ -1,18 +1,33 @@ -import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ChangeDetectionStrategy, Injector, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; -import { of as observableOf } from 'rxjs'; +import { of, of as observableOf } from 'rxjs'; import { ClaimedTaskActionsApproveComponent } from './claimed-task-actions-approve.component'; import { TranslateLoaderMock } from '../../../mocks/translate-loader.mock'; import { ClaimedTaskDataService } from '../../../../core/tasks/claimed-task-data.service'; import { ClaimedTask } from '../../../../core/tasks/models/claimed-task-object.model'; import { ProcessTaskResponse } from '../../../../core/tasks/models/process-task-response'; +import { getMockSearchService } from '../../../mocks/search-service.mock'; +import { getMockRequestService } from '../../../mocks/request.service.mock'; +import { PoolTaskDataService } from '../../../../core/tasks/pool-task-data.service'; +import { NotificationsService } from '../../../notifications/notifications.service'; +import { NotificationsServiceStub } from '../../../testing/notifications-service.stub'; +import { Router } from '@angular/router'; +import { RouterStub } from '../../../testing/router.stub'; +import { SearchService } from '../../../../core/shared/search/search.service'; +import { RequestService } from '../../../../core/data/request.service'; let component: ClaimedTaskActionsApproveComponent; let fixture: ComponentFixture; +const searchService = getMockSearchService(); + +const requestService = getMockRequestService(); + +let mockPoolTaskDataService: PoolTaskDataService; + describe('ClaimedTaskActionsApproveComponent', () => { const object = Object.assign(new ClaimedTask(), { id: 'claimed-task-1' }); const claimedTaskService = jasmine.createSpyObj('claimedTaskService', { @@ -20,6 +35,7 @@ describe('ClaimedTaskActionsApproveComponent', () => { }); beforeEach(waitForAsync(() => { + mockPoolTaskDataService = new PoolTaskDataService(null, null, null, null, null, null, null, null); TestBed.configureTestingModule({ imports: [ TranslateModule.forRoot({ @@ -30,7 +46,13 @@ describe('ClaimedTaskActionsApproveComponent', () => { }) ], providers: [ - { provide: ClaimedTaskDataService, useValue: claimedTaskService } + { provide: ClaimedTaskDataService, useValue: claimedTaskService }, + { provide: Injector, useValue: {} }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, + { provide: Router, useValue: new RouterStub() }, + { provide: SearchService, useValue: searchService }, + { provide: RequestService, useValue: requestService }, + { provide: PoolTaskDataService, useValue: mockPoolTaskDataService }, ], declarations: [ClaimedTaskActionsApproveComponent], schemas: [NO_ERRORS_SCHEMA] @@ -43,6 +65,7 @@ describe('ClaimedTaskActionsApproveComponent', () => { fixture = TestBed.createComponent(ClaimedTaskActionsApproveComponent); component = fixture.componentInstance; component.object = object; + spyOn(component, 'initReloadAnchor').and.returnValue(undefined); fixture.detectChanges(); }); @@ -66,6 +89,7 @@ describe('ClaimedTaskActionsApproveComponent', () => { beforeEach(() => { spyOn(component.processCompleted, 'emit'); + spyOn(component, 'startActionExecution').and.returnValue(of(null)); expectedBody = { [component.option]: 'true' @@ -75,12 +99,34 @@ describe('ClaimedTaskActionsApproveComponent', () => { fixture.detectChanges(); }); - it('should call claimedTaskService\'s submitTask with the expected body', () => { - expect(claimedTaskService.submitTask).toHaveBeenCalledWith(object.id, expectedBody); + it('should start the action execution', () => { + expect(component.startActionExecution).toHaveBeenCalled(); + }); + }); + + describe('actionExecution', () => { + + it('should call claimedTaskService\'s submitTask', (done) => { + + const expectedBody = { + [component.option]: 'true' + }; + + component.actionExecution().subscribe(() => { + expect(claimedTaskService.submitTask).toHaveBeenCalledWith(object.id, expectedBody); + done(); + }); }); - it('should emit a successful processCompleted event', () => { - expect(component.processCompleted.emit).toHaveBeenCalledWith(true); + }); + + describe('reloadObjectExecution', () => { + + it('should return the component object itself', (done) => { + component.reloadObjectExecution().subscribe((val) => { + expect(val).toEqual(component.object); + done(); + }); }); }); diff --git a/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.ts b/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.ts index 8f51ac393c..d73da460b7 100644 --- a/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.ts +++ b/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.ts @@ -1,7 +1,16 @@ -import { Component } from '@angular/core'; +import { Component, Injector } from '@angular/core'; import { ClaimedTaskActionsAbstractComponent } from '../abstract/claimed-task-actions-abstract.component'; import { rendersWorkflowTaskOption } from '../switcher/claimed-task-actions-decorator'; -import { ClaimedTaskDataService } from '../../../../core/tasks/claimed-task-data.service'; +import { Observable } from 'rxjs'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; +import { Router } from '@angular/router'; +import { NotificationsService } from '../../../notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { SearchService } from '../../../../core/shared/search/search.service'; +import { RequestService } from '../../../../core/data/request.service'; +import { ClaimedApprovedTaskSearchResult } from '../../../object-collection/shared/claimed-approved-task-search-result.model'; +import { of } from 'rxjs/internal/observable/of'; export const WORKFLOW_TASK_OPTION_APPROVE = 'submit_approve'; @@ -20,7 +29,24 @@ export class ClaimedTaskActionsApproveComponent extends ClaimedTaskActionsAbstra */ option = WORKFLOW_TASK_OPTION_APPROVE; - constructor(protected claimedTaskService: ClaimedTaskDataService) { - super(claimedTaskService); + constructor(protected injector: Injector, + protected router: Router, + protected notificationsService: NotificationsService, + protected translate: TranslateService, + protected searchService: SearchService, + protected requestService: RequestService) { + super(injector, router, notificationsService, translate, searchService, requestService); } + + reloadObjectExecution(): Observable | DSpaceObject> { + return of(this.object); + } + + convertReloadedObject(dso: DSpaceObject): DSpaceObject { + const reloadedObject = Object.assign(new ClaimedApprovedTaskSearchResult(), dso, { + indexableObject: dso + }); + return reloadedObject; + } + } diff --git a/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.html b/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.html index aa569bbfc8..6a39fd44ca 100644 --- a/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.html +++ b/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.html @@ -3,11 +3,11 @@ + (processCompleted)="this.processCompleted.emit($event)"> + (processCompleted)="this.processCompleted.emit($event)"> diff --git a/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.ts b/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.ts index c82154af09..b610232279 100644 --- a/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.ts +++ b/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.ts @@ -2,7 +2,7 @@ import { Component, Injector, Input, OnInit } from '@angular/core'; import { Router } from '@angular/router'; import { Observable } from 'rxjs'; -import { filter, map } from 'rxjs/operators'; +import { filter, map, take } from 'rxjs/operators'; import { TranslateService } from '@ngx-translate/core'; import { ClaimedTaskDataService } from '../../../core/tasks/claimed-task-data.service'; @@ -87,7 +87,8 @@ export class ClaimedTaskActionsComponent extends MyDSpaceActionsComponent>).pipe( filter((rd: RemoteData) => ((!rd.isRequestPending) && isNotUndefined(rd.payload))), - map((rd: RemoteData) => rd.payload)); + map((rd: RemoteData) => rd.payload), + take(1)); } /** diff --git a/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.html b/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.html index 4a42378f7e..3f3670e17f 100644 --- a/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.html +++ b/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.html @@ -1,7 +1,7 @@ {{'submission.workflow.tasks.claimed.edit' | translate}} diff --git a/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.spec.ts b/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.spec.ts index cdbb699ad0..1a958f5a49 100644 --- a/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.spec.ts +++ b/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.spec.ts @@ -1,5 +1,5 @@ -import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ChangeDetectionStrategy, Injector, NO_ERRORS_SCHEMA } from '@angular/core'; +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; @@ -7,13 +7,28 @@ import { ClaimedTaskActionsEditMetadataComponent } from './claimed-task-actions- import { ClaimedTask } from '../../../../core/tasks/models/claimed-task-object.model'; import { ClaimedTaskDataService } from '../../../../core/tasks/claimed-task-data.service'; import { TranslateLoaderMock } from '../../../testing/translate-loader.mock'; +import { getMockSearchService } from '../../../mocks/search-service.mock'; +import { getMockRequestService } from '../../../mocks/request.service.mock'; +import { PoolTaskDataService } from '../../../../core/tasks/pool-task-data.service'; +import { NotificationsService } from '../../../notifications/notifications.service'; +import { NotificationsServiceStub } from '../../../testing/notifications-service.stub'; +import { Router } from '@angular/router'; +import { RouterStub } from '../../../testing/router.stub'; +import { SearchService } from '../../../../core/shared/search/search.service'; +import { RequestService } from '../../../../core/data/request.service'; let component: ClaimedTaskActionsEditMetadataComponent; let fixture: ComponentFixture; +const searchService = getMockSearchService(); + +const requestService = getMockRequestService(); + +let mockPoolTaskDataService: PoolTaskDataService; + describe('ClaimedTaskActionsEditMetadataComponent', () => { const object = Object.assign(new ClaimedTask(), { id: 'claimed-task-1' }); - + mockPoolTaskDataService = new PoolTaskDataService(null, null, null, null, null, null, null, null); beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [ @@ -25,7 +40,13 @@ describe('ClaimedTaskActionsEditMetadataComponent', () => { }) ], providers: [ - { provide: ClaimedTaskDataService, useValue: {} } + { provide: ClaimedTaskDataService, useValue: {} }, + { provide: Injector, useValue: {} }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, + { provide: Router, useValue: new RouterStub() }, + { provide: SearchService, useValue: searchService }, + { provide: RequestService, useValue: requestService }, + { provide: PoolTaskDataService, useValue: mockPoolTaskDataService }, ], declarations: [ClaimedTaskActionsEditMetadataComponent], schemas: [NO_ERRORS_SCHEMA] @@ -38,6 +59,7 @@ describe('ClaimedTaskActionsEditMetadataComponent', () => { fixture = TestBed.createComponent(ClaimedTaskActionsEditMetadataComponent); component = fixture.componentInstance; component.object = object; + spyOn(component, 'initReloadAnchor').and.returnValue(undefined); fixture.detectChanges(); }); diff --git a/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.ts b/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.ts index c0ce9cd4e5..7da189dddd 100644 --- a/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.ts +++ b/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.ts @@ -1,7 +1,11 @@ -import { Component } from '@angular/core'; +import { Component, Injector } from '@angular/core'; import { ClaimedTaskActionsAbstractComponent } from '../abstract/claimed-task-actions-abstract.component'; import { rendersWorkflowTaskOption } from '../switcher/claimed-task-actions-decorator'; -import { ClaimedTaskDataService } from '../../../../core/tasks/claimed-task-data.service'; +import { Router } from '@angular/router'; +import { NotificationsService } from '../../../notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { SearchService } from '../../../../core/shared/search/search.service'; +import { RequestService } from '../../../../core/data/request.service'; export const WORKFLOW_TASK_OPTION_EDIT_METADATA = 'submit_edit_metadata'; @@ -20,7 +24,12 @@ export class ClaimedTaskActionsEditMetadataComponent extends ClaimedTaskActionsA */ option = WORKFLOW_TASK_OPTION_EDIT_METADATA; - constructor(protected claimedTaskService: ClaimedTaskDataService) { - super(claimedTaskService); + constructor(protected injector: Injector, + protected router: Router, + protected notificationsService: NotificationsService, + protected translate: TranslateService, + protected searchService: SearchService, + protected requestService: RequestService) { + super(injector, router, notificationsService, translate, searchService, requestService); } } diff --git a/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.spec.ts b/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.spec.ts index 7f5dcc04d3..5d3a2ec127 100644 --- a/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.spec.ts +++ b/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.spec.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ChangeDetectionStrategy, Injector, NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { By } from '@angular/platform-browser'; @@ -9,22 +9,39 @@ import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { ClaimedTaskActionsRejectComponent } from './claimed-task-actions-reject.component'; import { TranslateLoaderMock } from '../../../mocks/translate-loader.mock'; import { ClaimedTask } from '../../../../core/tasks/models/claimed-task-object.model'; -import { of as observableOf } from 'rxjs'; import { ProcessTaskResponse } from '../../../../core/tasks/models/process-task-response'; import { ClaimedTaskDataService } from '../../../../core/tasks/claimed-task-data.service'; +import { NotificationsService } from '../../../notifications/notifications.service'; +import { NotificationsServiceStub } from '../../../testing/notifications-service.stub'; +import { Router } from '@angular/router'; +import { RouterStub } from '../../../testing/router.stub'; +import { SearchService } from '../../../../core/shared/search/search.service'; +import { RequestService } from '../../../../core/data/request.service'; +import { PoolTaskDataService } from '../../../../core/tasks/pool-task-data.service'; +import { getMockSearchService } from '../../../mocks/search-service.mock'; +import { getMockRequestService } from '../../../mocks/request.service.mock'; +import { of } from 'rxjs'; +import { ClaimedDeclinedTaskSearchResult } from '../../../object-collection/shared/claimed-declined-task-search-result.model'; let component: ClaimedTaskActionsRejectComponent; let fixture: ComponentFixture; let formBuilder: FormBuilder; let modalService: NgbModal; +const searchService = getMockSearchService(); + +const requestService = getMockRequestService(); + +let mockPoolTaskDataService: PoolTaskDataService; + describe('ClaimedTaskActionsRejectComponent', () => { const object = Object.assign(new ClaimedTask(), { id: 'claimed-task-1' }); const claimedTaskService = jasmine.createSpyObj('claimedTaskService', { - submitTask: observableOf(new ProcessTaskResponse(true)) + submitTask: of(new ProcessTaskResponse(true)) }); beforeEach(waitForAsync(() => { + mockPoolTaskDataService = new PoolTaskDataService(null, null, null, null, null, null, null, null); TestBed.configureTestingModule({ imports: [ NgbModule, @@ -39,6 +56,12 @@ describe('ClaimedTaskActionsRejectComponent', () => { declarations: [ClaimedTaskActionsRejectComponent], providers: [ { provide: ClaimedTaskDataService, useValue: claimedTaskService }, + { provide: Injector, useValue: {} }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, + { provide: Router, useValue: new RouterStub() }, + { provide: SearchService, useValue: searchService }, + { provide: RequestService, useValue: requestService }, + { provide: PoolTaskDataService, useValue: mockPoolTaskDataService }, FormBuilder, NgbModal ], @@ -55,6 +78,7 @@ describe('ClaimedTaskActionsRejectComponent', () => { modalService = TestBed.inject(NgbModal); component.object = object; component.modalRef = modalService.open('ok'); + spyOn(component, 'initReloadAnchor').and.returnValue(undefined); fixture.detectChanges(); }); @@ -96,6 +120,7 @@ describe('ClaimedTaskActionsRejectComponent', () => { beforeEach(() => { spyOn(component.processCompleted, 'emit'); + spyOn(component, 'startActionExecution').and.returnValue(of(null)); expectedBody = { [component.option]: 'true', @@ -113,12 +138,48 @@ describe('ClaimedTaskActionsRejectComponent', () => { fixture.detectChanges(); }); - it('should call claimedTaskService\'s submitTask with the expected body', () => { - expect(claimedTaskService.submitTask).toHaveBeenCalledWith(object.id, expectedBody); + it('should start the action execution', () => { + expect(component.startActionExecution).toHaveBeenCalled(); }); - it('should emit a successful processCompleted event', () => { - expect(component.processCompleted.emit).toHaveBeenCalledWith(true); + }); + + describe('actionExecution', () => { + + let expectedBody; + + beforeEach(() => { + spyOn((component.rejectForm as any), 'get').and.returnValue({value: 'required'}); + expectedBody = { + [component.option]: 'true', + reason: 'required' + }; + }); + + it('should call claimedTaskService\'s submitTask with the proper reason', (done) => { + component.actionExecution().subscribe(() => { + expect(claimedTaskService.submitTask).toHaveBeenCalledWith(object.id, expectedBody); + done(); + }); }); }); + + describe('reloadObjectExecution', () => { + + it('should return the component object itself', (done) => { + component.reloadObjectExecution().subscribe((val) => { + expect(val).toEqual(component.object); + done(); + }); + }); + }); + + describe('convertReloadedObject', () => { + + it('should return a ClaimedDeclinedTaskSearchResult instance', () => { + const reloadedObject = component.convertReloadedObject(component.object); + expect(reloadedObject instanceof ClaimedDeclinedTaskSearchResult).toEqual(true); + }); + }); + }); diff --git a/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.ts b/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.ts index 46d40cbb64..911bd385f4 100644 --- a/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.ts +++ b/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.ts @@ -1,10 +1,19 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, Injector, OnInit } from '@angular/core'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; import { ClaimedTaskActionsAbstractComponent } from '../abstract/claimed-task-actions-abstract.component'; -import { ClaimedTaskDataService } from '../../../../core/tasks/claimed-task-data.service'; import { rendersWorkflowTaskOption } from '../switcher/claimed-task-actions-decorator'; +import { Router } from '@angular/router'; +import { NotificationsService } from '../../../notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { SearchService } from '../../../../core/shared/search/search.service'; +import { RequestService } from '../../../../core/data/request.service'; +import { Observable } from 'rxjs'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; +import { of } from 'rxjs/internal/observable/of'; +import { ClaimedDeclinedTaskSearchResult } from '../../../object-collection/shared/claimed-declined-task-search-result.model'; export const WORKFLOW_TASK_OPTION_REJECT = 'submit_reject'; @@ -33,17 +42,15 @@ export class ClaimedTaskActionsRejectComponent extends ClaimedTaskActionsAbstrac */ public modalRef: NgbModalRef; - /** - * Initialize instance variables - * - * @param {FormBuilder} formBuilder - * @param {NgbModal} modalService - * @param claimedTaskService - */ - constructor(protected claimedTaskService: ClaimedTaskDataService, + constructor(protected injector: Injector, + protected router: Router, + protected notificationsService: NotificationsService, + protected translate: TranslateService, + protected searchService: SearchService, + protected requestService: RequestService, private formBuilder: FormBuilder, private modalService: NgbModal) { - super(claimedTaskService); + super(injector, router, notificationsService, translate, searchService, requestService); } /** @@ -55,6 +62,14 @@ export class ClaimedTaskActionsRejectComponent extends ClaimedTaskActionsAbstrac }); } + /** + * Submit a reject option for the task + */ + submitTask() { + this.modalRef.close('Send Button'); + super.submitTask(); + } + /** * Create the request body for rejecting a workflow task * Includes the reason from the form @@ -64,14 +79,6 @@ export class ClaimedTaskActionsRejectComponent extends ClaimedTaskActionsAbstrac return Object.assign(super.createbody(), { reason }); } - /** - * Submit a reject option for the task - */ - submitTask() { - this.modalRef.close('Send Button'); - super.submitTask(); - } - /** * Open modal * @@ -81,4 +88,15 @@ export class ClaimedTaskActionsRejectComponent extends ClaimedTaskActionsAbstrac this.rejectForm.reset(); this.modalRef = this.modalService.open(content); } + + reloadObjectExecution(): Observable | DSpaceObject> { + return of(this.object); + } + + convertReloadedObject(dso: DSpaceObject): DSpaceObject { + const reloadedObject = Object.assign(new ClaimedDeclinedTaskSearchResult(), dso, { + indexableObject: dso + }); + return reloadedObject; + } } diff --git a/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.spec.ts b/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.spec.ts index 76155e1903..e53daccf0d 100644 --- a/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.spec.ts +++ b/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.spec.ts @@ -1,25 +1,41 @@ -import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ChangeDetectionStrategy, Injector, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, fakeAsync, TestBed, waitForAsync } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { ClaimedTaskActionsReturnToPoolComponent } from './claimed-task-actions-return-to-pool.component'; import { ClaimedTask } from '../../../../core/tasks/models/claimed-task-object.model'; -import { of as observableOf } from 'rxjs'; import { ProcessTaskResponse } from '../../../../core/tasks/models/process-task-response'; import { ClaimedTaskDataService } from '../../../../core/tasks/claimed-task-data.service'; import { TranslateLoaderMock } from '../../../mocks/translate-loader.mock'; +import { NotificationsService } from '../../../notifications/notifications.service'; +import { NotificationsServiceStub } from '../../../testing/notifications-service.stub'; +import { Router } from '@angular/router'; +import { RouterStub } from '../../../testing/router.stub'; +import { PoolTaskDataService } from '../../../../core/tasks/pool-task-data.service'; +import { SearchService } from '../../../../core/shared/search/search.service'; +import { RequestService } from '../../../../core/data/request.service'; +import { getMockSearchService } from '../../../mocks/search-service.mock'; +import { getMockRequestService } from '../../../mocks/request.service.mock'; +import { of } from 'rxjs'; let component: ClaimedTaskActionsReturnToPoolComponent; let fixture: ComponentFixture; +const searchService = getMockSearchService(); + +const requestService = getMockRequestService(); + +let mockPoolTaskDataService: PoolTaskDataService; + describe('ClaimedTaskActionsReturnToPoolComponent', () => { const object = Object.assign(new ClaimedTask(), { id: 'claimed-task-1' }); const claimedTaskService = jasmine.createSpyObj('claimedTaskService', { - returnToPoolTask: observableOf(new ProcessTaskResponse(true)) + returnToPoolTask: of(new ProcessTaskResponse(true)) }); beforeEach(waitForAsync(() => { + mockPoolTaskDataService = new PoolTaskDataService(null, null, null, null, null, null, null, null); TestBed.configureTestingModule({ imports: [ TranslateModule.forRoot({ @@ -30,7 +46,13 @@ describe('ClaimedTaskActionsReturnToPoolComponent', () => { }) ], providers: [ - { provide: ClaimedTaskDataService, useValue: claimedTaskService } + { provide: ClaimedTaskDataService, useValue: claimedTaskService }, + { provide: Injector, useValue: {} }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, + { provide: Router, useValue: new RouterStub() }, + { provide: SearchService, useValue: searchService }, + { provide: RequestService, useValue: requestService }, + { provide: PoolTaskDataService, useValue: mockPoolTaskDataService }, ], declarations: [ClaimedTaskActionsReturnToPoolComponent], schemas: [NO_ERRORS_SCHEMA] @@ -39,12 +61,13 @@ describe('ClaimedTaskActionsReturnToPoolComponent', () => { }).compileComponents(); })); - beforeEach(() => { + beforeEach(fakeAsync(() => { fixture = TestBed.createComponent(ClaimedTaskActionsReturnToPoolComponent); component = fixture.componentInstance; component.object = object; + spyOn(component, 'initReloadAnchor').and.returnValue(undefined); fixture.detectChanges(); - }); + })); it('should display return to pool button', () => { const btn = fixture.debugElement.query(By.css('.btn-secondary')); @@ -61,11 +84,9 @@ describe('ClaimedTaskActionsReturnToPoolComponent', () => { expect(span).toBeDefined(); }); - describe('submitTask', () => { + describe('actionExecution', () => { beforeEach(() => { - spyOn(component.processCompleted, 'emit'); - - component.submitTask(); + component.actionExecution().subscribe(); fixture.detectChanges(); }); @@ -73,8 +94,19 @@ describe('ClaimedTaskActionsReturnToPoolComponent', () => { expect(claimedTaskService.returnToPoolTask).toHaveBeenCalledWith(object.id); }); - it('should emit a successful processCompleted event', () => { - expect(component.processCompleted.emit).toHaveBeenCalledWith(true); + }); + + describe('reloadObjectExecution', () => { + beforeEach(() => { + spyOn(mockPoolTaskDataService, 'findByItem').and.returnValue(of(null)); + + component.itemUuid = 'uuid'; + component.reloadObjectExecution().subscribe(); + fixture.detectChanges(); + }); + + it('should call poolTaskDataService findItem with itemUuid', () => { + expect(mockPoolTaskDataService.findByItem).toHaveBeenCalledWith('uuid'); }); }); diff --git a/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.ts b/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.ts index c53bf30fad..f4a79db888 100644 --- a/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.ts +++ b/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.ts @@ -1,8 +1,16 @@ -import { Component } from '@angular/core'; +import {Component, Injector} from '@angular/core'; import { ClaimedTaskActionsAbstractComponent } from '../abstract/claimed-task-actions-abstract.component'; import { rendersWorkflowTaskOption } from '../switcher/claimed-task-actions-decorator'; -import { ClaimedTaskDataService } from '../../../../core/tasks/claimed-task-data.service'; -import { ProcessTaskResponse } from '../../../../core/tasks/models/process-task-response'; +import { Observable } from 'rxjs'; +import { Router } from '@angular/router'; +import { NotificationsService } from '../../../notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { SearchService } from '../../../../core/shared/search/search.service'; +import { RequestService } from '../../../../core/data/request.service'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; +import { PoolTaskDataService } from '../../../../core/tasks/pool-task-data.service'; +import { take } from 'rxjs/operators'; export const WORKFLOW_TASK_OPTION_RETURN_TO_POOL = 'return_to_pool'; @@ -21,19 +29,22 @@ export class ClaimedTaskActionsReturnToPoolComponent extends ClaimedTaskActionsA */ option = WORKFLOW_TASK_OPTION_RETURN_TO_POOL; - constructor(protected claimedTaskService: ClaimedTaskDataService) { - super(claimedTaskService); + constructor(protected injector: Injector, + protected router: Router, + protected notificationsService: NotificationsService, + protected translate: TranslateService, + protected searchService: SearchService, + protected requestService: RequestService, + private poolTaskService: PoolTaskDataService) { + super(injector, router, notificationsService, translate, searchService, requestService); } - /** - * Submit a return to pool option for the task - */ - submitTask() { - this.processing$.next(true); - this.claimedTaskService.returnToPoolTask(this.object.id) - .subscribe((res: ProcessTaskResponse) => { - this.processing$.next(false); - this.processCompleted.emit(res.hasSucceeded); - }); + reloadObjectExecution(): Observable | DSpaceObject> { + return this.poolTaskService.findByItem(this.itemUuid).pipe(take(1)); } + + actionExecution(): Observable { + return this.objectDataService.returnToPoolTask(this.object.id); + } + } diff --git a/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component.spec.ts b/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component.spec.ts index 331a8586b9..6de2056fe8 100644 --- a/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component.spec.ts +++ b/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component.spec.ts @@ -1,15 +1,26 @@ import { ClaimedTaskActionsLoaderComponent } from './claimed-task-actions-loader.component'; -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { ChangeDetectionStrategy, ComponentFactoryResolver, NO_ERRORS_SCHEMA } from '@angular/core'; -import * as decorators from './claimed-task-actions-decorator'; +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ChangeDetectionStrategy, Injector, NO_ERRORS_SCHEMA } from '@angular/core'; import { ClaimedTaskActionsDirective } from './claimed-task-actions.directive'; import { ClaimedTask } from '../../../../core/tasks/models/claimed-task-object.model'; import { TranslateModule } from '@ngx-translate/core'; import { ClaimedTaskActionsEditMetadataComponent } from '../edit-metadata/claimed-task-actions-edit-metadata.component'; import { ClaimedTaskDataService } from '../../../../core/tasks/claimed-task-data.service'; -import { spyOnExported } from '../../../testing/utils.test'; +import { NotificationsService } from '../../../notifications/notifications.service'; +import { NotificationsServiceStub } from '../../../testing/notifications-service.stub'; +import { Router } from '@angular/router'; +import { RouterStub } from '../../../testing/router.stub'; +import { SearchService } from '../../../../core/shared/search/search.service'; +import { RequestService } from '../../../../core/data/request.service'; +import { PoolTaskDataService } from '../../../../core/tasks/pool-task-data.service'; +import { getMockSearchService } from '../../../mocks/search-service.mock'; +import { getMockRequestService } from '../../../mocks/request.service.mock'; -xdescribe('ClaimedTaskActionsLoaderComponent', () => { +const searchService = getMockSearchService(); + +const requestService = getMockRequestService(); + +describe('ClaimedTaskActionsLoaderComponent', () => { let comp: ClaimedTaskActionsLoaderComponent; let fixture: ComponentFixture; @@ -23,7 +34,12 @@ xdescribe('ClaimedTaskActionsLoaderComponent', () => { schemas: [NO_ERRORS_SCHEMA], providers: [ { provide: ClaimedTaskDataService, useValue: {} }, - ComponentFactoryResolver + { provide: Injector, useValue: {} }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, + { provide: Router, useValue: new RouterStub() }, + { provide: SearchService, useValue: searchService }, + { provide: RequestService, useValue: requestService }, + { provide: PoolTaskDataService, useValue: {} } ] }).overrideComponent(ClaimedTaskActionsLoaderComponent, { set: { @@ -36,16 +52,16 @@ xdescribe('ClaimedTaskActionsLoaderComponent', () => { beforeEach(waitForAsync(() => { fixture = TestBed.createComponent(ClaimedTaskActionsLoaderComponent); comp = fixture.componentInstance; - comp.object = object; comp.option = option; - spyOnExported(decorators, 'getComponentByWorkflowTaskOption').and.returnValue(ClaimedTaskActionsEditMetadataComponent); + spyOn(comp, 'getComponentByWorkflowTaskOption').and.returnValue(ClaimedTaskActionsEditMetadataComponent); + fixture.detectChanges(); })); describe('When the component is rendered', () => { it('should call the getComponentByWorkflowTaskOption function with the right option', () => { - expect(decorators.getComponentByWorkflowTaskOption).toHaveBeenCalledWith(option); + expect(comp.getComponentByWorkflowTaskOption).toHaveBeenCalledWith(option); }); }); }); diff --git a/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component.ts b/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component.ts index b553eff206..68c597a41c 100644 --- a/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component.ts +++ b/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component.ts @@ -14,6 +14,7 @@ import { ClaimedTaskActionsDirective } from './claimed-task-actions.directive'; import { ClaimedTaskActionsAbstractComponent } from '../abstract/claimed-task-actions-abstract.component'; import { hasValue } from '../../../empty.util'; import { Subscription } from 'rxjs'; +import { MyDSpaceActionsResult } from '../../mydspace-actions'; @Component({ selector: 'ds-claimed-task-actions-loader', @@ -38,7 +39,7 @@ export class ClaimedTaskActionsLoaderComponent implements OnInit, OnDestroy { /** * Emits the success or failure of a processed action */ - @Output() processCompleted: EventEmitter = new EventEmitter(); + @Output() processCompleted = new EventEmitter(); /** * Directive to determine where the dynamic child component is located @@ -58,7 +59,8 @@ export class ClaimedTaskActionsLoaderComponent implements OnInit, OnDestroy { * Fetch, create and initialize the relevant component */ ngOnInit(): void { - const comp = getComponentByWorkflowTaskOption(this.option); + + const comp = this.getComponentByWorkflowTaskOption(this.option); if (hasValue(comp)) { const componentFactory = this.componentFactoryResolver.resolveComponentFactory(comp); @@ -69,11 +71,15 @@ export class ClaimedTaskActionsLoaderComponent implements OnInit, OnDestroy { const componentInstance = (componentRef.instance as ClaimedTaskActionsAbstractComponent); componentInstance.object = this.object; if (hasValue(componentInstance.processCompleted)) { - this.subs.push(componentInstance.processCompleted.subscribe((success) => this.processCompleted.emit(success))); + this.subs.push(componentInstance.processCompleted.subscribe((result) => this.processCompleted.emit(result))); } } } + getComponentByWorkflowTaskOption(option: string) { + return getComponentByWorkflowTaskOption(option); + } + /** * Unsubscribe from open subscriptions */ diff --git a/src/app/shared/mydspace-actions/mydspace-actions.ts b/src/app/shared/mydspace-actions/mydspace-actions.ts index 550b437b0b..2ddc5990b4 100644 --- a/src/app/shared/mydspace-actions/mydspace-actions.ts +++ b/src/app/shared/mydspace-actions/mydspace-actions.ts @@ -1,5 +1,5 @@ import { Router } from '@angular/router'; -import { Component, Injector, Input } from '@angular/core'; +import { Component, EventEmitter, Injector, Input, Output } from '@angular/core'; import { take, tap } from 'rxjs/operators'; @@ -12,10 +12,15 @@ import { NotificationOptions } from '../notifications/models/notification-option import { NotificationsService } from '../notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; import { RequestService } from '../../core/data/request.service'; -import { Subscription } from 'rxjs'; +import { BehaviorSubject, Subscription } from 'rxjs'; import { SearchService } from '../../core/shared/search/search.service'; import { getFirstSucceededRemoteData } from '../../core/shared/operators'; +export interface MyDSpaceActionsResult { + result: boolean; + reloadedObject: DSpaceObject; +} + /** * Abstract class for all different representations of mydspace actions */ @@ -31,7 +36,18 @@ export abstract class MyDSpaceActionsComponent(); + + /** + * A boolean representing if an operation is pending + * @type {BehaviorSubject} + */ + public processing$ = new BehaviorSubject(false); + + /** + * Instance of DataService related to mydspace object */ protected objectDataService: TService; @@ -71,6 +87,7 @@ export abstract class MyDSpaceActionsComponent; + +let mockObject: PoolTask; +let notificationsServiceStub: NotificationsServiceStub; +let router: RouterStub; + +const searchService = getMockSearchService(); + +const requestService = getMockRequestService(); + +const item = Object.assign(new Item(), { + bundles: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.type': [ + { + language: null, + value: 'Article' + } + ], + 'dc.contributor.author': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '2015-06-26' + } + ] + } +}); +const rdItem = createSuccessfulRemoteDataObject(item); +const workflowitem = Object.assign(new WorkflowItem(), { item: observableOf(rdItem) }); +const rdWorkflowitem = createSuccessfulRemoteDataObject(workflowitem); +mockObject = Object.assign(new PoolTask(), { workflowitem: observableOf(rdWorkflowitem), id: '1234' }); + +describe('MyDSpaceReloadableActionsComponent', () => { + beforeEach(fakeAsync(() => { + mockDataService = new PoolTaskDataService(null, null, null, null, null, null, null, null); + mockClaimedTaskDataService = new ClaimedTaskDataService(null, null, null, null, null, null, null, null); + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }) + ], + declarations: [PoolTaskActionsComponent], + providers: [ + { provide: Injector, useValue: {} }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, + { provide: Router, useValue: new RouterStub() }, + { provide: PoolTaskDataService, useValue: mockDataService }, + { provide: ClaimedTaskDataService, useValue: mockClaimedTaskDataService }, + { provide: SearchService, useValue: searchService }, + { provide: RequestService, useValue: requestService } + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(PoolTaskActionsComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(PoolTaskActionsComponent); + component = fixture.componentInstance; + component.object = mockObject; + notificationsServiceStub = TestBed.get(NotificationsService); + router = TestBed.get(Router); + fixture.detectChanges(); + }); + + afterEach(() => { + fixture = null; + component = null; + }); + + describe('on reload action init', () => { + + beforeEach(() => { + spyOn(component, 'initReloadAnchor').and.returnValue(null); + spyOn(component, 'initObjects'); + }); + + it('should call initReloadAnchor and initObjects on init', fakeAsync(() => { + component.ngOnInit(); + + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(component.initReloadAnchor).toHaveBeenCalled(); + expect(component.initObjects).toHaveBeenCalled(); + }); + + })); + + }); + + describe('on action execution fail', () => { + + let remoteClaimTaskErrorResponse; + + beforeEach(() => { + + mockDataService = new PoolTaskDataService(null, null, null, null, null, null, null, null); + + const poolTaskHref = 'poolTaskHref'; + remoteClaimTaskErrorResponse = new ProcessTaskResponse(false, null, null); + const remoteReloadedObjectResponse: any = createSuccessfulRemoteDataObject(new PoolTask()); + + spyOn(mockDataService, 'getPoolTaskEndpointById').and.returnValue(observableOf(poolTaskHref)); + spyOn(mockClaimedTaskDataService, 'findByItem').and.returnValue(observableOf(remoteReloadedObjectResponse)); + spyOn(mockClaimedTaskDataService, 'claimTask').and.returnValue(observableOf(remoteClaimTaskErrorResponse)); + spyOn(component, 'reloadObjectExecution').and.callThrough(); + spyOn(component.processCompleted, 'emit').and.callThrough(); + + (component as any).objectDataService = mockDataService; + }); + + it('should show error notification', (done) => { + + component.startActionExecution().subscribe( (result) => { + expect(notificationsServiceStub.error).toHaveBeenCalled(); + done(); + }); + }); + + it('should not call reloadObject', (done) => { + + component.startActionExecution().subscribe( (result) => { + expect(component.reloadObjectExecution).not.toHaveBeenCalled(); + done(); + }); + + }); + + it('should not emit processCompleted', (done) => { + + component.startActionExecution().subscribe( (result) => { + expect(component.processCompleted.emit).not.toHaveBeenCalled(); + done(); + }); + + }); + + }); + + describe('on action execution success', () => { + + beforeEach(() => { + + mockDataService = new PoolTaskDataService(null, null, null, null, null, null, null, null); + + const poolTaskHref = 'poolTaskHref'; + const remoteClaimTaskResponse: any = new ProcessTaskResponse(true, null, null); + const remoteReloadedObjectResponse: any = createSuccessfulRemoteDataObject(new PoolTask()); + + spyOn(mockDataService, 'getPoolTaskEndpointById').and.returnValue(observableOf(poolTaskHref)); + spyOn(mockClaimedTaskDataService, 'findByItem').and.returnValue(observableOf(remoteReloadedObjectResponse)); + spyOn(mockClaimedTaskDataService, 'claimTask').and.returnValue(observableOf(remoteClaimTaskResponse)); + spyOn(component, 'reloadObjectExecution').and.callThrough(); + spyOn(component, 'convertReloadedObject').and.callThrough(); + spyOn(component.processCompleted, 'emit').and.callThrough(); + + (component as any).objectDataService = mockDataService; + }); + + it('should reloadObject in case of action execution success', (done) => { + + component.startActionExecution().subscribe( (result) => { + expect(component.reloadObjectExecution).toHaveBeenCalled(); + done(); + }); + }); + + it('should convert the reloaded object', (done) => { + + component.startActionExecution().subscribe( (result) => { + expect(component.convertReloadedObject).toHaveBeenCalled(); + done(); + }); + }); + + it('should emit the reloaded object in case of success', (done) => { + + component.startActionExecution().subscribe( (result) => { + expect(component.processCompleted.emit).toHaveBeenCalledWith({result: true, reloadedObject: result as any}); + done(); + }); + }); + + }); + + describe('on action execution success but without a reloadedObject', () => { + + beforeEach(() => { + + mockDataService = new PoolTaskDataService(null, null, null, null, null, null, null, null); + + const poolTaskHref = 'poolTaskHref'; + const remoteClaimTaskResponse: any = new ProcessTaskResponse(true, null, null); + const remoteReloadedObjectResponse: any = createFailedRemoteDataObject(); + + spyOn(mockDataService, 'getPoolTaskEndpointById').and.returnValue(observableOf(poolTaskHref)); + spyOn(mockClaimedTaskDataService, 'findByItem').and.returnValue(observableOf(remoteReloadedObjectResponse)); + spyOn(mockClaimedTaskDataService, 'claimTask').and.returnValue(observableOf(remoteClaimTaskResponse)); + + spyOn(component, 'convertReloadedObject').and.returnValue(null); + spyOn(component, 'reload').and.returnValue(null); + + (component as any).objectDataService = mockDataService; + }); + + it('should call reload method', (done) => { + + component.startActionExecution().subscribe( (result) => { + expect(component.reload).toHaveBeenCalled(); + done(); + }); + }); + + }); + +}); diff --git a/src/app/shared/mydspace-actions/mydspace-reloadable-actions.ts b/src/app/shared/mydspace-actions/mydspace-reloadable-actions.ts new file mode 100644 index 0000000000..7043191915 --- /dev/null +++ b/src/app/shared/mydspace-actions/mydspace-reloadable-actions.ts @@ -0,0 +1,145 @@ +import { Router } from '@angular/router'; +import { Component, Injector, OnInit } from '@angular/core'; + +import { map, switchMap, take, tap } from 'rxjs/operators'; + +import { RemoteData } from '../../core/data/remote-data'; +import { DataService } from '../../core/data/data.service'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { ResourceType } from '../../core/shared/resource-type'; +import { NotificationOptions } from '../notifications/models/notification-options.model'; +import { NotificationsService } from '../notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { RequestService } from '../../core/data/request.service'; +import { SearchService } from '../../core/shared/search/search.service'; +import { Observable} from 'rxjs/internal/Observable'; +import { of} from 'rxjs/internal/observable/of'; +import { ProcessTaskResponse } from '../../core/tasks/models/process-task-response'; +import { getFirstCompletedRemoteData } from '../../core/shared/operators'; +import { getSearchResultFor } from '../search/search-result-element-decorator'; +import { MyDSpaceActionsComponent } from './mydspace-actions'; + +/** + * Abstract class for all different representations of mydspace actions + */ +@Component({ + selector: 'ds-mydspace-reloadable-actions', + template: '' +}) +export abstract class MyDSpaceReloadableActionsComponent> + extends MyDSpaceActionsComponent implements OnInit { + + protected constructor( + protected objectType: ResourceType, + protected injector: Injector, + protected router: Router, + protected notificationsService: NotificationsService, + protected translate: TranslateService, + protected searchService: SearchService, + protected requestService: RequestService) { + super(objectType, injector, router, notificationsService, translate, searchService, requestService); + } + + /** + * Perform the actual implementation of this reloadable action. + */ + abstract actionExecution(): Observable; + + /** + * Reload the object (typically by a remote call). + */ + abstract reloadObjectExecution(): Observable | DSpaceObject>; + + ngOnInit() { + this.initReloadAnchor(); + this.initObjects(this.object); + } + + /** + * Start the execution of the action. + * 1. performAction + * 2. reload of the object + * 3. notification + */ + startActionExecution(): Observable { + this.processing$.next(true); + return this.actionExecution().pipe( + take(1), + switchMap((res: ProcessTaskResponse) => { + if (res.hasSucceeded) { + return this._reloadObject().pipe( + tap( + (reloadedObject) => { + this.processing$.next(false); + this.handleReloadableActionResponse(res.hasSucceeded, reloadedObject); + }) + ); + } else { + this.processing$.next(false); + this.handleReloadableActionResponse(res.hasSucceeded, null); + return of(null); + } + })); + } + + /** + * Handle the action response and show properly notifications. + * + * @param result + * true on success, false otherwise + * @param reloadedObject + * the reloadedObject + */ + handleReloadableActionResponse(result: boolean, reloadedObject: DSpaceObject): void { + if (result) { + if (reloadedObject) { + this.processCompleted.emit({result, reloadedObject}); + } else { + this.reload(); + } + this.notificationsService.success(null, + this.translate.get('submission.workflow.tasks.generic.success'), + new NotificationOptions(5000, false)); + } else { + this.notificationsService.error(null, + this.translate.get('submission.workflow.tasks.generic.error'), + new NotificationOptions(20000, true)); + } + } + + /** + * Hook called on init to initialized the required information used to reload the object. + */ + // tslint:disable-next-line:no-empty + initReloadAnchor() {} + + /** + * Convert the reloadedObject to the Type required by this action. + * @param dso + */ + convertReloadedObject(dso: DSpaceObject): DSpaceObject { + const constructor = getSearchResultFor((dso as any).constructor); + const reloadedObject = Object.assign(new constructor(), dso, { + indexableObject: dso + }); + return reloadedObject; + } + + /** + * Retrieve the refreshed object and transform it to a reloadedObject. + * @param dso + */ + private _reloadObject(): Observable { + return this.reloadObjectExecution().pipe( + switchMap((res) => { + if (res instanceof RemoteData) { + return of(res).pipe(getFirstCompletedRemoteData(), map((completed) => completed.payload)); + } else { + return of(res); + } + })).pipe(map((dso) => { + return dso ? this.convertReloadedObject(dso) : dso; + })); + } + +} diff --git a/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.html b/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.html index 6f4ffffad3..214f85ed5b 100644 --- a/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.html +++ b/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.html @@ -1,8 +1,8 @@ diff --git a/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.spec.ts b/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.spec.ts index d3285dcb63..bce1f1a467 100644 --- a/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.spec.ts +++ b/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.spec.ts @@ -1,5 +1,5 @@ import { ChangeDetectionStrategy, Injector, NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, fakeAsync, TestBed, waitForAsync } from '@angular/core/testing'; +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; import { By } from '@angular/platform-browser'; @@ -21,6 +21,12 @@ import { getMockRequestService } from '../../mocks/request.service.mock'; import { RequestService } from '../../../core/data/request.service'; import { getMockSearchService } from '../../mocks/search-service.mock'; import { SearchService } from '../../../core/shared/search/search.service'; +import { ClaimedTaskDataService } from '../../../core/tasks/claimed-task-data.service'; +import { PoolTaskSearchResult } from '../../object-collection/shared/pool-task-search-result.model'; +import { ProcessTaskResponse } from '../../../core/tasks/models/process-task-response'; + +let mockDataService: PoolTaskDataService; +let mockClaimedTaskDataService: ClaimedTaskDataService; let component: PoolTaskActionsComponent; let fixture: ComponentFixture; @@ -29,13 +35,9 @@ let mockObject: PoolTask; let notificationsServiceStub: NotificationsServiceStub; let router: RouterStub; -const mockDataService = jasmine.createSpyObj('PoolTaskDataService', { - claimTask: jasmine.createSpy('claimTask') -}); - const searchService = getMockSearchService(); -const requestServce = getMockRequestService(); +const requestService = getMockRequestService(); const item = Object.assign(new Item(), { bundles: observableOf({}), @@ -73,6 +75,8 @@ mockObject = Object.assign(new PoolTask(), { workflowitem: observableOf(rdWorkfl describe('PoolTaskActionsComponent', () => { beforeEach(waitForAsync(() => { + mockDataService = new PoolTaskDataService(null, null, null, null, null, null, null, null); + mockClaimedTaskDataService = new ClaimedTaskDataService(null, null, null, null, null, null, null, null); TestBed.configureTestingModule({ imports: [ TranslateModule.forRoot({ @@ -88,8 +92,9 @@ describe('PoolTaskActionsComponent', () => { { provide: NotificationsService, useValue: new NotificationsServiceStub() }, { provide: Router, useValue: new RouterStub() }, { provide: PoolTaskDataService, useValue: mockDataService }, + { provide: ClaimedTaskDataService, useValue: mockClaimedTaskDataService }, { provide: SearchService, useValue: searchService }, - { provide: RequestService, useValue: requestServce } + { provide: RequestService, useValue: requestService } ], schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(PoolTaskActionsComponent, { @@ -128,63 +133,35 @@ describe('PoolTaskActionsComponent', () => { expect(btn).toBeDefined(); }); - it('should call claimTask method on claim', fakeAsync(() => { - spyOn(component, 'reload'); - mockDataService.claimTask.and.returnValue(observableOf({ hasSucceeded: true })); + it('should call claim task with href of getPoolTaskEndpointById', ((done) => { - component.claim(); - fixture.detectChanges(); + const poolTaskHref = 'poolTaskHref'; + const remoteClaimTaskResponse: any = new ProcessTaskResponse(true, null, null); + const remoteReloadedObjectResponse: any = createSuccessfulRemoteDataObject(new PoolTask()); - fixture.whenStable().then(() => { - expect(mockDataService.claimTask).toHaveBeenCalledWith(mockObject.id); - }); + spyOn(mockDataService, 'getPoolTaskEndpointById').and.returnValue(observableOf(poolTaskHref)); + spyOn(mockClaimedTaskDataService, 'claimTask').and.returnValue(observableOf(remoteClaimTaskResponse)); + spyOn(mockClaimedTaskDataService, 'findByItem').and.returnValue(observableOf(remoteReloadedObjectResponse)); - })); + (component as any).objectDataService = mockDataService; - it('should display a success notification on claim success', waitForAsync(() => { - spyOn(component, 'reload'); - mockDataService.claimTask.and.returnValue(observableOf({ hasSucceeded: true })); + spyOn(component, 'handleReloadableActionResponse').and.callThrough(); - component.claim(); - fixture.detectChanges(); + component.startActionExecution().subscribe( (result) => { + + expect(mockDataService.getPoolTaskEndpointById).toHaveBeenCalledWith(mockObject.id); + expect(mockClaimedTaskDataService.claimTask).toHaveBeenCalledWith(mockObject.id, poolTaskHref); + expect(mockClaimedTaskDataService.findByItem).toHaveBeenCalledWith(component.itemUuid); + + expect(result instanceof PoolTaskSearchResult).toBeTrue(); + + expect(component.handleReloadableActionResponse).toHaveBeenCalledWith(true, result); - fixture.whenStable().then(() => { expect(notificationsServiceStub.success).toHaveBeenCalled(); + + done(); }); - })); - it('should reload page on claim success', waitForAsync(() => { - spyOn(router, 'navigateByUrl'); - router.url = 'test.url/test'; - mockDataService.claimTask.and.returnValue(observableOf({ hasSucceeded: true })); - - component.claim(); - fixture.detectChanges(); - - fixture.whenStable().then(() => { - expect(router.navigateByUrl).toHaveBeenCalledWith('test.url/test'); - }); - })); - - it('should display an error notification on claim failure', waitForAsync(() => { - mockDataService.claimTask.and.returnValue(observableOf({ hasSucceeded: false })); - - component.claim(); - fixture.detectChanges(); - - fixture.whenStable().then(() => { - expect(notificationsServiceStub.error).toHaveBeenCalled(); - }); - })); - - it('should clear the object cache by href', waitForAsync(() => { - component.reload(); - fixture.detectChanges(); - - fixture.whenStable().then(() => { - expect(searchService.getEndpoint).toHaveBeenCalled(); - expect(requestServce.removeByHrefSubstring).toHaveBeenCalledWith('discover/search/objects'); - }); })); }); diff --git a/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.ts b/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.ts index 892298d2f7..92086ac817 100644 --- a/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.ts +++ b/src/app/shared/mydspace-actions/pool-task/pool-task-actions.component.ts @@ -1,20 +1,24 @@ -import { Component, Injector, Input } from '@angular/core'; +import { Component, Injector, Input, OnDestroy } from '@angular/core'; import { Router } from '@angular/router'; -import { BehaviorSubject, Observable } from 'rxjs'; -import { filter, map } from 'rxjs/operators'; +import { Observable } from 'rxjs'; +import {filter, map, switchMap, take} from 'rxjs/operators'; import { TranslateService } from '@ngx-translate/core'; import { WorkflowItem } from '../../../core/submission/models/workflowitem.model'; -import { ProcessTaskResponse } from '../../../core/tasks/models/process-task-response'; import { RemoteData } from '../../../core/data/remote-data'; import { PoolTask } from '../../../core/tasks/models/pool-task-object.model'; import { PoolTaskDataService } from '../../../core/tasks/pool-task-data.service'; import { isNotUndefined } from '../../empty.util'; -import { MyDSpaceActionsComponent } from '../mydspace-actions'; import { NotificationsService } from '../../notifications/notifications.service'; import { RequestService } from '../../../core/data/request.service'; import { SearchService } from '../../../core/shared/search/search.service'; +import { ClaimedTaskDataService } from '../../../core/tasks/claimed-task-data.service'; +import { getFirstSucceededRemoteDataPayload } from '../../../core/shared/operators'; +import { Item } from '../../../core/shared/item.model'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +import { MyDSpaceReloadableActionsComponent } from '../mydspace-reloadable-actions'; +import { ProcessTaskResponse } from '../../../core/tasks/models/process-task-response'; /** * This component represents mydspace actions related to PoolTask object. @@ -24,24 +28,25 @@ import { SearchService } from '../../../core/shared/search/search.service'; styleUrls: ['./pool-task-actions.component.scss'], templateUrl: './pool-task-actions.component.html', }) -export class PoolTaskActionsComponent extends MyDSpaceActionsComponent { +export class PoolTaskActionsComponent extends MyDSpaceReloadableActionsComponent implements OnDestroy { /** * The PoolTask object */ @Input() object: PoolTask; - /** - * A boolean representing if a claim operation is pending - * @type {BehaviorSubject} - */ - public processingClaim$ = new BehaviorSubject(false); - /** * The workflowitem object that belonging to the PoolTask object */ public workflowitem$: Observable; + /** + * Anchor used to reload the pool task. + */ + public itemUuid: string; + + subs = []; + /** * Initialize instance variables * @@ -55,6 +60,7 @@ export class PoolTaskActionsComponent extends MyDSpaceActionsComponent>).pipe( filter((rd: RemoteData) => ((!rd.isRequestPending) && isNotUndefined(rd.payload))), - map((rd: RemoteData) => rd.payload)); + map((rd: RemoteData) => rd.payload), + take(1)); + } + + actionExecution(): Observable { + return this.objectDataService.getPoolTaskEndpointById(this.object.id) + .pipe(switchMap((poolTaskHref) => { + return this.claimedTaskService.claimTask(this.object.id, poolTaskHref); + })); + } + + reloadObjectExecution(): Observable | DSpaceObject> { + return this.claimedTaskService.findByItem(this.itemUuid).pipe(take(1)); } /** - * Claim the task. + * Retrieve the itemUuid. */ - claim() { - this.processingClaim$.next(true); - this.objectDataService.claimTask(this.object.id) - .subscribe((res: ProcessTaskResponse) => { - this.handleActionResponse(res.hasSucceeded); - this.processingClaim$.next(false); - }); + initReloadAnchor() { + (this.object as any).workflowitem.pipe( + getFirstSucceededRemoteDataPayload(), + switchMap((workflowItem: WorkflowItem) => workflowItem.item.pipe(getFirstSucceededRemoteDataPayload()) + )) + .subscribe((item: Item) => { + this.itemUuid = item.uuid; + }); } + + ngOnDestroy() { + this.subs.forEach((sub) => sub.unsubscribe()); + } + } diff --git a/src/app/shared/object-collection/object-collection.component.html b/src/app/shared/object-collection/object-collection.component.html index e696170a6f..f2778757ef 100644 --- a/src/app/shared/object-collection/object-collection.component.html +++ b/src/app/shared/object-collection/object-collection.component.html @@ -18,6 +18,7 @@ [importable]="importable" [importConfig]="importConfig" (importObject)="importObject.emit($event)" + (contentChange)="contentChange.emit()" *ngIf="(currentMode$ | async) === viewModeEnum.ListElement"> diff --git a/src/app/shared/object-collection/object-collection.component.ts b/src/app/shared/object-collection/object-collection.component.ts index ffb5c42880..52881f5eaf 100644 --- a/src/app/shared/object-collection/object-collection.component.ts +++ b/src/app/shared/object-collection/object-collection.component.ts @@ -53,6 +53,11 @@ export class ObjectCollectionComponent implements OnInit { @Output() deselectObject: EventEmitter = new EventEmitter(); @Output() selectObject: EventEmitter = new EventEmitter(); + /** + * Emit when one of the collection's object has changed. + */ + @Output() contentChange = new EventEmitter(); + /** * Whether or not to add an import button to the object elements */ diff --git a/src/app/shared/object-collection/shared/claimed-approved-task-search-result.model.ts b/src/app/shared/object-collection/shared/claimed-approved-task-search-result.model.ts new file mode 100644 index 0000000000..7cacd87048 --- /dev/null +++ b/src/app/shared/object-collection/shared/claimed-approved-task-search-result.model.ts @@ -0,0 +1,8 @@ +import { ClaimedTask } from '../../../core/tasks/models/claimed-task-object.model'; +import { SearchResult } from '../../search/search-result.model'; + +/** + * Represents a search result object of an Approved ClaimedTask object + */ +export class ClaimedApprovedTaskSearchResult extends SearchResult { +} diff --git a/src/app/shared/object-collection/shared/claimed-declined-task-search-result.model.ts b/src/app/shared/object-collection/shared/claimed-declined-task-search-result.model.ts new file mode 100644 index 0000000000..ff775be909 --- /dev/null +++ b/src/app/shared/object-collection/shared/claimed-declined-task-search-result.model.ts @@ -0,0 +1,8 @@ +import { ClaimedTask } from '../../../core/tasks/models/claimed-task-object.model'; +import { SearchResult } from '../../search/search-result.model'; + +/** + * Represents a search result object of a Declined ClaimedTask object + */ +export class ClaimedDeclinedTaskSearchResult extends SearchResult { +} diff --git a/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.scss b/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.scss new file mode 100644 index 0000000000..b9bc65ea45 --- /dev/null +++ b/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.scss @@ -0,0 +1,3 @@ +:host { + width: 100%; +} diff --git a/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.spec.ts b/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.spec.ts index 9cbd8ce6ce..74dc0be623 100644 --- a/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.spec.ts +++ b/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.spec.ts @@ -1,14 +1,12 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { waitForAsync, ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; import { ChangeDetectionStrategy, ComponentFactoryResolver, NO_ERRORS_SCHEMA } from '@angular/core'; import { ListableObjectComponentLoaderComponent } from './listable-object-component-loader.component'; import { ListableObject } from '../listable-object.model'; import { GenericConstructor } from '../../../../core/shared/generic-constructor'; import { Context } from '../../../../core/shared/context.model'; import { ViewMode } from '../../../../core/shared/view-mode.model'; -import * as listableObjectDecorators from './listable-object.decorator'; import { ItemListElementComponent } from '../../../object-list/item-list-element/item-types/item/item-list-element.component'; import { ListableObjectDirective } from './listable-object.directive'; -import { spyOnExported } from '../../../testing/utils.test'; import { TranslateModule } from '@ngx-translate/core'; import { By } from '@angular/platform-browser'; import { Item } from '../../../../core/shared/item.model'; @@ -23,7 +21,7 @@ class TestType extends ListableObject { } } -xdescribe('ListableObjectComponentLoaderComponent', () => { +describe('ListableObjectComponentLoaderComponent', () => { let comp: ListableObjectComponentLoaderComponent; let fixture: ComponentFixture; @@ -32,7 +30,7 @@ xdescribe('ListableObjectComponentLoaderComponent', () => { imports: [TranslateModule.forRoot()], declarations: [ListableObjectComponentLoaderComponent, ItemListElementComponent, ListableObjectDirective], schemas: [NO_ERRORS_SCHEMA], - providers: [ComponentFactoryResolver] + providers: [] }).overrideComponent(ListableObjectComponentLoaderComponent, { set: { changeDetection: ChangeDetectionStrategy.Default, @@ -48,14 +46,14 @@ xdescribe('ListableObjectComponentLoaderComponent', () => { comp.object = new TestType(); comp.viewMode = testViewMode; comp.context = testContext; - spyOnExported(listableObjectDecorators, 'getListableObjectComponent').and.returnValue(ItemListElementComponent); + spyOn(comp, 'getComponent').and.returnValue(ItemListElementComponent as any); fixture.detectChanges(); })); describe('When the component is rendered', () => { it('should call the getListableObjectComponent function with the right types, view mode and context', () => { - expect(listableObjectDecorators.getListableObjectComponent).toHaveBeenCalledWith([testType], testViewMode, testContext); + expect(comp.getComponent).toHaveBeenCalledWith([testType], testViewMode, testContext); }); }); @@ -117,4 +115,20 @@ xdescribe('ListableObjectComponentLoaderComponent', () => { }); }); + describe('When a reloadedObject is emitted', () => { + + it('should re-instantiate the listable component ', fakeAsync(() => { + + spyOn((comp as any), 'instantiateComponent').and.returnValue(null); + + const listableComponent = fixture.debugElement.query(By.css('ds-item-list-element')).componentInstance; + const reloadedObject: any = 'object'; + (listableComponent as any).reloadedObject.emit(reloadedObject); + tick(); + + expect((comp as any).instantiateComponent).toHaveBeenCalledWith(reloadedObject); + })); + + }); + }); diff --git a/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.ts b/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.ts index 14a5459a98..b02ab3cfeb 100644 --- a/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.ts +++ b/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.ts @@ -3,8 +3,10 @@ import { ComponentFactoryResolver, ElementRef, Input, - OnInit, - ViewChild + OnDestroy, OnInit, + Output, ViewChild +, + EventEmitter } from '@angular/core'; import { ListableObject } from '../listable-object.model'; import { ViewMode } from '../../../../core/shared/view-mode.model'; @@ -14,17 +16,20 @@ import { GenericConstructor } from '../../../../core/shared/generic-constructor' import { ListableObjectDirective } from './listable-object.directive'; import { CollectionElementLinkType } from '../../collection-element-link.type'; import { hasValue } from '../../../empty.util'; +import { Subscription } from 'rxjs/internal/Subscription'; +import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; +import { take } from 'rxjs/operators'; import { ThemeService } from '../../../theme-support/theme.service'; @Component({ selector: 'ds-listable-object-component-loader', - // styleUrls: ['./listable-object-component-loader.component.scss'], + styleUrls: ['./listable-object-component-loader.component.scss'], templateUrl: './listable-object-component-loader.component.html' }) /** * Component for determining what component to use depending on the item's relationship type (relationship.type) */ -export class ListableObjectComponentLoaderComponent implements OnInit { +export class ListableObjectComponentLoaderComponent implements OnInit, OnDestroy { /** * The item or metadata to determine the component for */ @@ -81,6 +86,11 @@ export class ListableObjectComponentLoaderComponent implements OnInit { */ @ViewChild('badges', { static: true }) badges: ElementRef; + /** + * Emit when the listable object has been reloaded. + */ + @Output() contentChange = new EventEmitter(); + /** * Whether or not the "Private" badge should be displayed for this listable object */ @@ -91,6 +101,12 @@ export class ListableObjectComponentLoaderComponent implements OnInit { */ withdrawnBadge = false; + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + protected subs: Subscription[] = []; + constructor( private componentFactoryResolver: ComponentFactoryResolver, private themeService: ThemeService @@ -101,9 +117,22 @@ export class ListableObjectComponentLoaderComponent implements OnInit { * Setup the dynamic child component */ ngOnInit(): void { + this.instantiateComponent(this.object); + } + + ngOnDestroy() { + this.subs + .filter((subscription) => hasValue(subscription)) + .forEach((subscription) => subscription.unsubscribe()); + } + + private instantiateComponent(object) { + this.initBadges(); - const componentFactory = this.componentFactoryResolver.resolveComponentFactory(this.getComponent()); + const component = this.getComponent(object.getRenderTypes(), this.viewMode, this.context); + + const componentFactory = this.componentFactoryResolver.resolveComponentFactory(component); const viewContainerRef = this.listableObjectDirective.viewContainerRef; viewContainerRef.clear(); @@ -115,7 +144,7 @@ export class ListableObjectComponentLoaderComponent implements OnInit { [ [this.badges.nativeElement], ]); - (componentRef.instance as any).object = this.object; + (componentRef.instance as any).object = object; (componentRef.instance as any).index = this.index; (componentRef.instance as any).linkType = this.linkType; (componentRef.instance as any).listID = this.listID; @@ -123,6 +152,17 @@ export class ListableObjectComponentLoaderComponent implements OnInit { (componentRef.instance as any).context = this.context; (componentRef.instance as any).viewMode = this.viewMode; (componentRef.instance as any).value = this.value; + + if ((componentRef.instance as any).reloadedObject) { + (componentRef.instance as any).reloadedObject.pipe(take(1)).subscribe((reloadedObject: DSpaceObject) => { + if (reloadedObject) { + componentRef.destroy(); + this.object = reloadedObject; + this.instantiateComponent(reloadedObject); + this.contentChange.emit(reloadedObject); + } + }); + } } /** @@ -142,7 +182,9 @@ export class ListableObjectComponentLoaderComponent implements OnInit { * Fetch the component depending on the item's relationship type, view mode and context * @returns {GenericConstructor} */ - private getComponent(): GenericConstructor { - return getListableObjectComponent(this.object.getRenderTypes(), this.viewMode, this.context, this.themeService.getThemeName()); + getComponent(renderTypes: (string | GenericConstructor)[], + viewMode: ViewMode, + context: Context): GenericConstructor { + return getListableObjectComponent(renderTypes, viewMode, context, this.themeService.getThemeName()); } } diff --git a/src/app/shared/object-collection/shared/mydspace-item-status/my-dspace-item-status-type.ts b/src/app/shared/object-collection/shared/mydspace-item-status/my-dspace-item-status-type.ts index 48a0a6f4a3..5cf4d91b20 100644 --- a/src/app/shared/object-collection/shared/mydspace-item-status/my-dspace-item-status-type.ts +++ b/src/app/shared/object-collection/shared/mydspace-item-status/my-dspace-item-status-type.ts @@ -3,5 +3,7 @@ export enum MyDspaceItemStatusType { VALIDATION = 'mydspace.status.validation', WAITING_CONTROLLER = 'mydspace.status.waiting-for-controller', WORKSPACE = 'mydspace.status.workspace', - ARCHIVED = 'mydspace.status.archived' + ARCHIVED = 'mydspace.status.archived', + DECLINED = 'mydspace.status.declined', + APPROVED = 'mydspace.status.approved', } diff --git a/src/app/shared/object-collection/shared/object-collection-element/abstract-listable-element.component.ts b/src/app/shared/object-collection/shared/object-collection-element/abstract-listable-element.component.ts index 2c944b92ce..7d4e107b2b 100644 --- a/src/app/shared/object-collection/shared/object-collection-element/abstract-listable-element.component.ts +++ b/src/app/shared/object-collection/shared/object-collection-element/abstract-listable-element.component.ts @@ -1,14 +1,16 @@ -import { Component, Input } from '@angular/core'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; import { ListableObject } from '../listable-object.model'; import { CollectionElementLinkType } from '../../collection-element-link.type'; import { Context } from '../../../../core/shared/context.model'; import { ViewMode } from '../../../../core/shared/view-mode.model'; +import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; @Component({ selector: 'ds-abstract-object-element', template: ``, }) export class AbstractListableElementComponent { + /** * The object to render in this list element */ @@ -49,6 +51,11 @@ export class AbstractListableElementComponent { */ @Input() viewMode: ViewMode; + /** + * Emit when the object has been reloaded. + */ + @Output() reloadedObject = new EventEmitter(); + /** * The available link types */ diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.html b/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.html index a03d8c96fe..1d8f599e65 100644 --- a/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.html +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.html @@ -6,5 +6,5 @@ [status]="status"> - + diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.spec.ts b/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.spec.ts index f88a46204f..a6a3e2020b 100644 --- a/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.spec.ts +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.spec.ts @@ -1,5 +1,5 @@ import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ComponentFixture, TestBed, tick, waitForAsync, fakeAsync} from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { of as observableOf } from 'rxjs'; @@ -14,6 +14,7 @@ import { ClaimedTaskSearchResult } from '../../../object-collection/shared/claim import { VarDirective } from '../../../utils/var.directive'; import { LinkService } from '../../../../core/cache/builders/link.service'; import { getMockLinkService } from '../../../mocks/link-service.mock'; +import { By } from '@angular/platform-browser'; let component: ClaimedTaskSearchResultDetailElementComponent; let fixture: ComponentFixture; @@ -98,4 +99,16 @@ describe('ClaimedTaskSearchResultDetailElementComponent', () => { it('should have properly status', () => { expect(component.status).toEqual(MyDspaceItemStatusType.VALIDATION); }); + + it('should forward claimed-task-actions processComplete event to reloadObject event emitter', fakeAsync(() => { + spyOn(component.reloadedObject, 'emit').and.callThrough(); + const actionPayload: any = { reloadedObject: {}}; + + const actionsComponent = fixture.debugElement.query(By.css('ds-claimed-task-actions')); + actionsComponent.triggerEventHandler('processCompleted', actionPayload); + tick(); + + expect(component.reloadedObject.emit).toHaveBeenCalledWith(actionPayload.reloadedObject); + + })); }); diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/pool-search-result/pool-search-result-detail-element.component.html b/src/app/shared/object-detail/my-dspace-result-detail-element/pool-search-result/pool-search-result-detail-element.component.html index 61c897e8d5..232b54d4d9 100644 --- a/src/app/shared/object-detail/my-dspace-result-detail-element/pool-search-result/pool-search-result-detail-element.component.html +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/pool-search-result/pool-search-result-detail-element.component.html @@ -5,5 +5,5 @@ [showSubmitter]="showSubmitter" [status]="status"> - + diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/pool-search-result/pool-search-result-detail-element.component.spec.ts b/src/app/shared/object-detail/my-dspace-result-detail-element/pool-search-result/pool-search-result-detail-element.component.spec.ts index 68eb398f13..f5f19fc041 100644 --- a/src/app/shared/object-detail/my-dspace-result-detail-element/pool-search-result/pool-search-result-detail-element.component.spec.ts +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/pool-search-result/pool-search-result-detail-element.component.spec.ts @@ -1,5 +1,5 @@ import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { waitForAsync, ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { of as observableOf } from 'rxjs'; @@ -14,6 +14,7 @@ import { PoolTaskSearchResult } from '../../../object-collection/shared/pool-tas import { VarDirective } from '../../../utils/var.directive'; import { LinkService } from '../../../../core/cache/builders/link.service'; import { getMockLinkService } from '../../../mocks/link-service.mock'; +import { By } from '@angular/platform-browser'; let component: PoolSearchResultDetailElementComponent; let fixture: ComponentFixture; @@ -99,4 +100,15 @@ describe('PoolSearchResultDetailElementComponent', () => { it('should have properly status', () => { expect(component.status).toEqual(MyDspaceItemStatusType.WAITING_CONTROLLER); }); + + it('should forward pool-task-actions processCompleted event to the reloadedObject event emitter', fakeAsync(() => { + spyOn(component.reloadedObject, 'emit').and.callThrough(); + const actionPayload: any = { reloadedObject: {}}; + const actionsComponents = fixture.debugElement.query(By.css('ds-pool-task-actions')); + actionsComponents.triggerEventHandler('processCompleted', actionPayload); + tick(); + + expect(component.reloadedObject.emit).toHaveBeenCalledWith(actionPayload.reloadedObject); + + })); }); diff --git a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-approved-search-result/claimed-approved-search-result-list-element.component.html b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-approved-search-result/claimed-approved-search-result-list-element.component.html new file mode 100644 index 0000000000..8ebcdbd69a --- /dev/null +++ b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-approved-search-result/claimed-approved-search-result-list-element.component.html @@ -0,0 +1,10 @@ + + + diff --git a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-approved-search-result/claimed-approved-search-result-list-element.component.spec.ts b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-approved-search-result/claimed-approved-search-result-list-element.component.spec.ts new file mode 100644 index 0000000000..e56999472e --- /dev/null +++ b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-approved-search-result/claimed-approved-search-result-list-element.component.spec.ts @@ -0,0 +1,101 @@ +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { of as observableOf } from 'rxjs'; + +import { Item } from '../../../../../core/shared/item.model'; +import { createSuccessfulRemoteDataObject } from '../../../../remote-data.utils'; +import { WorkflowItem } from '../../../../../core/submission/models/workflowitem.model'; +import { ClaimedTask } from '../../../../../core/tasks/models/claimed-task-object.model'; +import { getMockLinkService } from '../../../../mocks/link-service.mock'; +import { VarDirective } from '../../../../utils/var.directive'; +import { TruncatableService } from '../../../../truncatable/truncatable.service'; +import { LinkService } from '../../../../../core/cache/builders/link.service'; +import { MyDspaceItemStatusType } from '../../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; +import { ClaimedApprovedTaskSearchResult } from '../../../../object-collection/shared/claimed-approved-task-search-result.model'; +import { ClaimedApprovedSearchResultListElementComponent } from './claimed-approved-search-result-list-element.component'; + +let component: ClaimedApprovedSearchResultListElementComponent; +let fixture: ComponentFixture; + +const mockResultObject: ClaimedApprovedTaskSearchResult = new ClaimedApprovedTaskSearchResult(); +mockResultObject.hitHighlights = {}; + +const item = Object.assign(new Item(), { + bundles: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.type': [ + { + language: null, + value: 'Article' + } + ], + 'dc.contributor.author': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '2015-06-26' + } + ] + } +}); +const rdItem = createSuccessfulRemoteDataObject(item); +const workflowitem = Object.assign(new WorkflowItem(), { item: observableOf(rdItem) }); +const rdWorkflowitem = createSuccessfulRemoteDataObject(workflowitem); +mockResultObject.indexableObject = Object.assign(new ClaimedTask(), { workflowitem: observableOf(rdWorkflowitem) }); +const linkService = getMockLinkService(); + +describe('ClaimedApprovedSearchResultListElementComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule], + declarations: [ClaimedApprovedSearchResultListElementComponent, VarDirective], + providers: [ + { provide: TruncatableService, useValue: {} }, + { provide: LinkService, useValue: linkService } + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(ClaimedApprovedSearchResultListElementComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(ClaimedApprovedSearchResultListElementComponent); + component = fixture.componentInstance; + })); + + beforeEach(() => { + component.dso = mockResultObject.indexableObject; + fixture.detectChanges(); + }); + + it('should init workflowitem properly', (done) => { + component.workflowitemRD$.subscribe((workflowitemRD) => { + expect(linkService.resolveLinks).toHaveBeenCalledWith( + component.dso, + jasmine.objectContaining({ name: 'workflowitem' }), + jasmine.objectContaining({ name: 'action' }) + ); + expect(workflowitemRD.payload).toEqual(workflowitem); + done(); + }); + }); + + it('should have properly status', () => { + expect(component.status).toEqual(MyDspaceItemStatusType.APPROVED); + }); + +}); diff --git a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-approved-search-result/claimed-approved-search-result-list-element.component.ts b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-approved-search-result/claimed-approved-search-result-list-element.component.ts new file mode 100644 index 0000000000..5423722160 --- /dev/null +++ b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-approved-search-result/claimed-approved-search-result-list-element.component.ts @@ -0,0 +1,68 @@ +import { Component } from '@angular/core'; +import { ViewMode } from '../../../../../core/shared/view-mode.model'; +import { ClaimedApprovedTaskSearchResult } from '../../../../object-collection/shared/claimed-approved-task-search-result.model'; +import { listableObjectComponent } from '../../../../object-collection/shared/listable-object/listable-object.decorator'; +import { LinkService } from '../../../../../core/cache/builders/link.service'; +import { TruncatableService } from '../../../../truncatable/truncatable.service'; +import { MyDspaceItemStatusType } from '../../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; +import { Observable } from 'rxjs'; +import { RemoteData } from '../../../../../core/data/remote-data'; +import { WorkflowItem } from '../../../../../core/submission/models/workflowitem.model'; +import { followLink } from '../../../../utils/follow-link-config.model'; +import { SearchResultListElementComponent } from '../../../search-result-list-element/search-result-list-element.component'; +import { ClaimedTaskSearchResult} from '../../../../object-collection/shared/claimed-task-search-result.model'; +import { ClaimedTask } from '../../../../../core/tasks/models/claimed-task-object.model'; + +/** + * This component renders claimed task approved object for the search result in the list view. + */ +@Component({ + selector: 'ds-claimed-approved-search-result-list-element', + styleUrls: ['../../../search-result-list-element/search-result-list-element.component.scss'], + templateUrl: './claimed-approved-search-result-list-element.component.html' +}) +@listableObjectComponent(ClaimedApprovedTaskSearchResult, ViewMode.ListElement) +export class ClaimedApprovedSearchResultListElementComponent extends SearchResultListElementComponent { + + /** + * A boolean representing if to show submitter information + */ + public showSubmitter = true; + + /** + * Represent item's status + */ + public status = MyDspaceItemStatusType.APPROVED; + + /** + * The workflowitem object that belonging to the result object + */ + public workflowitemRD$: Observable>; + + public constructor( + protected linkService: LinkService, + protected truncatableService: TruncatableService + ) { + super(truncatableService); + } + + /** + * Initialize all instance variables + */ + ngOnInit() { + super.ngOnInit(); + this.linkService.resolveLinks(this.dso, + followLink('workflowitem', + null, + true, + false, + true, + followLink('item'), + followLink('submitter') + ), + followLink('action') + ); + this.workflowitemRD$ = this.dso.workflowitem as Observable>; + } + +} diff --git a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-declined-search-result/claimed-declided-search-result-list-element.component.spec.ts b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-declined-search-result/claimed-declided-search-result-list-element.component.spec.ts new file mode 100644 index 0000000000..8a8d542063 --- /dev/null +++ b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-declined-search-result/claimed-declided-search-result-list-element.component.spec.ts @@ -0,0 +1,101 @@ +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { of as observableOf } from 'rxjs'; + +import { ClaimedDeclinedSearchResultListElementComponent } from './claimed-declined-search-result-list-element.component'; +import { ClaimedDeclinedTaskSearchResult } from '../../../../object-collection/shared/claimed-declined-task-search-result.model'; +import { Item } from '../../../../../core/shared/item.model'; +import { createSuccessfulRemoteDataObject } from '../../../../remote-data.utils'; +import { WorkflowItem } from '../../../../../core/submission/models/workflowitem.model'; +import { ClaimedTask } from '../../../../../core/tasks/models/claimed-task-object.model'; +import { getMockLinkService } from '../../../../mocks/link-service.mock'; +import { VarDirective } from '../../../../utils/var.directive'; +import { TruncatableService } from '../../../../truncatable/truncatable.service'; +import { LinkService } from '../../../../../core/cache/builders/link.service'; +import { MyDspaceItemStatusType } from '../../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; + +let component: ClaimedDeclinedSearchResultListElementComponent; +let fixture: ComponentFixture; + +const mockResultObject: ClaimedDeclinedTaskSearchResult = new ClaimedDeclinedTaskSearchResult(); +mockResultObject.hitHighlights = {}; + +const item = Object.assign(new Item(), { + bundles: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.type': [ + { + language: null, + value: 'Article' + } + ], + 'dc.contributor.author': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'dc.date.issued': [ + { + language: null, + value: '2015-06-26' + } + ] + } +}); +const rdItem = createSuccessfulRemoteDataObject(item); +const workflowitem = Object.assign(new WorkflowItem(), { item: observableOf(rdItem) }); +const rdWorkflowitem = createSuccessfulRemoteDataObject(workflowitem); +mockResultObject.indexableObject = Object.assign(new ClaimedTask(), { workflowitem: observableOf(rdWorkflowitem) }); +const linkService = getMockLinkService(); + +describe('ClaimedDeclinedSearchResultListElementComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule], + declarations: [ClaimedDeclinedSearchResultListElementComponent, VarDirective], + providers: [ + { provide: TruncatableService, useValue: {} }, + { provide: LinkService, useValue: linkService } + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(ClaimedDeclinedSearchResultListElementComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(ClaimedDeclinedSearchResultListElementComponent); + component = fixture.componentInstance; + })); + + beforeEach(() => { + component.dso = mockResultObject.indexableObject; + fixture.detectChanges(); + }); + + it('should init workflowitem properly', (done) => { + component.workflowitemRD$.subscribe((workflowitemRD) => { + expect(linkService.resolveLinks).toHaveBeenCalledWith( + component.dso, + jasmine.objectContaining({ name: 'workflowitem' }), + jasmine.objectContaining({ name: 'action' }) + ); + expect(workflowitemRD.payload).toEqual(workflowitem); + done(); + }); + }); + + it('should have properly status', () => { + expect(component.status).toEqual(MyDspaceItemStatusType.DECLINED); + }); + +}); diff --git a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-declined-search-result/claimed-declined-search-result-list-element.component.html b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-declined-search-result/claimed-declined-search-result-list-element.component.html new file mode 100644 index 0000000000..f20696823c --- /dev/null +++ b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-declined-search-result/claimed-declined-search-result-list-element.component.html @@ -0,0 +1,10 @@ + + + diff --git a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-declined-search-result/claimed-declined-search-result-list-element.component.ts b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-declined-search-result/claimed-declined-search-result-list-element.component.ts new file mode 100644 index 0000000000..7db12c1725 --- /dev/null +++ b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-declined-search-result/claimed-declined-search-result-list-element.component.ts @@ -0,0 +1,68 @@ +import { Component } from '@angular/core'; + +import { listableObjectComponent } from '../../../../object-collection/shared/listable-object/listable-object.decorator'; +import { ClaimedDeclinedTaskSearchResult } from '../../../../object-collection/shared/claimed-declined-task-search-result.model'; +import { ViewMode } from '../../../../../core/shared/view-mode.model'; +import { LinkService } from '../../../../../core/cache/builders/link.service'; +import { TruncatableService } from '../../../../truncatable/truncatable.service'; +import { MyDspaceItemStatusType } from '../../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; +import { Observable } from 'rxjs'; +import { RemoteData } from '../../../../../core/data/remote-data'; +import { WorkflowItem } from '../../../../../core/submission/models/workflowitem.model'; +import { followLink } from '../../../../utils/follow-link-config.model'; +import { SearchResultListElementComponent } from '../../../search-result-list-element/search-result-list-element.component'; +import { ClaimedTaskSearchResult } from '../../../../object-collection/shared/claimed-task-search-result.model'; +import { ClaimedTask } from '../../../../../core/tasks/models/claimed-task-object.model'; + +/** + * This component renders claimed task declined object for the search result in the list view. + */ +@Component({ + selector: 'ds-claimed-declined-search-result-list-element', + styleUrls: ['../../../search-result-list-element/search-result-list-element.component.scss'], + templateUrl: './claimed-declined-search-result-list-element.component.html' +}) +@listableObjectComponent(ClaimedDeclinedTaskSearchResult, ViewMode.ListElement) +export class ClaimedDeclinedSearchResultListElementComponent extends SearchResultListElementComponent { + + /** + * A boolean representing if to show submitter information + */ + public showSubmitter = true; + + /** + * Represent item's status + */ + public status = MyDspaceItemStatusType.DECLINED; + + /** + * The workflowitem object that belonging to the result object + */ + public workflowitemRD$: Observable>; + + public constructor( + protected linkService: LinkService, + protected truncatableService: TruncatableService + ) { + super(truncatableService); + } + + /** + * Initialize all instance variables + */ + ngOnInit() { + super.ngOnInit(); + this.linkService.resolveLinks(this.dso, + followLink('workflowitem', + null, + true, + false, + true, + followLink('item'), + followLink('submitter') + ), + followLink('action')); + this.workflowitemRD$ = this.dso.workflowitem as Observable>; + } + +} diff --git a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.html b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.html index b35a4f8741..30aac357a4 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.html +++ b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.html @@ -4,7 +4,6 @@ [object]="object" [showSubmitter]="showSubmitter" [status]="status"> - - + diff --git a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.spec.ts b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.spec.ts index 151d205273..5dad421f68 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.spec.ts +++ b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.spec.ts @@ -1,5 +1,5 @@ import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { of as observableOf } from 'rxjs'; @@ -15,12 +15,11 @@ import { TruncatableService } from '../../../truncatable/truncatable.service'; import { VarDirective } from '../../../utils/var.directive'; import { LinkService } from '../../../../core/cache/builders/link.service'; import { getMockLinkService } from '../../../mocks/link-service.mock'; +import { By } from '@angular/platform-browser'; let component: ClaimedSearchResultListElementComponent; let fixture: ComponentFixture; -const compIndex = 1; - const mockResultObject: ClaimedTaskSearchResult = new ClaimedTaskSearchResult(); mockResultObject.hitHighlights = {}; @@ -99,4 +98,16 @@ describe('ClaimedSearchResultListElementComponent', () => { it('should have properly status', () => { expect(component.status).toEqual(MyDspaceItemStatusType.VALIDATION); }); + + it('should forward claimed-task-actions processComplete event to reloadObject event emitter', fakeAsync(() => { + spyOn(component.reloadedObject, 'emit').and.callThrough(); + const actionPayload: any = { reloadedObject: {}}; + + const actionsComponent = fixture.debugElement.query(By.css('ds-claimed-task-actions')); + actionsComponent.triggerEventHandler('processCompleted', actionPayload); + tick(); + + expect(component.reloadedObject.emit).toHaveBeenCalledWith(actionPayload.reloadedObject); + + })); }); diff --git a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.ts b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.ts index 788a373f83..cef1056401 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.ts +++ b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.ts @@ -1,30 +1,23 @@ import { Component } from '@angular/core'; -import { Location, LocationStrategy, PathLocationStrategy } from '@angular/common'; - -import { Observable } from 'rxjs'; import { ViewMode } from '../../../../core/shared/view-mode.model'; -import { RemoteData } from '../../../../core/data/remote-data'; -import { WorkflowItem } from '../../../../core/submission/models/workflowitem.model'; -import { ClaimedTask } from '../../../../core/tasks/models/claimed-task-object.model'; -import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; import { listableObjectComponent } from '../../../object-collection/shared/listable-object/listable-object.decorator'; import { ClaimedTaskSearchResult } from '../../../object-collection/shared/claimed-task-search-result.model'; -import { SearchResultListElementComponent } from '../../search-result-list-element/search-result-list-element.component'; -import { followLink } from '../../../utils/follow-link-config.model'; import { LinkService } from '../../../../core/cache/builders/link.service'; import { TruncatableService } from '../../../truncatable/truncatable.service'; +import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; +import { Observable } from 'rxjs'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { WorkflowItem } from '../../../../core/submission/models/workflowitem.model'; +import { followLink } from '../../../utils/follow-link-config.model'; +import { SearchResultListElementComponent } from '../../search-result-list-element/search-result-list-element.component'; +import { ClaimedTask } from '../../../../core/tasks/models/claimed-task-object.model'; -/** - * This component renders claimed task object for the search result in the list view. - */ @Component({ selector: 'ds-claimed-search-result-list-element', styleUrls: ['../../search-result-list-element/search-result-list-element.component.scss'], - templateUrl: './claimed-search-result-list-element.component.html', - providers: [Location, { provide: LocationStrategy, useClass: PathLocationStrategy }] + templateUrl: './claimed-search-result-list-element.component.html' }) - @listableObjectComponent(ClaimedTaskSearchResult, ViewMode.ListElement) export class ClaimedSearchResultListElementComponent extends SearchResultListElementComponent { @@ -43,7 +36,7 @@ export class ClaimedSearchResultListElementComponent extends SearchResultListEle */ public workflowitemRD$: Observable>; - constructor( + public constructor( protected linkService: LinkService, protected truncatableService: TruncatableService ) { @@ -60,4 +53,5 @@ export class ClaimedSearchResultListElementComponent extends SearchResultListEle ), followLink('action')); this.workflowitemRD$ = this.dso.workflowitem as Observable>; } + } diff --git a/src/app/shared/object-list/my-dspace-result-list-element/item-search-result/item-search-result-list-element-submission.component.html b/src/app/shared/object-list/my-dspace-result-list-element/item-search-result/item-search-result-list-element-submission.component.html index e1b1435481..e422a84641 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/item-search-result/item-search-result-list-element-submission.component.html +++ b/src/app/shared/object-list/my-dspace-result-list-element/item-search-result/item-search-result-list-element-submission.component.html @@ -2,4 +2,4 @@ [object]="object" [status]="status"> - + diff --git a/src/app/shared/object-list/my-dspace-result-list-element/item-search-result/item-search-result-list-element-submission.component.spec.ts b/src/app/shared/object-list/my-dspace-result-list-element/item-search-result/item-search-result-list-element-submission.component.spec.ts index 07174ac74f..e2017e8748 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/item-search-result/item-search-result-list-element-submission.component.spec.ts +++ b/src/app/shared/object-list/my-dspace-result-list-element/item-search-result/item-search-result-list-element-submission.component.spec.ts @@ -1,5 +1,5 @@ import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { waitForAsync, ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { of as observableOf } from 'rxjs'; @@ -9,12 +9,11 @@ import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspa import { ItemSearchResult } from '../../../object-collection/shared/item-search-result.model'; import { ItemSearchResultListElementSubmissionComponent } from './item-search-result-list-element-submission.component'; import { TruncatableService } from '../../../truncatable/truncatable.service'; +import { By } from '@angular/platform-browser'; let component: ItemSearchResultListElementSubmissionComponent; let fixture: ComponentFixture; -const compIndex = 1; - const mockResultObject: ItemSearchResult = new ItemSearchResult(); mockResultObject.hitHighlights = {}; @@ -75,4 +74,16 @@ describe('ItemMyDSpaceResultListElementComponent', () => { it('should have properly status', () => { expect(component.status).toEqual(MyDspaceItemStatusType.ARCHIVED); }); + + it('should forward item-actions processComplete event to reloadObject event emitter', fakeAsync(() => { + spyOn(component.reloadedObject, 'emit').and.callThrough(); + const actionPayload: any = { reloadedObject: {}}; + + const actionsComponent = fixture.debugElement.query(By.css('ds-item-actions')); + actionsComponent.triggerEventHandler('processCompleted', actionPayload); + tick(); + + expect(component.reloadedObject.emit).toHaveBeenCalledWith(actionPayload.reloadedObject); + + })); }); diff --git a/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.html b/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.html index 9358e35bed..25e2c4f8c4 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.html +++ b/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.html @@ -4,6 +4,5 @@ [object]="object" [showSubmitter]="showSubmitter" [status]="status"> - - + diff --git a/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.spec.ts b/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.spec.ts index 74c64ca254..e55b45aed7 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.spec.ts +++ b/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.spec.ts @@ -1,5 +1,5 @@ import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { waitForAsync, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { of as observableOf } from 'rxjs'; @@ -15,12 +15,11 @@ import { TruncatableService } from '../../../truncatable/truncatable.service'; import { VarDirective } from '../../../utils/var.directive'; import { LinkService } from '../../../../core/cache/builders/link.service'; import { getMockLinkService } from '../../../mocks/link-service.mock'; +import { By } from '@angular/platform-browser'; let component: PoolSearchResultListElementComponent; let fixture: ComponentFixture; -const compIndex = 1; - const mockResultObject: PoolTaskSearchResult = new PoolTaskSearchResult(); mockResultObject.hitHighlights = {}; @@ -99,4 +98,15 @@ describe('PoolSearchResultListElementComponent', () => { it('should have properly status', () => { expect(component.status).toEqual(MyDspaceItemStatusType.WAITING_CONTROLLER); }); + + it('should forward pool-task-actions processCompleted event to the reloadedObject event emitter', fakeAsync(() => { + spyOn(component.reloadedObject, 'emit').and.callThrough(); + const actionPayload: any = { reloadedObject: {}}; + const actionsComponents = fixture.debugElement.query(By.css('ds-pool-task-actions')); + actionsComponents.triggerEventHandler('processCompleted', actionPayload); + tick(); + + expect(component.reloadedObject.emit).toHaveBeenCalledWith(actionPayload.reloadedObject); + + })); }); diff --git a/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.ts b/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.ts index bf101e651c..b130d5001c 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.ts +++ b/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.ts @@ -63,4 +63,5 @@ export class PoolSearchResultListElementComponent extends SearchResultListElemen ), followLink('action')); this.workflowitemRD$ = this.dso.workflowitem as Observable>; } + } diff --git a/src/app/shared/object-list/my-dspace-result-list-element/workflow-item-search-result/workflow-item-search-result-list-element.component.html b/src/app/shared/object-list/my-dspace-result-list-element/workflow-item-search-result/workflow-item-search-result-list-element.component.html index ced2846b4b..74fc5fd06d 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/workflow-item-search-result/workflow-item-search-result-list-element.component.html +++ b/src/app/shared/object-list/my-dspace-result-list-element/workflow-item-search-result/workflow-item-search-result-list-element.component.html @@ -4,7 +4,7 @@ [object]="object" [status]="status"> - + ; -const compIndex = 1; - const mockResultObject: WorkflowItemSearchResult = new WorkflowItemSearchResult(); mockResultObject.hitHighlights = {}; @@ -96,4 +95,16 @@ describe('WorkflowItemSearchResultListElementComponent', () => { it('should have properly status', () => { expect(component.status).toEqual(MyDspaceItemStatusType.WORKFLOW); }); + + it('should forward workflowitem-actions processCompleted event to the reloadedObject event emitter', fakeAsync(() => { + spyOn(component.reloadedObject, 'emit').and.callThrough(); + const actionPayload: any = { reloadedObject: {}}; + + const actionsComponent = fixture.debugElement.query(By.css('ds-workflowitem-actions')); + actionsComponent.triggerEventHandler('processCompleted', actionPayload); + tick(); + + expect(component.reloadedObject.emit).toHaveBeenCalledWith(actionPayload.reloadedObject); + + })); }); diff --git a/src/app/shared/object-list/my-dspace-result-list-element/workspace-item-search-result/workspace-item-search-result-list-element.component.html b/src/app/shared/object-list/my-dspace-result-list-element/workspace-item-search-result/workspace-item-search-result-list-element.component.html index 8966b4b1d8..41d95b87af 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/workspace-item-search-result/workspace-item-search-result-list-element.component.html +++ b/src/app/shared/object-list/my-dspace-result-list-element/workspace-item-search-result/workspace-item-search-result-list-element.component.html @@ -4,7 +4,7 @@ [object]="object" [status]="status"> - + ; -const compIndex = 1; - const mockResultObject: WorkflowItemSearchResult = new WorkflowItemSearchResult(); mockResultObject.hitHighlights = {}; @@ -95,4 +94,17 @@ describe('WorkspaceItemSearchResultListElementComponent', () => { it('should have properly status', () => { expect(component.status).toEqual(MyDspaceItemStatusType.WORKSPACE); }); + + it('should forward workspaceitem-actions processCompleted event to the reloadedObject event emitter', fakeAsync(() => { + + spyOn(component.reloadedObject, 'emit').and.callThrough(); + const actionPayload: any = { reloadedObject: {}}; + + const actionsComponent = fixture.debugElement.query(By.css('ds-workspaceitem-actions')); + actionsComponent.triggerEventHandler('processCompleted', actionPayload); + tick(); + + expect(component.reloadedObject.emit).toHaveBeenCalledWith(actionPayload.reloadedObject); + + })); }); diff --git a/src/app/shared/object-list/object-list.component.html b/src/app/shared/object-list/object-list.component.html index 4aecaaac8f..331ff1cb28 100644 --- a/src/app/shared/object-list/object-list.component.html +++ b/src/app/shared/object-list/object-list.component.html @@ -22,7 +22,9 @@ [importConfig]="importConfig" (importObject)="importObject.emit($event)"> + [listID]="selectionConfig?.listId" + (contentChange)="contentChange.emit()" + > diff --git a/src/app/shared/object-list/object-list.component.ts b/src/app/shared/object-list/object-list.component.ts index b58c8b358e..6f4caae939 100644 --- a/src/app/shared/object-list/object-list.component.ts +++ b/src/app/shared/object-list/object-list.component.ts @@ -76,6 +76,11 @@ export class ObjectListComponent { */ @Input() importConfig: { importLabel: string }; + /** + * Emit when one of the listed object has changed. + */ + @Output() contentChange = new EventEmitter(); + /** * The current listable objects */ diff --git a/src/app/shared/object-list/search-result-list-element/search-result-list-element.component.ts b/src/app/shared/object-list/search-result-list-element/search-result-list-element.component.ts index 82bd21cec6..3ad4f5e7e6 100644 --- a/src/app/shared/object-list/search-result-list-element/search-result-list-element.component.ts +++ b/src/app/shared/object-list/search-result-list-element/search-result-list-element.component.ts @@ -12,7 +12,6 @@ import { Metadata } from '../../../core/shared/metadata.utils'; selector: 'ds-search-result-list-element', template: `` }) - export class SearchResultListElementComponent, K extends DSpaceObject> extends AbstractListableElementComponent implements OnInit { /** * The DSpaceObject of the search result diff --git a/src/app/shared/search/search-filters/search-filters.component.spec.ts b/src/app/shared/search/search-filters/search-filters.component.spec.ts index aaea82df27..2dd810db63 100644 --- a/src/app/shared/search/search-filters/search-filters.component.spec.ts +++ b/src/app/shared/search/search-filters/search-filters.component.spec.ts @@ -7,7 +7,7 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { SearchFilterService } from '../../../core/shared/search/search-filter.service'; import { SearchFiltersComponent } from './search-filters.component'; import { SearchService } from '../../../core/shared/search/search.service'; -import { of as observableOf } from 'rxjs'; +import { of as observableOf, Subject } from 'rxjs'; import { SEARCH_CONFIG_SERVICE } from '../../../+my-dspace-page/my-dspace-page.component'; import { SearchConfigurationServiceStub } from '../../testing/search-configuration-service.stub'; @@ -66,4 +66,26 @@ describe('SearchFiltersComponent', () => { }); }); + describe('when refreshSearch observable is present and emit events', () => { + + let refreshFiltersEmitter: Subject; + + beforeEach(() => { + spyOn(comp, 'initFilters').and.callFake(() => { /****/}); + + refreshFiltersEmitter = new Subject(); + comp.refreshFilters = refreshFiltersEmitter.asObservable(); + comp.ngOnInit(); + }); + + it('should reinitialize search filters', () => { + + expect(comp.initFilters).toHaveBeenCalledTimes(1); + + refreshFiltersEmitter.next(); + + expect(comp.initFilters).toHaveBeenCalledTimes(2); + }); + }); + }); diff --git a/src/app/shared/search/search-filters/search-filters.component.ts b/src/app/shared/search/search-filters/search-filters.component.ts index 5daa0f17e0..348af6743d 100644 --- a/src/app/shared/search/search-filters/search-filters.component.ts +++ b/src/app/shared/search/search-filters/search-filters.component.ts @@ -1,4 +1,4 @@ -import { Component, Inject, Input, OnInit } from '@angular/core'; +import { Component, Inject, Input, OnDestroy, OnInit } from '@angular/core'; import { Observable } from 'rxjs'; import { map, switchMap } from 'rxjs/operators'; @@ -12,17 +12,19 @@ import { getFirstSucceededRemoteData } from '../../../core/shared/operators'; import { SEARCH_CONFIG_SERVICE } from '../../../+my-dspace-page/my-dspace-page.component'; import { currentPath } from '../../utils/route.utils'; import { Router } from '@angular/router'; +import { hasValue } from '../../empty.util'; @Component({ selector: 'ds-search-filters', styleUrls: ['./search-filters.component.scss'], templateUrl: './search-filters.component.html', + }) /** * This component represents the part of the search sidebar that contains filters. */ -export class SearchFiltersComponent implements OnInit { +export class SearchFiltersComponent implements OnInit, OnDestroy { /** * An observable containing configuration about which filters are shown and how they are shown */ @@ -39,11 +41,18 @@ export class SearchFiltersComponent implements OnInit { */ @Input() inPlaceSearch; + /** + * Emits when the search filters values may be stale, and so they must be refreshed. + */ + @Input() refreshFilters: Observable; + /** * Link to the search page */ searchLink: string; + subs = []; + /** * Initialize instance variables * @param {SearchService} searchService @@ -58,9 +67,12 @@ export class SearchFiltersComponent implements OnInit { } ngOnInit(): void { - this.filters = this.searchConfigService.searchOptions.pipe( - switchMap((options) => this.searchService.getConfig(options.scope, options.configuration).pipe(getFirstSucceededRemoteData())), - ); + + this.initFilters(); + + if (this.refreshFilters) { + this.subs.push(this.refreshFilters.subscribe(() => this.initFilters())); + } this.clearParams = this.searchConfigService.getCurrentFrontendFilters().pipe(map((filters) => { Object.keys(filters).forEach((f) => filters[f] = null); @@ -69,6 +81,12 @@ export class SearchFiltersComponent implements OnInit { this.searchLink = this.getSearchLink(); } + initFilters() { + this.filters = this.searchConfigService.searchOptions.pipe( + switchMap((options) => this.searchService.getConfig(options.scope, options.configuration).pipe(getFirstSucceededRemoteData())), + ); + } + /** * @returns {string} The base path to the search page, or the current page when inPlaceSearch is true */ @@ -85,4 +103,12 @@ export class SearchFiltersComponent implements OnInit { trackUpdate(index, config: SearchFilterConfig) { return config ? config.name : undefined; } + + ngOnDestroy() { + this.subs.forEach((sub) => { + if (hasValue(sub)) { + sub.unsubscribe(); + } + }); + } } diff --git a/src/app/shared/search/search-sidebar/search-sidebar.component.html b/src/app/shared/search/search-sidebar/search-sidebar.component.html index 638aed7834..74abeadfd8 100644 --- a/src/app/shared/search/search-sidebar/search-sidebar.component.html +++ b/src/app/shared/search/search-sidebar/search-sidebar.component.html @@ -11,7 +11,7 @@ diff --git a/src/app/shared/search/search-sidebar/search-sidebar.component.ts b/src/app/shared/search/search-sidebar/search-sidebar.component.ts index 42e8a444bc..2060e0f345 100644 --- a/src/app/shared/search/search-sidebar/search-sidebar.component.ts +++ b/src/app/shared/search/search-sidebar/search-sidebar.component.ts @@ -1,6 +1,7 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; import { SearchConfigurationOption } from '../search-switch-configuration/search-configuration-option.model'; +import { Observable } from 'rxjs'; /** * This component renders a simple item page. @@ -44,6 +45,11 @@ export class SearchSidebarComponent { */ @Input() inPlaceSearch; + /** + * Emits when the search filters values may be stale, and so they must be refreshed. + */ + @Input() refreshFilters: Observable; + /** * Emits event when the user clicks a button to open or close the sidebar */ diff --git a/src/app/shared/uploader/uploader.component.spec.ts b/src/app/shared/uploader/uploader.component.spec.ts index d33c27b897..6ff54578b5 100644 --- a/src/app/shared/uploader/uploader.component.spec.ts +++ b/src/app/shared/uploader/uploader.component.spec.ts @@ -10,6 +10,10 @@ import { UploaderComponent } from './uploader.component'; import { FileUploadModule } from 'ng2-file-upload'; import { TranslateModule } from '@ngx-translate/core'; import { createTestComponent } from '../testing/utils.test'; +import { HttpXsrfTokenExtractor } from '@angular/common/http'; +import { CookieService } from '../../core/services/cookie.service'; +import { CookieServiceMock } from '../mocks/cookie.service.mock'; +import { HttpXsrfTokenExtractorMock } from '../mocks/http-xsrf-token-extractor.mock'; describe('Chips component', () => { @@ -33,7 +37,9 @@ describe('Chips component', () => { ChangeDetectorRef, ScrollToService, UploaderComponent, - UploaderService + UploaderService, + { provide: HttpXsrfTokenExtractor, useValue: new HttpXsrfTokenExtractorMock('mock-token') }, + { provide: CookieService, useValue: new CookieServiceMock() }, ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }); diff --git a/src/app/shared/uploader/uploader.component.ts b/src/app/shared/uploader/uploader.component.ts index ac5c5c65a2..4ee17ac87b 100644 --- a/src/app/shared/uploader/uploader.component.ts +++ b/src/app/shared/uploader/uploader.component.ts @@ -18,6 +18,9 @@ import { UploaderOptions } from './uploader-options.model'; import { hasValue, isNotEmpty, isUndefined } from '../empty.util'; import { UploaderService } from './uploader.service'; import { UploaderProperties } from './uploader-properties.model'; +import { HttpXsrfTokenExtractor } from '@angular/common/http'; +import { XSRF_REQUEST_HEADER, XSRF_RESPONSE_HEADER, XSRF_COOKIE } from '../../core/xsrf/xsrf.interceptor'; +import { CookieService } from '../../core/services/cookie.service'; @Component({ selector: 'ds-uploader', @@ -91,7 +94,9 @@ export class UploaderComponent { } } - constructor(private cdr: ChangeDetectorRef, private scrollToService: ScrollToService, private uploaderService: UploaderService) { + constructor(private cdr: ChangeDetectorRef, private scrollToService: ScrollToService, + private uploaderService: UploaderService, private tokenExtractor: HttpXsrfTokenExtractor, + private cookieService: CookieService) { } /** @@ -108,7 +113,7 @@ export class UploaderComponent { removeAfterUpload: true, autoUpload: this.uploadFilesOptions.autoUpload, method: this.uploadFilesOptions.method, - queueLimit: this.uploadFilesOptions.maxFileNumber + queueLimit: this.uploadFilesOptions.maxFileNumber, }); if (isUndefined(this.enableDragOverDocument)) { @@ -123,10 +128,6 @@ export class UploaderComponent { } ngAfterViewInit() { - // Maybe to remove: needed to avoid CORS issue with our temp upload server - this.uploader.onAfterAddingFile = ((item) => { - item.withCredentials = false; - }); this.uploader.onAfterAddingAll = ((items) => { this.onFileSelected.emit(items); }); @@ -137,6 +138,8 @@ export class UploaderComponent { if (item.url !== this.uploader.options.url) { item.url = this.uploader.options.url; } + // Ensure the current XSRF token is included in every upload request (token may change between items uploaded) + this.uploader.options.headers = [{ name: XSRF_REQUEST_HEADER, value: this.tokenExtractor.getToken() }]; this.onBeforeUpload(); this.isOverDocumentDropZone = observableOf(false); @@ -152,12 +155,30 @@ export class UploaderComponent { }; } this.uploader.onCompleteItem = (item: any, response: any, status: any, headers: any) => { + // Check for a changed XSRF token in response & save new token if found (to both cookie & header for next request) + // NOTE: this is only necessary because ng2-file-upload doesn't use an Http service and therefore never + // triggers our xsrf.interceptor.ts. See this bug: https://github.com/valor-software/ng2-file-upload/issues/950 + const token = headers[XSRF_RESPONSE_HEADER.toLowerCase()]; + if (isNotEmpty(token)) { + this.saveXsrfToken(token); + this.uploader.options.headers = [{ name: XSRF_REQUEST_HEADER, value: this.tokenExtractor.getToken() }]; + } + if (isNotEmpty(response)) { const responsePath = JSON.parse(response); this.onCompleteItem.emit(responsePath); } }; this.uploader.onErrorItem = (item: any, response: any, status: any, headers: any) => { + // Check for a changed XSRF token in response & save new token if found (to both cookie & header for next request) + // NOTE: this is only necessary because ng2-file-upload doesn't use an Http service and therefore never + // triggers our xsrf.interceptor.ts. See this bug: https://github.com/valor-software/ng2-file-upload/issues/950 + const token = headers[XSRF_RESPONSE_HEADER.toLowerCase()]; + if (isNotEmpty(token)) { + this.saveXsrfToken(token); + this.uploader.options.headers = [{ name: XSRF_REQUEST_HEADER, value: this.tokenExtractor.getToken() }]; + } + this.onUploadError.emit({ item: item, response: response, status: status, headers: headers }); this.uploader.cancelAll(); }; @@ -201,4 +222,18 @@ export class UploaderComponent { } } + /** + * Save XSRF token found in response. This is a temporary copy of the method in xsrf.interceptor.ts + * It can be removed once ng2-file-upload supports interceptors (see https://github.com/valor-software/ng2-file-upload/issues/950), + * or we switch to a new upload library (see https://github.com/DSpace/dspace-angular/issues/820) + * @param token token found + */ + private saveXsrfToken(token: string) { + // Save token value as a *new* value of our client-side XSRF-TOKEN cookie. + // This is the cookie that is parsed by Angular's tokenExtractor(), + // which we will send back in the X-XSRF-TOKEN header per Angular best practices. + this.cookieService.remove(XSRF_COOKIE); + this.cookieService.set(XSRF_COOKIE, token); + } + }