diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 941a6b769b..b16930e819 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -173,6 +173,11 @@ import { LinkHeadService } from './services/link-head.service'; import { ResearcherProfileService } from './profile/researcher-profile.service'; import { ProfileClaimService } from '../profile-page/profile-claim/profile-claim.service'; import { ResearcherProfile } from './profile/model/researcher-profile.model'; +import { OrcidQueueService } from './orcid/orcid-queue.service'; +import { OrcidHistoryDataService } from './orcid/orcid-history-data.service'; +import { OrcidQueue } from './orcid/model/orcid-queue.model'; +import { OrcidHistory } from './orcid/model/orcid-history.model'; +import { OrcidAuthService } from './orcid/orcid-auth.service'; /** * When not in production, endpoint responses can be mocked for testing purposes @@ -300,7 +305,10 @@ const PROVIDERS = [ GroupDataService, FeedbackDataService, ResearcherProfileService, - ProfileClaimService + ProfileClaimService, + OrcidAuthService, + OrcidQueueService, + OrcidHistoryDataService, ]; /** @@ -362,7 +370,10 @@ export const models = SearchConfig, SubmissionAccessesModel, AccessStatusObject, - ResearcherProfile + ResearcherProfile, + OrcidQueue, + OrcidHistory, + AccessStatusObject ]; @NgModule({ diff --git a/src/app/core/orcid/model/orcid-history.model.ts b/src/app/core/orcid/model/orcid-history.model.ts new file mode 100644 index 0000000000..ef8f30e0a3 --- /dev/null +++ b/src/app/core/orcid/model/orcid-history.model.ts @@ -0,0 +1,89 @@ +import { autoserialize, deserialize } from 'cerialize'; +import { typedObject } from '../../cache/builders/build-decorators'; +import { HALLink } from '../../shared/hal-link.model'; +import { ResourceType } from '../../shared/resource-type'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { ORCID_HISTORY } from './orcid-history.resource-type'; +import { CacheableObject } from '../../cache/cacheable-object.model'; + +/** + * Class the represents a Orcid History. + */ +@typedObject +export class OrcidHistory extends CacheableObject { + + static type = ORCID_HISTORY; + + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The identifier of this Orcid History record + */ + @autoserialize + id: number; + + /** + * The name of the related entity + */ + @autoserialize + entityName: string; + + /** + * The identifier of the profileItem of this Orcid History record. + */ + @autoserialize + profileItemId: string; + + /** + * The identifier of the entity related to this Orcid History record. + */ + @autoserialize + entityId: string; + + /** + * The type of the entity related to this Orcid History record. + */ + @autoserialize + entityType: string; + + /** + * The response status coming from ORCID api. + */ + @autoserialize + status: number; + + /** + * The putCode assigned by ORCID to the entity. + */ + @autoserialize + putCode: string; + + /** + * The last send attempt timestamp. + */ + lastAttempt: string; + + /** + * The success send attempt timestamp. + */ + successAttempt: string; + + /** + * The response coming from ORCID. + */ + responseMessage: string; + + /** + * The {@link HALLink}s for this Orcid History record + */ + @deserialize + _links: { + self: HALLink, + }; + +} diff --git a/src/app/core/orcid/model/orcid-history.resource-type.ts b/src/app/core/orcid/model/orcid-history.resource-type.ts new file mode 100644 index 0000000000..45da8cbf68 --- /dev/null +++ b/src/app/core/orcid/model/orcid-history.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from '../../shared/resource-type'; + +/** + * The resource type for OrcidHistory + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const ORCID_HISTORY = new ResourceType('orcidhistory'); diff --git a/src/app/core/orcid/model/orcid-queue.model.ts b/src/app/core/orcid/model/orcid-queue.model.ts new file mode 100644 index 0000000000..2a1c3f1d82 --- /dev/null +++ b/src/app/core/orcid/model/orcid-queue.model.ts @@ -0,0 +1,68 @@ +import { autoserialize, deserialize } from 'cerialize'; +import { typedObject } from '../../cache/builders/build-decorators'; +import { HALLink } from '../../shared/hal-link.model'; +import { ResourceType } from '../../shared/resource-type'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { ORCID_QUEUE } from './orcid-queue.resource-type'; +import { CacheableObject } from '../../cache/cacheable-object.model'; + +/** + * Class the represents a Orcid Queue. + */ +@typedObject +export class OrcidQueue extends CacheableObject { + + static type = ORCID_QUEUE; + + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The identifier of this Orcid Queue record + */ + @autoserialize + id: number; + + /** + * The record description. + */ + @autoserialize + description: string; + + /** + * The identifier of the profileItem of this Orcid Queue record. + */ + @autoserialize + profileItemId: string; + + /** + * The identifier of the entity related to this Orcid Queue record. + */ + @autoserialize + entityId: string; + + /** + * The type of this Orcid Queue record. + */ + @autoserialize + recordType: string; + + /** + * The operation related to this Orcid Queue record. + */ + @autoserialize + operation: string; + + /** + * The {@link HALLink}s for this Orcid Queue record + */ + @deserialize + _links: { + self: HALLink, + }; + +} diff --git a/src/app/core/orcid/model/orcid-queue.resource-type.ts b/src/app/core/orcid/model/orcid-queue.resource-type.ts new file mode 100644 index 0000000000..a7f40d70ec --- /dev/null +++ b/src/app/core/orcid/model/orcid-queue.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from '../../shared/resource-type'; + +/** + * The resource type for OrcidQueue + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const ORCID_QUEUE = new ResourceType('orcidqueue'); diff --git a/src/app/core/orcid/orcid-auth.service.spec.ts b/src/app/core/orcid/orcid-auth.service.spec.ts new file mode 100644 index 0000000000..27a33a85b1 --- /dev/null +++ b/src/app/core/orcid/orcid-auth.service.spec.ts @@ -0,0 +1,329 @@ +import { cold, getTestScheduler } from 'jasmine-marbles'; +import { TestScheduler } from 'rxjs/testing'; +import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { RouterMock } from '../../shared/mocks/router.mock'; +import { ResearcherProfile } from '../profile/model/researcher-profile.model'; +import { Item } from '../shared/item.model'; +import { AddOperation, RemoveOperation } from 'fast-json-patch'; +import { ConfigurationProperty } from '../shared/configuration-property.model'; +import { ConfigurationDataService } from '../data/configuration-data.service'; +import { createPaginatedList } from '../../shared/testing/utils.test'; +import { NativeWindowRefMock } from '../../shared/mocks/mock-native-window-ref'; +import { URLCombiner } from '../url-combiner/url-combiner'; +import { OrcidAuthService } from './orcid-auth.service'; +import { ResearcherProfileService } from '../profile/researcher-profile.service'; + +describe('OrcidAuthService', () => { + let scheduler: TestScheduler; + let service: OrcidAuthService; + let serviceAsAny: any; + + let researcherProfileService: jasmine.SpyObj; + let configurationDataService: ConfigurationDataService; + let nativeWindowService: NativeWindowRefMock; + let routerStub: any; + + const researcherProfileId = 'beef9946-rt56-479e-8f11-b90cbe9f7241'; + const itemId = 'beef9946-rt56-479e-8f11-b90cbe9f7241'; + + const researcherProfile: ResearcherProfile = Object.assign(new ResearcherProfile(), { + id: researcherProfileId, + visible: false, + type: 'profile', + _links: { + item: { + href: `https://rest.api/rest/api/profiles/${researcherProfileId}/item` + }, + self: { + href: `https://rest.api/rest/api/profiles/${researcherProfileId}` + }, + } + }); + + const researcherProfilePatched: ResearcherProfile = Object.assign(new ResearcherProfile(), { + id: researcherProfileId, + visible: true, + type: 'profile', + _links: { + item: { + href: `https://rest.api/rest/api/profiles/${researcherProfileId}/item` + }, + self: { + href: `https://rest.api/rest/api/profiles/${researcherProfileId}` + }, + } + }); + + const mockItemUnlinkedToOrcid: Item = Object.assign(new Item(), { + id: 'mockItemUnlinkedToOrcid', + bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), + metadata: { + 'dc.title': [{ + value: 'test person' + }], + 'dspace.entity.type': [{ + 'value': 'Person' + }], + 'dspace.object.owner': [{ + 'value': 'test person', + 'language': null, + 'authority': 'researcher-profile-id', + 'confidence': 600, + 'place': 0 + }], + } + }); + + const mockItemLinkedToOrcid: Item = Object.assign(new Item(), { + bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), + metadata: { + 'dc.title': [{ + value: 'test person' + }], + 'dspace.entity.type': [{ + 'value': 'Person' + }], + 'dspace.object.owner': [{ + 'value': 'test person', + 'language': null, + 'authority': 'researcher-profile-id', + 'confidence': 600, + 'place': 0 + }], + 'dspace.orcid.authenticated': [{ + 'value': '2022-06-10T15:15:12.952872', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + }], + 'dspace.orcid.scope': [{ + 'value': '/authenticate', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + }, { + 'value': '/read-limited', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 1 + }, { + 'value': '/activities/update', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 2 + }, { + 'value': '/person/update', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 3 + }], + 'person.identifier.orcid': [{ + 'value': 'orcid-id', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + }] + } + }); + + const disconnectionAllowAdmin = { + uuid: 'orcid.disconnection.allowed-users', + name: 'orcid.disconnection.allowed-users', + values: ['only_admin'] + } as ConfigurationProperty; + + const disconnectionAllowAdminOwner = { + uuid: 'orcid.disconnection.allowed-users', + name: 'orcid.disconnection.allowed-users', + values: ['admin_and_owner'] + } as ConfigurationProperty; + + const authorizeUrl = { + uuid: 'orcid.authorize-url', + name: 'orcid.authorize-url', + values: ['orcid.authorize-url'] + } as ConfigurationProperty; + const appClientId = { + uuid: 'orcid.application-client-id', + name: 'orcid.application-client-id', + values: ['orcid.application-client-id'] + } as ConfigurationProperty; + const orcidScope = { + uuid: 'orcid.scope', + name: 'orcid.scope', + values: ['/authenticate', '/read-limited'] + } as ConfigurationProperty; + + beforeEach(() => { + scheduler = getTestScheduler(); + routerStub = new RouterMock(); + researcherProfileService = jasmine.createSpyObj('ResearcherProfileService', { + findById: jasmine.createSpy('findById'), + updateByOrcidOperations: jasmine.createSpy('updateByOrcidOperations') + }); + configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: jasmine.createSpy('findByPropertyName') + }); + nativeWindowService = new NativeWindowRefMock(); + + service = new OrcidAuthService( + nativeWindowService, + configurationDataService, + researcherProfileService, + routerStub); + + serviceAsAny = service; + }); + + + describe('isLinkedToOrcid', () => { + it('should return true when item has metadata', () => { + const result = service.isLinkedToOrcid(mockItemLinkedToOrcid); + expect(result).toBeTrue(); + }); + + it('should return true when item has no metadata', () => { + const result = service.isLinkedToOrcid(mockItemUnlinkedToOrcid); + expect(result).toBeFalse(); + }); + }); + + describe('onlyAdminCanDisconnectProfileFromOrcid', () => { + it('should return true when property is only_admin', () => { + spyOn((service as any), 'getOrcidDisconnectionAllowedUsersConfiguration').and.returnValue(createSuccessfulRemoteDataObject$(disconnectionAllowAdmin)); + const result = service.onlyAdminCanDisconnectProfileFromOrcid(); + const expected = cold('(a|)', { + a: true + }); + expect(result).toBeObservable(expected); + }); + + it('should return false on faild', () => { + spyOn((service as any), 'getOrcidDisconnectionAllowedUsersConfiguration').and.returnValue(createFailedRemoteDataObject$()); + const result = service.onlyAdminCanDisconnectProfileFromOrcid(); + const expected = cold('(a|)', { + a: false + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('ownerCanDisconnectProfileFromOrcid', () => { + it('should return true when property is admin_and_owner', () => { + spyOn((service as any), 'getOrcidDisconnectionAllowedUsersConfiguration').and.returnValue(createSuccessfulRemoteDataObject$(disconnectionAllowAdminOwner)); + const result = service.ownerCanDisconnectProfileFromOrcid(); + const expected = cold('(a|)', { + a: true + }); + expect(result).toBeObservable(expected); + }); + + it('should return false on faild', () => { + spyOn((service as any), 'getOrcidDisconnectionAllowedUsersConfiguration').and.returnValue(createFailedRemoteDataObject$()); + const result = service.ownerCanDisconnectProfileFromOrcid(); + const expected = cold('(a|)', { + a: false + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('linkOrcidByItem', () => { + beforeEach(() => { + scheduler = getTestScheduler(); + researcherProfileService.updateByOrcidOperations.and.returnValue(createSuccessfulRemoteDataObject$(researcherProfilePatched)); + researcherProfileService.findById.and.returnValue(createSuccessfulRemoteDataObject$(researcherProfile)); + }); + + it('should call updateByOrcidOperations method properly', () => { + const operations: AddOperation[] = [{ + path: '/orcid', + op: 'add', + value: 'test-code' + }]; + + scheduler.schedule(() => service.linkOrcidByItem(mockItemUnlinkedToOrcid, 'test-code').subscribe()); + scheduler.flush(); + + expect(researcherProfileService.updateByOrcidOperations).toHaveBeenCalledWith(researcherProfile, operations); + }); + }); + + describe('unlinkOrcidByItem', () => { + beforeEach(() => { + scheduler = getTestScheduler(); + researcherProfileService.updateByOrcidOperations.and.returnValue(createSuccessfulRemoteDataObject$(researcherProfilePatched)); + researcherProfileService.findById.and.returnValue(createSuccessfulRemoteDataObject$(researcherProfile)); + }); + + it('should call updateByOrcidOperations method properly', () => { + const operations: RemoveOperation[] = [{ + path: '/orcid', + op: 'remove' + }]; + + scheduler.schedule(() => service.unlinkOrcidByItem(mockItemLinkedToOrcid).subscribe()); + scheduler.flush(); + + expect(researcherProfileService.updateByOrcidOperations).toHaveBeenCalledWith(researcherProfile, operations); + }); + }); + + describe('getOrcidAuthorizeUrl', () => { + beforeEach(() => { + routerStub.setRoute('/entities/person/uuid/orcid'); + (service as any).configurationService.findByPropertyName.and.returnValues( + createSuccessfulRemoteDataObject$(authorizeUrl), + createSuccessfulRemoteDataObject$(appClientId), + createSuccessfulRemoteDataObject$(orcidScope) + ); + }); + + it('should build the url properly', () => { + const result = service.getOrcidAuthorizeUrl(mockItemUnlinkedToOrcid); + const redirectUri: string = new URLCombiner(nativeWindowService.nativeWindow.origin, encodeURIComponent(routerStub.url.split('?')[0])).toString(); + const url = 'orcid.authorize-url?client_id=orcid.application-client-id&redirect_uri=' + redirectUri + '&response_type=code&scope=/authenticate /read-limited'; + + const expected = cold('(a|)', { + a: url + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('getOrcidAuthorizationScopesByItem', () => { + it('should return list of scopes saved in the item', () => { + const orcidScopes = [ + '/authenticate', + '/read-limited', + '/activities/update', + '/person/update' + ]; + const result = service.getOrcidAuthorizationScopesByItem(mockItemLinkedToOrcid); + expect(result).toEqual(orcidScopes); + }); + }); + + describe('getOrcidAuthorizationScopes', () => { + it('should return list of scopes by configuration', () => { + (service as any).configurationService.findByPropertyName.and.returnValue( + createSuccessfulRemoteDataObject$(orcidScope) + ); + const orcidScopes = [ + '/authenticate', + '/read-limited' + ]; + const expected = cold('(a|)', { + a: orcidScopes + }); + const result = service.getOrcidAuthorizationScopes(); + expect(result).toBeObservable(expected); + }); + }); +}); diff --git a/src/app/core/orcid/orcid-auth.service.ts b/src/app/core/orcid/orcid-auth.service.ts new file mode 100644 index 0000000000..cf7bc2b259 --- /dev/null +++ b/src/app/core/orcid/orcid-auth.service.ts @@ -0,0 +1,145 @@ +import { Inject, Injectable } from '@angular/core'; +import { Router } from '@angular/router'; + +import { combineLatest, Observable } from 'rxjs'; +import { map, switchMap } from 'rxjs/operators'; +import { AddOperation, RemoveOperation } from 'fast-json-patch'; + +import { ResearcherProfileService } from '../profile/researcher-profile.service'; +import { Item } from '../shared/item.model'; +import { isNotEmpty } from '../../shared/empty.util'; +import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../shared/operators'; +import { RemoteData } from '../data/remote-data'; +import { ConfigurationProperty } from '../shared/configuration-property.model'; +import { ConfigurationDataService } from '../data/configuration-data.service'; +import { ResearcherProfile } from '../profile/model/researcher-profile.model'; +import { URLCombiner } from '../url-combiner/url-combiner'; +import { NativeWindowRef, NativeWindowService } from '../services/window.service'; + +@Injectable() +export class OrcidAuthService { + + constructor( + @Inject(NativeWindowService) protected _window: NativeWindowRef, + private configurationService: ConfigurationDataService, + private researcherProfileService: ResearcherProfileService, + private router: Router) { + } + + /** + * Check if the given item is linked to an ORCID profile. + * + * @param item the item to check + * @returns the check result + */ + public isLinkedToOrcid(item: Item): boolean { + return item.hasMetadata('dspace.orcid.authenticated'); + } + + /** + * Returns true if only the admin users can disconnect a researcher profile from ORCID. + * + * @returns the check result + */ + public onlyAdminCanDisconnectProfileFromOrcid(): Observable { + return this.getOrcidDisconnectionAllowedUsersConfiguration().pipe( + map((propertyRD: RemoteData) => { + return propertyRD.hasSucceeded && propertyRD.payload.values.map((value) => value.toLowerCase()).includes('only_admin'); + }) + ); + } + + /** + * Returns true if the profile's owner can disconnect that profile from ORCID. + * + * @returns the check result + */ + public ownerCanDisconnectProfileFromOrcid(): Observable { + return this.getOrcidDisconnectionAllowedUsersConfiguration().pipe( + map((propertyRD: RemoteData) => { + return propertyRD.hasSucceeded && propertyRD.payload.values.map( (value) => value.toLowerCase()).includes('admin_and_owner'); + }) + ); + } + + /** + * Perform a link operation to ORCID profile. + * + * @param person The person item related to the researcher profile + * @param code The auth-code received from orcid + */ + public linkOrcidByItem(person: Item, code: string): Observable> { + const operations: AddOperation[] = [{ + path: '/orcid', + op: 'add', + value: code + }]; + + return this.researcherProfileService.findById(person.firstMetadata('dspace.object.owner').authority).pipe( + getFirstCompletedRemoteData(), + switchMap((profileRD) => this.researcherProfileService.updateByOrcidOperations(profileRD.payload, operations)) + ); + } + + /** + * Perform unlink operation from ORCID profile. + * + * @param person The person item related to the researcher profile + */ + public unlinkOrcidByItem(person: Item): Observable> { + const operations: RemoveOperation[] = [{ + path:'/orcid', + op:'remove' + }]; + + return this.researcherProfileService.findById(person.firstMetadata('dspace.object.owner').authority).pipe( + getFirstCompletedRemoteData(), + switchMap((profileRD) => this.researcherProfileService.updateByOrcidOperations(profileRD.payload, operations)) + ); + } + + /** + * Build and return the url to authenticate with orcid + * + * @param profile + */ + public getOrcidAuthorizeUrl(profile: Item): Observable { + return combineLatest([ + this.configurationService.findByPropertyName('orcid.authorize-url').pipe(getFirstSucceededRemoteDataPayload()), + this.configurationService.findByPropertyName('orcid.application-client-id').pipe(getFirstSucceededRemoteDataPayload()), + this.configurationService.findByPropertyName('orcid.scope').pipe(getFirstSucceededRemoteDataPayload())] + ).pipe( + map(([authorizeUrl, clientId, scopes]) => { + const redirectUri = new URLCombiner(this._window.nativeWindow.origin, encodeURIComponent(this.router.url.split('?')[0])); + console.log(redirectUri.toString()); + return authorizeUrl.values[0] + '?client_id=' + clientId.values[0] + '&redirect_uri=' + redirectUri + '&response_type=code&scope=' + + scopes.values.join(' '); + })); + } + + /** + * Return all orcid authorization scopes saved in the given item + * + * @param item + */ + public getOrcidAuthorizationScopesByItem(item: Item): string[] { + return isNotEmpty(item) ? item.allMetadataValues('dspace.orcid.scope') : []; + } + + /** + * Return all orcid authorization scopes available by configuration + */ + public getOrcidAuthorizationScopes(): Observable { + return this.configurationService.findByPropertyName('orcid.scope').pipe( + getFirstCompletedRemoteData(), + map((propertyRD: RemoteData) => propertyRD.hasSucceeded ? propertyRD.payload.values : []) + ); + } + + private getOrcidDisconnectionAllowedUsersConfiguration(): Observable> { + return this.configurationService.findByPropertyName('orcid.disconnection.allowed-users').pipe( + getFirstCompletedRemoteData() + ); + } + +} diff --git a/src/app/core/orcid/orcid-history-data.service.ts b/src/app/core/orcid/orcid-history-data.service.ts new file mode 100644 index 0000000000..cef3efbe78 --- /dev/null +++ b/src/app/core/orcid/orcid-history-data.service.ts @@ -0,0 +1,126 @@ +// eslint-disable-next-line max-classes-per-file +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { map, switchMap } from 'rxjs/operators'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { dataService } from '../cache/builders/build-decorators'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { DataService } from '../data/data.service'; +import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service'; +import { ItemDataService } from '../data/item-data.service'; +import { RemoteData } from '../data/remote-data'; +import { PostRequest } from '../data/request.models'; +import { RequestService } from '../data/request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { OrcidHistory } from './model/orcid-history.model'; +import { ORCID_HISTORY } from './model/orcid-history.resource-type'; +import { OrcidQueue } from './model/orcid-queue.model'; +import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { CoreState } from '../core-state.model'; +import { RestRequest } from '../data/rest-request.model'; +import { sendRequest } from '../shared/request.operators'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { FindListOptions } from '../data/find-list-options.model'; +import { PaginatedList } from '../data/paginated-list.model'; + +/** + * A private DataService implementation to delegate specific methods to. + */ +class OrcidHistoryServiceImpl extends DataService { + public linkPath = 'orcidhistories'; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer) { + super(); + } + +} + +/** + * A service that provides methods to make REST requests with Orcid History endpoint. + */ +@Injectable() +@dataService(ORCID_HISTORY) +export class OrcidHistoryDataService { + + dataService: OrcidHistoryServiceImpl; + + responseMsToLive: number = 10 * 1000; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer, + protected itemService: ItemDataService ) { + + this.dataService = new OrcidHistoryServiceImpl(requestService, rdbService, store, objectCache, halService, + notificationsService, http, comparator); + + } + + sendToORCID(orcidQueue: OrcidQueue): Observable> { + const requestId = this.requestService.generateRequestId(); + return this.getEndpoint().pipe( + map((endpointURL: string) => { + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'text/uri-list'); + options.headers = headers; + return new PostRequest(requestId, endpointURL, orcidQueue._links.self.href, options); + }), + sendRequest(this.requestService), + switchMap((request: RestRequest) => this.rdbService.buildFromRequestUUID(request.uuid) as Observable>) + ); + } + + getEndpoint(): Observable { + return this.halService.getEndpoint(this.dataService.linkPath); + } + + /** + * Returns an observable of {@link RemoteData} of an object, based on its ID, with a list of + * {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object + * @param id ID of object we want to retrieve + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + */ + findById(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { + return this.dataService.findById(id, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Returns a list of observables of {@link RemoteData} of {@link OrcidHistory}s, based on an href, with a list of {@link FollowLinkConfig}, + * to automatically resolve {@link HALLink}s of the {@link OrcidHistory} + * @param href The url of object we want to retrieve + * @param findListOptions Find list options object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + */ + findAllByHref(href: string, findListOptions: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.dataService.findAllByHref(href, findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + +} diff --git a/src/app/core/orcid/orcid-queue.service.ts b/src/app/core/orcid/orcid-queue.service.ts new file mode 100644 index 0000000000..30b9580b96 --- /dev/null +++ b/src/app/core/orcid/orcid-queue.service.ts @@ -0,0 +1,110 @@ +// eslint-disable-next-line max-classes-per-file +import { DataService } from '../data/data.service'; +import { OrcidQueue } from './model/orcid-queue.model'; +import { RequestService } from '../data/request.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { Store } from '@ngrx/store'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { HttpClient } from '@angular/common/http'; +import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service'; +import { Injectable } from '@angular/core'; +import { dataService } from '../cache/builders/build-decorators'; +import { ORCID_QUEUE } from './model/orcid-queue.resource-type'; +import { ItemDataService } from '../data/item-data.service'; +import { Observable } from 'rxjs'; +import { RemoteData } from '../data/remote-data'; +import { PaginatedList } from '../data/paginated-list.model'; +import { RequestParam } from '../cache/models/request-param.model'; +import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; +import { NoContent } from '../shared/NoContent.model'; +import { ConfigurationDataService } from '../data/configuration-data.service'; +import { Router } from '@angular/router'; +import { CoreState } from '../core-state.model'; + +/** + * A private DataService implementation to delegate specific methods to. + */ +class OrcidQueueServiceImpl extends DataService { + public linkPath = 'orcidqueues'; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer) { + super(); + } + +} + +/** + * A service that provides methods to make REST requests with Orcid Queue endpoint. + */ +@Injectable() +@dataService(ORCID_QUEUE) +export class OrcidQueueService { + + dataService: OrcidQueueServiceImpl; + + responseMsToLive: number = 10 * 1000; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer, + protected configurationService: ConfigurationDataService, + protected router: Router, + protected itemService: ItemDataService ) { + + this.dataService = new OrcidQueueServiceImpl(requestService, rdbService, store, objectCache, halService, + notificationsService, http, comparator); + + } + + /** + * @param itemId It represent an Id of profileItem + * @param paginationOptions The pagination options object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @returns { OrcidQueue } + */ + searchByProfileItemId(itemId: string, paginationOptions: PaginationComponentOptions, useCachedVersionIfAvailable = true, reRequestOnStale = true): Observable>> { + return this.dataService.searchBy('findByProfileItem', { + searchParams: [new RequestParam('profileItemId', itemId)], + elementsPerPage: paginationOptions.pageSize, + currentPage: paginationOptions.currentPage + }, + useCachedVersionIfAvailable, + reRequestOnStale + ); + } + + /** + * @param orcidQueueId represents a id of orcid queue + * @returns { NoContent } + */ + deleteById(orcidQueueId: number): Observable> { + return this.dataService.delete(orcidQueueId.toString()); + } + + /** + * This method will set linkPath to stale + */ + clearFindByProfileItemRequests() { + this.requestService.setStaleByHrefSubstring(this.dataService.linkPath + '/search/findByProfileItem'); + } + +} diff --git a/src/app/core/profile/researcher-profile.service.spec.ts b/src/app/core/profile/researcher-profile.service.spec.ts index 11f6e1b13b..899867ec8e 100644 --- a/src/app/core/profile/researcher-profile.service.spec.ts +++ b/src/app/core/profile/researcher-profile.service.spec.ts @@ -12,7 +12,6 @@ import { RequestService } from '../data/request.service'; import { PageInfo } from '../shared/page-info.model'; import { buildPaginatedList } from '../data/paginated-list.model'; import { - createFailedRemoteDataObject$, createNoContentRemoteDataObject$, createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ @@ -23,15 +22,12 @@ import { ResearcherProfileService } from './researcher-profile.service'; import { RouterMock } from '../../shared/mocks/router.mock'; import { ResearcherProfile } from './model/researcher-profile.model'; import { Item } from '../shared/item.model'; -import { AddOperation, RemoveOperation, ReplaceOperation } from 'fast-json-patch'; +import { ReplaceOperation } from 'fast-json-patch'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { PostRequest } from '../data/request.models'; import { followLink } from '../../shared/utils/follow-link-config.model'; import { ConfigurationProperty } from '../shared/configuration-property.model'; -import { ConfigurationDataService } from '../data/configuration-data.service'; import { createPaginatedList } from '../../shared/testing/utils.test'; -import { NativeWindowRefMock } from '../../shared/mocks/mock-native-window-ref'; -import { URLCombiner } from '../url-combiner/url-combiner'; describe('ResearcherProfileService', () => { let scheduler: TestScheduler; @@ -42,8 +38,6 @@ describe('ResearcherProfileService', () => { let objectCache: ObjectCacheService; let halService: HALEndpointService; let responseCacheEntry: RequestEntry; - let configurationDataService: ConfigurationDataService; - let nativeWindowService: NativeWindowRefMock; let routerStub: any; const researcherProfileId = 'beef9946-rt56-479e-8f11-b90cbe9f7241'; @@ -252,13 +246,8 @@ describe('ResearcherProfileService', () => { const itemService = jasmine.createSpyObj('ItemService', { findByHref: jasmine.createSpy('findByHref') }); - configurationDataService = jasmine.createSpyObj('configurationDataService', { - findByPropertyName: jasmine.createSpy('findByPropertyName') - }); - nativeWindowService = new NativeWindowRefMock(); service = new ResearcherProfileService( - nativeWindowService, requestService, rdbService, objectCache, @@ -267,8 +256,7 @@ describe('ResearcherProfileService', () => { http, routerStub, comparator, - itemService, - configurationDataService + itemService ); serviceAsAny = service; @@ -415,121 +403,6 @@ describe('ResearcherProfileService', () => { }); }); - describe('isLinkedToOrcid', () => { - it('should return true when item has metadata', () => { - const result = service.isLinkedToOrcid(mockItemLinkedToOrcid); - expect(result).toBeTrue(); - }); - - it('should return true when item has no metadata', () => { - const result = service.isLinkedToOrcid(mockItemUnlinkedToOrcid); - expect(result).toBeFalse(); - }); - }); - - describe('onlyAdminCanDisconnectProfileFromOrcid', () => { - it('should return true when property is only_admin', () => { - spyOn((service as any), 'getOrcidDisconnectionAllowedUsersConfiguration').and.returnValue(createSuccessfulRemoteDataObject$(disconnectionAllowAdmin)); - const result = service.onlyAdminCanDisconnectProfileFromOrcid(); - const expected = cold('(a|)', { - a: true - }); - expect(result).toBeObservable(expected); - }); - - it('should return false on faild', () => { - spyOn((service as any), 'getOrcidDisconnectionAllowedUsersConfiguration').and.returnValue(createFailedRemoteDataObject$()); - const result = service.onlyAdminCanDisconnectProfileFromOrcid(); - const expected = cold('(a|)', { - a: false - }); - expect(result).toBeObservable(expected); - }); - }); - - describe('ownerCanDisconnectProfileFromOrcid', () => { - it('should return true when property is admin_and_owner', () => { - spyOn((service as any), 'getOrcidDisconnectionAllowedUsersConfiguration').and.returnValue(createSuccessfulRemoteDataObject$(disconnectionAllowAdminOwner)); - const result = service.ownerCanDisconnectProfileFromOrcid(); - const expected = cold('(a|)', { - a: true - }); - expect(result).toBeObservable(expected); - }); - - it('should return false on faild', () => { - spyOn((service as any), 'getOrcidDisconnectionAllowedUsersConfiguration').and.returnValue(createFailedRemoteDataObject$()); - const result = service.ownerCanDisconnectProfileFromOrcid(); - const expected = cold('(a|)', { - a: false - }); - expect(result).toBeObservable(expected); - }); - }); - - describe('linkOrcidByItem', () => { - beforeEach(() => { - scheduler = getTestScheduler(); - spyOn((service as any).dataService, 'patch').and.returnValue(createSuccessfulRemoteDataObject$(researcherProfilePatched)); - spyOn((service as any), 'findById').and.returnValue(createSuccessfulRemoteDataObject$(researcherProfile)); - }); - - it('should call patch method properly', () => { - const operations: AddOperation[] = [{ - path: '/orcid', - op: 'add', - value: 'test-code' - }]; - - scheduler.schedule(() => service.linkOrcidByItem(mockItemUnlinkedToOrcid, 'test-code').subscribe()); - scheduler.flush(); - - expect((service as any).dataService.patch).toHaveBeenCalledWith(researcherProfile, operations); - }); - }); - - describe('unlinkOrcidByItem', () => { - beforeEach(() => { - scheduler = getTestScheduler(); - spyOn((service as any).dataService, 'patch').and.returnValue(createSuccessfulRemoteDataObject$(researcherProfilePatched)); - spyOn((service as any), 'findById').and.returnValue(createSuccessfulRemoteDataObject$(researcherProfile)); - }); - - it('should call patch method properly', () => { - const operations: RemoveOperation[] = [{ - path: '/orcid', - op: 'remove' - }]; - - scheduler.schedule(() => service.unlinkOrcidByItem(mockItemLinkedToOrcid).subscribe()); - scheduler.flush(); - - expect((service as any).dataService.patch).toHaveBeenCalledWith(researcherProfile, operations); - }); - }); - - describe('getOrcidAuthorizeUrl', () => { - beforeEach(() => { - routerStub.setRoute('/entities/person/uuid/orcid'); - (service as any).configurationService.findByPropertyName.and.returnValues( - createSuccessfulRemoteDataObject$(authorizeUrl), - createSuccessfulRemoteDataObject$(appClientId), - createSuccessfulRemoteDataObject$(orcidScope) - ); - }); - - it('should build the url properly', () => { - const result = service.getOrcidAuthorizeUrl(mockItemUnlinkedToOrcid); - const redirectUri: string = new URLCombiner(nativeWindowService.nativeWindow.origin, encodeURIComponent(routerStub.url.split('?')[0])).toString(); - const url = 'orcid.authorize-url?client_id=orcid.application-client-id&redirect_uri=' + redirectUri + '&response_type=code&scope=/authenticate /read-limited'; - - const expected = cold('(a|)', { - a: url - }); - expect(result).toBeObservable(expected); - }); - }); - describe('updateByOrcidOperations', () => { beforeEach(() => { scheduler = getTestScheduler(); @@ -543,34 +416,4 @@ describe('ResearcherProfileService', () => { expect((service as any).dataService.patch).toHaveBeenCalledWith(researcherProfile, []); }); }); - - describe('getOrcidAuthorizationScopesByItem', () => { - it('should return list of scopes saved in the item', () => { - const orcidScopes = [ - '/authenticate', - '/read-limited', - '/activities/update', - '/person/update' - ]; - const result = service.getOrcidAuthorizationScopesByItem(mockItemLinkedToOrcid); - expect(result).toEqual(orcidScopes); - }); - }); - - describe('getOrcidAuthorizationScopes', () => { - it('should return list of scopes by configuration', () => { - (service as any).configurationService.findByPropertyName.and.returnValue( - createSuccessfulRemoteDataObject$(orcidScope) - ); - const orcidScopes = [ - '/authenticate', - '/read-limited' - ]; - const expected = cold('(a|)', { - a: orcidScopes - }); - const result = service.getOrcidAuthorizationScopes(); - expect(result).toBeObservable(expected); - }); - }); }); diff --git a/src/app/core/profile/researcher-profile.service.ts b/src/app/core/profile/researcher-profile.service.ts index 0ceb851e2c..882845d133 100644 --- a/src/app/core/profile/researcher-profile.service.ts +++ b/src/app/core/profile/researcher-profile.service.ts @@ -1,41 +1,33 @@ /* eslint-disable max-classes-per-file */ import { HttpClient, HttpHeaders } from '@angular/common/http'; -import { Inject, Injectable } from '@angular/core'; +import { Injectable } from '@angular/core'; import { Router } from '@angular/router'; import { Store } from '@ngrx/store'; -import { AddOperation, Operation, RemoveOperation, ReplaceOperation } from 'fast-json-patch'; -import { combineLatest, Observable } from 'rxjs'; -import { find, map, switchMap } from 'rxjs/operators'; +import { Operation, ReplaceOperation } from 'fast-json-patch'; +import { Observable } from 'rxjs'; +import { find, map } from 'rxjs/operators'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { ConfigurationDataService } from '../data/configuration-data.service'; import { DataService } from '../data/data.service'; import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service'; import { ItemDataService } from '../data/item-data.service'; import { RemoteData } from '../data/remote-data'; import { RequestService } from '../data/request.service'; -import { ConfigurationProperty } from '../shared/configuration-property.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { NoContent } from '../shared/NoContent.model'; -import { - getAllCompletedRemoteData, - getFirstCompletedRemoteData, - getFirstSucceededRemoteDataPayload -} from '../shared/operators'; +import { getAllCompletedRemoteData, getFirstCompletedRemoteData } from '../shared/operators'; import { ResearcherProfile } from './model/researcher-profile.model'; import { RESEARCHER_PROFILE } from './model/researcher-profile.resource-type'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { PostRequest } from '../data/request.models'; -import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; +import { hasValue, isEmpty } from '../../shared/empty.util'; import { CoreState } from '../core-state.model'; import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { Item } from '../shared/item.model'; import { createFailedRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { NativeWindowRef, NativeWindowService } from '../services/window.service'; -import { URLCombiner } from '../url-combiner/url-combiner'; /** * A private DataService implementation to delegate specific methods to. @@ -69,7 +61,6 @@ export class ResearcherProfileService { protected responseMsToLive: number = 10 * 1000; constructor( - @Inject(NativeWindowService) protected _window: NativeWindowRef, protected requestService: RequestService, protected rdbService: RemoteDataBuildService, protected objectCache: ObjectCacheService, @@ -78,8 +69,7 @@ export class ResearcherProfileService { protected http: HttpClient, protected router: Router, protected comparator: DefaultChangeAnalyzer, - protected itemService: ItemDataService, - protected configurationService: ConfigurationDataService) { + protected itemService: ItemDataService) { this.dataService = new ResearcherProfileServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator); @@ -165,98 +155,6 @@ export class ResearcherProfileService { return this.dataService.patch(researcherProfile, [replaceOperation]); } - /** - * Check if the given item is linked to an ORCID profile. - * - * @param item the item to check - * @returns the check result - */ - public isLinkedToOrcid(item: Item): boolean { - return item.hasMetadata('dspace.orcid.authenticated'); - } - - /** - * Returns true if only the admin users can disconnect a researcher profile from ORCID. - * - * @returns the check result - */ - public onlyAdminCanDisconnectProfileFromOrcid(): Observable { - return this.getOrcidDisconnectionAllowedUsersConfiguration().pipe( - map((propertyRD: RemoteData) => { - return propertyRD.hasSucceeded && propertyRD.payload.values.map((value) => value.toLowerCase()).includes('only_admin'); - }) - ); - } - - /** - * Returns true if the profile's owner can disconnect that profile from ORCID. - * - * @returns the check result - */ - public ownerCanDisconnectProfileFromOrcid(): Observable { - return this.getOrcidDisconnectionAllowedUsersConfiguration().pipe( - map((propertyRD: RemoteData) => { - return propertyRD.hasSucceeded && propertyRD.payload.values.map( (value) => value.toLowerCase()).includes('admin_and_owner'); - }) - ); - } - - /** - * Perform a link operation to ORCID profile. - * - * @param person The person item related to the researcher profile - * @param code The auth-code received from orcid - */ - public linkOrcidByItem(person: Item, code: string): Observable> { - const operations: AddOperation[] = [{ - path: '/orcid', - op: 'add', - value: code - }]; - - return this.findById(person.firstMetadata('dspace.object.owner').authority).pipe( - getFirstCompletedRemoteData(), - switchMap((profileRD) => this.updateByOrcidOperations(profileRD.payload, operations)) - ); - } - - /** - * Perform unlink operation from ORCID profile. - * - * @param person The person item related to the researcher profile - */ - public unlinkOrcidByItem(person: Item): Observable> { - const operations: RemoveOperation[] = [{ - path:'/orcid', - op:'remove' - }]; - - return this.findById(person.firstMetadata('dspace.object.owner').authority).pipe( - getFirstCompletedRemoteData(), - switchMap((profileRD) => this.updateByOrcidOperations(profileRD.payload, operations)) - ); - } - - /** - * Build and return the url to authenticate with orcid - * - * @param profile - */ - public getOrcidAuthorizeUrl(profile: Item): Observable { - return combineLatest([ - this.configurationService.findByPropertyName('orcid.authorize-url').pipe(getFirstSucceededRemoteDataPayload()), - this.configurationService.findByPropertyName('orcid.application-client-id').pipe(getFirstSucceededRemoteDataPayload()), - this.configurationService.findByPropertyName('orcid.scope').pipe(getFirstSucceededRemoteDataPayload())] - ).pipe( - map(([authorizeUrl, clientId, scopes]) => { - console.log(this._window.nativeWindow.origin, this.router.url); - const redirectUri = new URLCombiner(this._window.nativeWindow.origin, encodeURIComponent(this.router.url.split('?')[0])); - console.log(redirectUri.toString()); - return authorizeUrl.values[0] + '?client_id=' + clientId.values[0] + '&redirect_uri=' + redirectUri + '&response_type=code&scope=' - + scopes.values.join(' '); - })); - } - /** * Creates a researcher profile starting from an external source URI * @param sourceUri URI of source item of researcher profile. @@ -291,29 +189,6 @@ export class ResearcherProfileService { return this.dataService.patch(researcherProfile, operations); } - /** - * Return all orcid authorization scopes saved in the given item - * - * @param item - */ - public getOrcidAuthorizationScopesByItem(item: Item): string[] { - return isNotEmpty(item) ? item.allMetadataValues('dspace.orcid.scope') : []; - } - /** - * Return all orcid authorization scopes available by configuration - */ - public getOrcidAuthorizationScopes(): Observable { - return this.configurationService.findByPropertyName('orcid.scope').pipe( - getFirstCompletedRemoteData(), - map((propertyRD: RemoteData) => propertyRD.hasSucceeded ? propertyRD.payload.values : []) - ); - } - - private getOrcidDisconnectionAllowedUsersConfiguration(): Observable> { - return this.configurationService.findByPropertyName('orcid.disconnection.allowed-users').pipe( - getFirstCompletedRemoteData() - ); - } } diff --git a/src/app/item-page/item-page.module.ts b/src/app/item-page/item-page.module.ts index d1be5a9cd8..cbb9f3299e 100644 --- a/src/app/item-page/item-page.module.ts +++ b/src/app/item-page/item-page.module.ts @@ -48,6 +48,7 @@ import { OrcidAuthComponent } from './orcid-page/orcid-auth/orcid-auth.component import { OrcidPageComponent } from './orcid-page/orcid-page.component'; import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'; import { OrcidSyncSettingsComponent } from './orcid-page/orcid-sync-settings/orcid-sync-settings.component'; +import { OrcidQueueComponent } from './orcid-page/orcid-queue/orcid-queue.component'; const ENTRY_COMPONENTS = [ @@ -83,7 +84,8 @@ const DECLARATIONS = [ VersionPageComponent, OrcidPageComponent, OrcidAuthComponent, - OrcidSyncSettingsComponent + OrcidSyncSettingsComponent, + OrcidQueueComponent ]; @NgModule({ diff --git a/src/app/item-page/orcid-page/orcid-auth/orcid-auth.component.spec.ts b/src/app/item-page/orcid-page/orcid-auth/orcid-auth.component.spec.ts index 6b5f2d8593..e96e5996fb 100644 --- a/src/app/item-page/orcid-page/orcid-auth/orcid-auth.component.spec.ts +++ b/src/app/item-page/orcid-page/orcid-auth/orcid-auth.component.spec.ts @@ -9,7 +9,7 @@ import { of } from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; -import { ResearcherProfileService } from '../../../core/profile/researcher-profile.service'; +import { OrcidAuthService } from '../../../core/orcid/orcid-auth.service'; import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { Item } from '../../../core/shared/item.model'; import { createPaginatedList } from '../../../shared/testing/utils.test'; @@ -25,7 +25,7 @@ describe('OrcidAuthComponent test suite', () => { let comp: OrcidAuthComponent; let fixture: ComponentFixture; let scheduler: TestScheduler; - let researcherProfileService: jasmine.SpyObj; + let orcidAuthService: jasmine.SpyObj; let nativeWindowRef; let notificationsService; @@ -112,7 +112,7 @@ describe('OrcidAuthComponent test suite', () => { }); beforeEach(waitForAsync(() => { - researcherProfileService = jasmine.createSpyObj('researcherProfileService', { + orcidAuthService = jasmine.createSpyObj('researcherProfileService', { getOrcidAuthorizationScopes: jasmine.createSpy('getOrcidAuthorizationScopes'), getOrcidAuthorizationScopesByItem: jasmine.createSpy('getOrcidAuthorizationScopesByItem'), getOrcidAuthorizeUrl: jasmine.createSpy('getOrcidAuthorizeUrl'), @@ -137,7 +137,7 @@ describe('OrcidAuthComponent test suite', () => { providers: [ { provide: NativeWindowService, useFactory: NativeWindowMockFactory }, { provide: NotificationsService, useClass: NotificationsServiceStub }, - { provide: ResearcherProfileService, useValue: researcherProfileService } + { provide: OrcidAuthService, useValue: orcidAuthService } ], schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(OrcidAuthComponent, { @@ -149,17 +149,17 @@ describe('OrcidAuthComponent test suite', () => { scheduler = getTestScheduler(); fixture = TestBed.createComponent(OrcidAuthComponent); comp = fixture.componentInstance; - researcherProfileService.getOrcidAuthorizationScopes.and.returnValue(of(orcidScopes)); + orcidAuthService.getOrcidAuthorizationScopes.and.returnValue(of(orcidScopes)); })); describe('when orcid profile is not linked', () => { beforeEach(waitForAsync(() => { comp.item = mockItemUnlinkedToOrcid; - researcherProfileService.getOrcidAuthorizationScopesByItem.and.returnValue([]); - researcherProfileService.isLinkedToOrcid.and.returnValue(false); - researcherProfileService.onlyAdminCanDisconnectProfileFromOrcid.and.returnValue(of(false)); - researcherProfileService.ownerCanDisconnectProfileFromOrcid.and.returnValue(of(true)); - researcherProfileService.getOrcidAuthorizeUrl.and.returnValue(of('oarcidUrl')); + orcidAuthService.getOrcidAuthorizationScopesByItem.and.returnValue([]); + orcidAuthService.isLinkedToOrcid.and.returnValue(false); + orcidAuthService.onlyAdminCanDisconnectProfileFromOrcid.and.returnValue(of(false)); + orcidAuthService.ownerCanDisconnectProfileFromOrcid.and.returnValue(of(true)); + orcidAuthService.getOrcidAuthorizeUrl.and.returnValue(of('oarcidUrl')); fixture.detectChanges(); })); @@ -183,7 +183,7 @@ describe('OrcidAuthComponent test suite', () => { describe('when orcid profile is linked', () => { beforeEach(waitForAsync(() => { comp.item = mockItemLinkedToOrcid; - researcherProfileService.isLinkedToOrcid.and.returnValue(true); + orcidAuthService.isLinkedToOrcid.and.returnValue(true); })); describe('', () => { @@ -191,16 +191,16 @@ describe('OrcidAuthComponent test suite', () => { beforeEach(waitForAsync(() => { comp.item = mockItemLinkedToOrcid; notificationsService = (comp as any).notificationsService; - researcherProfileService.getOrcidAuthorizationScopesByItem.and.returnValue([...orcidScopes]); - researcherProfileService.isLinkedToOrcid.and.returnValue(true); - researcherProfileService.onlyAdminCanDisconnectProfileFromOrcid.and.returnValue(of(false)); - researcherProfileService.ownerCanDisconnectProfileFromOrcid.and.returnValue(of(true)); + orcidAuthService.getOrcidAuthorizationScopesByItem.and.returnValue([...orcidScopes]); + orcidAuthService.isLinkedToOrcid.and.returnValue(true); + orcidAuthService.onlyAdminCanDisconnectProfileFromOrcid.and.returnValue(of(false)); + orcidAuthService.ownerCanDisconnectProfileFromOrcid.and.returnValue(of(true)); })); describe('and unlink is successfully', () => { beforeEach(waitForAsync(() => { comp.item = mockItemLinkedToOrcid; - researcherProfileService.unlinkOrcidByItem.and.returnValue(createSuccessfulRemoteDataObject$(new ResearcherProfile())); + orcidAuthService.unlinkOrcidByItem.and.returnValue(createSuccessfulRemoteDataObject$(new ResearcherProfile())); spyOn(comp.unlink, 'emit'); fixture.detectChanges(); })); @@ -217,7 +217,7 @@ describe('OrcidAuthComponent test suite', () => { describe('and unlink is failed', () => { beforeEach(waitForAsync(() => { comp.item = mockItemLinkedToOrcid; - researcherProfileService.unlinkOrcidByItem.and.returnValue(createFailedRemoteDataObject$()); + orcidAuthService.unlinkOrcidByItem.and.returnValue(createFailedRemoteDataObject$()); fixture.detectChanges(); })); @@ -234,10 +234,10 @@ describe('OrcidAuthComponent test suite', () => { beforeEach(waitForAsync(() => { comp.item = mockItemLinkedToOrcid; - researcherProfileService.getOrcidAuthorizationScopesByItem.and.returnValue([...orcidScopes]); - researcherProfileService.isLinkedToOrcid.and.returnValue(true); - researcherProfileService.onlyAdminCanDisconnectProfileFromOrcid.and.returnValue(of(false)); - researcherProfileService.ownerCanDisconnectProfileFromOrcid.and.returnValue(of(true)); + orcidAuthService.getOrcidAuthorizationScopesByItem.and.returnValue([...orcidScopes]); + orcidAuthService.isLinkedToOrcid.and.returnValue(true); + orcidAuthService.onlyAdminCanDisconnectProfileFromOrcid.and.returnValue(of(false)); + orcidAuthService.ownerCanDisconnectProfileFromOrcid.and.returnValue(of(true)); fixture.detectChanges(); })); @@ -263,10 +263,10 @@ describe('OrcidAuthComponent test suite', () => { beforeEach(waitForAsync(() => { comp.item = mockItemLinkedToOrcid; - researcherProfileService.getOrcidAuthorizationScopesByItem.and.returnValue([...partialOrcidScopes]); - researcherProfileService.isLinkedToOrcid.and.returnValue(true); - researcherProfileService.onlyAdminCanDisconnectProfileFromOrcid.and.returnValue(of(false)); - researcherProfileService.ownerCanDisconnectProfileFromOrcid.and.returnValue(of(true)); + orcidAuthService.getOrcidAuthorizationScopesByItem.and.returnValue([...partialOrcidScopes]); + orcidAuthService.isLinkedToOrcid.and.returnValue(true); + orcidAuthService.onlyAdminCanDisconnectProfileFromOrcid.and.returnValue(of(false)); + orcidAuthService.ownerCanDisconnectProfileFromOrcid.and.returnValue(of(true)); fixture.detectChanges(); })); @@ -294,10 +294,10 @@ describe('OrcidAuthComponent test suite', () => { beforeEach(waitForAsync(() => { comp.item = mockItemLinkedToOrcid; - researcherProfileService.getOrcidAuthorizationScopesByItem.and.returnValue([...orcidScopes]); - researcherProfileService.isLinkedToOrcid.and.returnValue(true); - researcherProfileService.onlyAdminCanDisconnectProfileFromOrcid.and.returnValue(of(true)); - researcherProfileService.ownerCanDisconnectProfileFromOrcid.and.returnValue(of(false)); + orcidAuthService.getOrcidAuthorizationScopesByItem.and.returnValue([...orcidScopes]); + orcidAuthService.isLinkedToOrcid.and.returnValue(true); + orcidAuthService.onlyAdminCanDisconnectProfileFromOrcid.and.returnValue(of(true)); + orcidAuthService.ownerCanDisconnectProfileFromOrcid.and.returnValue(of(false)); fixture.detectChanges(); })); @@ -314,10 +314,10 @@ describe('OrcidAuthComponent test suite', () => { beforeEach(waitForAsync(() => { comp.item = mockItemLinkedToOrcid; - researcherProfileService.getOrcidAuthorizationScopesByItem.and.returnValue([...orcidScopes]); - researcherProfileService.isLinkedToOrcid.and.returnValue(true); - researcherProfileService.onlyAdminCanDisconnectProfileFromOrcid.and.returnValue(of(true)); - researcherProfileService.ownerCanDisconnectProfileFromOrcid.and.returnValue(of(true)); + orcidAuthService.getOrcidAuthorizationScopesByItem.and.returnValue([...orcidScopes]); + orcidAuthService.isLinkedToOrcid.and.returnValue(true); + orcidAuthService.onlyAdminCanDisconnectProfileFromOrcid.and.returnValue(of(true)); + orcidAuthService.ownerCanDisconnectProfileFromOrcid.and.returnValue(of(true)); fixture.detectChanges(); })); diff --git a/src/app/item-page/orcid-page/orcid-auth/orcid-auth.component.ts b/src/app/item-page/orcid-page/orcid-auth/orcid-auth.component.ts index b052fdedd8..ea970e7d31 100644 --- a/src/app/item-page/orcid-page/orcid-auth/orcid-auth.component.ts +++ b/src/app/item-page/orcid-page/orcid-auth/orcid-auth.component.ts @@ -3,14 +3,13 @@ import { Component, EventEmitter, Inject, Input, OnChanges, OnInit, Output, Simp import { TranslateService } from '@ngx-translate/core'; import { BehaviorSubject, Observable } from 'rxjs'; import { map } from 'rxjs/operators'; - -import { ResearcherProfileService } from '../../../core/profile/researcher-profile.service'; import { NativeWindowRef, NativeWindowService } from '../../../core/services/window.service'; import { Item } from '../../../core/shared/item.model'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { RemoteData } from '../../../core/data/remote-data'; import { ResearcherProfile } from '../../../core/profile/model/researcher-profile.model'; import { getFirstCompletedRemoteData } from '../../../core/shared/operators'; +import { OrcidAuthService } from '../../../core/orcid/orcid-auth.service'; @Component({ selector: 'ds-orcid-auth', @@ -65,7 +64,7 @@ export class OrcidAuthComponent implements OnInit, OnChanges { @Output() unlink: EventEmitter = new EventEmitter(); constructor( - private researcherProfileService: ResearcherProfileService, + private orcidAuthService: OrcidAuthService, private translateService: TranslateService, private notificationsService: NotificationsService, @Inject(NativeWindowService) private _window: NativeWindowRef, @@ -73,7 +72,7 @@ export class OrcidAuthComponent implements OnInit, OnChanges { } ngOnInit() { - this.researcherProfileService.getOrcidAuthorizationScopes().subscribe((scopes: string[]) => { + this.orcidAuthService.getOrcidAuthorizationScopes().subscribe((scopes: string[]) => { this.orcidAuthorizationScopes.next(scopes); this.initOrcidAuthSettings(); }); @@ -160,7 +159,7 @@ export class OrcidAuthComponent implements OnInit, OnChanges { * Link existing person profile with orcid */ linkOrcid(): void { - this.researcherProfileService.getOrcidAuthorizeUrl(this.item).subscribe((authorizeUrl) => { + this.orcidAuthService.getOrcidAuthorizeUrl(this.item).subscribe((authorizeUrl) => { this._window.nativeWindow.location.href = authorizeUrl; }); } @@ -170,7 +169,7 @@ export class OrcidAuthComponent implements OnInit, OnChanges { */ unlinkOrcid(): void { this.unlinkProcessing.next(true); - this.researcherProfileService.unlinkOrcidByItem(this.item).pipe( + this.orcidAuthService.unlinkOrcidByItem(this.item).pipe( getFirstCompletedRemoteData() ).subscribe((remoteData: RemoteData) => { this.unlinkProcessing.next(false); @@ -193,19 +192,19 @@ export class OrcidAuthComponent implements OnInit, OnChanges { this.setMissingOrcidAuthorizations(); - this.researcherProfileService.onlyAdminCanDisconnectProfileFromOrcid().subscribe((result) => { + this.orcidAuthService.onlyAdminCanDisconnectProfileFromOrcid().subscribe((result) => { this.onlyAdminCanDisconnectProfileFromOrcid$.next(result); }); - this.researcherProfileService.ownerCanDisconnectProfileFromOrcid().subscribe((result) => { + this.orcidAuthService.ownerCanDisconnectProfileFromOrcid().subscribe((result) => { this.ownerCanDisconnectProfileFromOrcid$.next(result); }); - this.isOrcidLinked$.next(this.researcherProfileService.isLinkedToOrcid(this.item)); + this.isOrcidLinked$.next(this.orcidAuthService.isLinkedToOrcid(this.item)); } private setMissingOrcidAuthorizations(): void { - const profileScopes = this.researcherProfileService.getOrcidAuthorizationScopesByItem(this.item); + const profileScopes = this.orcidAuthService.getOrcidAuthorizationScopesByItem(this.item); const orcidScopes = this.orcidAuthorizationScopes.value; const missingScopes = orcidScopes.filter((scope) => !profileScopes.includes(scope)); @@ -213,7 +212,7 @@ export class OrcidAuthComponent implements OnInit, OnChanges { } private setOrcidAuthorizationsFromItem(): void { - this.profileAuthorizationScopes.next(this.researcherProfileService.getOrcidAuthorizationScopesByItem(this.item)); + this.profileAuthorizationScopes.next(this.orcidAuthService.getOrcidAuthorizationScopesByItem(this.item)); } } diff --git a/src/app/item-page/orcid-page/orcid-page.component.html b/src/app/item-page/orcid-page/orcid-page.component.html index 9c19d0b66e..33c3125d67 100644 --- a/src/app/item-page/orcid-page/orcid-page.component.html +++ b/src/app/item-page/orcid-page/orcid-page.component.html @@ -15,4 +15,5 @@ + diff --git a/src/app/item-page/orcid-page/orcid-page.component.spec.ts b/src/app/item-page/orcid-page/orcid-page.component.spec.ts index a4af5edf5c..1ed237943e 100644 --- a/src/app/item-page/orcid-page/orcid-page.component.spec.ts +++ b/src/app/item-page/orcid-page/orcid-page.component.spec.ts @@ -11,7 +11,6 @@ import { getTestScheduler } from 'jasmine-marbles'; import { AuthService } from '../../core/auth/auth.service'; import { ActivatedRouteStub } from '../../shared/testing/active-router.stub'; -import { ResearcherProfileService } from '../../core/profile/researcher-profile.service'; import { OrcidPageComponent } from './orcid-page.component'; import { createFailedRemoteDataObject$, @@ -23,6 +22,7 @@ import { createPaginatedList } from '../../shared/testing/utils.test'; import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; import { ItemDataService } from '../../core/data/item-data.service'; import { ResearcherProfile } from '../../core/profile/model/researcher-profile.model'; +import { OrcidAuthService } from '../../core/orcid/orcid-auth.service'; describe('OrcidPageComponent test suite', () => { let comp: OrcidPageComponent; @@ -32,7 +32,7 @@ describe('OrcidPageComponent test suite', () => { let routeStub: jasmine.SpyObj; let routeData: any; let itemDataService: jasmine.SpyObj; - let researcherProfileService: jasmine.SpyObj; + let orcidAuthService: jasmine.SpyObj; const mockResearcherProfile: ResearcherProfile = Object.assign(new ResearcherProfile(), { id: 'test-id', @@ -88,7 +88,7 @@ describe('OrcidPageComponent test suite', () => { routeStub = new ActivatedRouteStub({}, routeData); - researcherProfileService = jasmine.createSpyObj('researcherProfileService', { + orcidAuthService = jasmine.createSpyObj('OrcidAuthService', { isLinkedToOrcid: jasmine.createSpy('isLinkedToOrcid'), linkOrcidByItem: jasmine.createSpy('linkOrcidByItem'), }); @@ -110,7 +110,7 @@ describe('OrcidPageComponent test suite', () => { declarations: [OrcidPageComponent], providers: [ { provide: ActivatedRoute, useValue: routeStub }, - { provide: ResearcherProfileService, useValue: researcherProfileService }, + { provide: OrcidAuthService, useValue: orcidAuthService }, { provide: AuthService, useValue: authService }, { provide: ItemDataService, useValue: itemDataService }, { provide: PLATFORM_ID, useValue: 'browser' }, @@ -146,7 +146,7 @@ describe('OrcidPageComponent test suite', () => { it('should call isLinkedToOrcid', () => { comp.isLinkedToOrcid(); - expect(researcherProfileService.isLinkedToOrcid).toHaveBeenCalledWith(comp.item.value); + expect(orcidAuthService.isLinkedToOrcid).toHaveBeenCalledWith(comp.item.value); }); it('should update item', fakeAsync(() => { @@ -168,13 +168,13 @@ describe('OrcidPageComponent test suite', () => { describe('and linking to orcid profile is successfully', () => { beforeEach(waitForAsync(() => { - researcherProfileService.linkOrcidByItem.and.returnValue(createSuccessfulRemoteDataObject$(mockResearcherProfile)); + orcidAuthService.linkOrcidByItem.and.returnValue(createSuccessfulRemoteDataObject$(mockResearcherProfile)); itemDataService.findById.and.returnValue(createSuccessfulRemoteDataObject$(mockItemLinkedToOrcid)); fixture.detectChanges(); })); it('should call linkOrcidByItem', () => { - expect(researcherProfileService.linkOrcidByItem).toHaveBeenCalledWith(mockItem, 'orcid-code'); + expect(orcidAuthService.linkOrcidByItem).toHaveBeenCalledWith(mockItem, 'orcid-code'); expect(comp.updateItem).toHaveBeenCalled(); }); @@ -193,13 +193,13 @@ describe('OrcidPageComponent test suite', () => { describe('and linking to orcid profile is failed', () => { beforeEach(waitForAsync(() => { - researcherProfileService.linkOrcidByItem.and.returnValue(createFailedRemoteDataObject$()); + orcidAuthService.linkOrcidByItem.and.returnValue(createFailedRemoteDataObject$()); itemDataService.findById.and.returnValue(createSuccessfulRemoteDataObject$(mockItemLinkedToOrcid)); fixture.detectChanges(); })); it('should call linkOrcidByItem', () => { - expect(researcherProfileService.linkOrcidByItem).toHaveBeenCalledWith(mockItem, 'orcid-code'); + expect(orcidAuthService.linkOrcidByItem).toHaveBeenCalledWith(mockItem, 'orcid-code'); expect(comp.updateItem).not.toHaveBeenCalled(); }); diff --git a/src/app/item-page/orcid-page/orcid-page.component.ts b/src/app/item-page/orcid-page/orcid-page.component.ts index 6c1de3d100..f3dbb569d9 100644 --- a/src/app/item-page/orcid-page/orcid-page.component.ts +++ b/src/app/item-page/orcid-page/orcid-page.component.ts @@ -1,10 +1,11 @@ import { Component, Inject, OnInit, PLATFORM_ID } from '@angular/core'; import { ActivatedRoute, ParamMap, Router } from '@angular/router'; +import { isPlatformBrowser } from '@angular/common'; import { BehaviorSubject, combineLatest } from 'rxjs'; import { map, take } from 'rxjs/operators'; -import { ResearcherProfileService } from '../../core/profile/researcher-profile.service'; +import { OrcidAuthService } from '../../core/orcid/orcid-auth.service'; import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../../core/shared/operators'; import { RemoteData } from '../../core/data/remote-data'; import { Item } from '../../core/shared/item.model'; @@ -14,7 +15,6 @@ import { redirectOn4xx } from '../../core/shared/authorized.operators'; import { ItemDataService } from '../../core/data/item-data.service'; import { isNotEmpty } from '../../shared/empty.util'; import { ResearcherProfile } from '../../core/profile/model/researcher-profile.model'; -import { isPlatformBrowser } from '@angular/common'; /** * A component that represents the orcid settings page @@ -50,7 +50,7 @@ export class OrcidPageComponent implements OnInit { @Inject(PLATFORM_ID) private platformId: any, private authService: AuthService, private itemService: ItemDataService, - private researcherProfileService: ResearcherProfileService, + private orcidAuthService: OrcidAuthService, private route: ActivatedRoute, private router: Router ) { @@ -95,7 +95,7 @@ export class OrcidPageComponent implements OnInit { * @returns the check result */ isLinkedToOrcid(): boolean { - return this.researcherProfileService.isLinkedToOrcid(this.item.value); + return this.orcidAuthService.isLinkedToOrcid(this.item.value); } /** @@ -109,6 +109,7 @@ export class OrcidPageComponent implements OnInit { * Retrieve the updated profile item */ updateItem(): void { + this.clearRouteParams(); this.itemService.findById(this.itemId, false).pipe( getFirstCompletedRemoteData() ).subscribe((itemRD: RemoteData) => { @@ -125,7 +126,7 @@ export class OrcidPageComponent implements OnInit { * @param code The auth-code received from ORCID */ private linkProfileToOrcid(person: Item, code: string) { - this.researcherProfileService.linkOrcidByItem(person, code).pipe( + this.orcidAuthService.linkOrcidByItem(person, code).pipe( getFirstCompletedRemoteData() ).subscribe((profileRD: RemoteData) => { this.processingConnection.next(false); @@ -135,11 +136,18 @@ export class OrcidPageComponent implements OnInit { } else { this.item.next(person); this.connectionStatus.next(false); + this.clearRouteParams(); } - - // update route removing the code from query params - const redirectUrl = this.router.url.split('?')[0]; - this.router.navigate([redirectUrl]); }); } + + /** + * Update route removing the code from query params + * @private + */ + private clearRouteParams(): void { + // update route removing the code from query params + const redirectUrl = this.router.url.split('?')[0]; + this.router.navigate([redirectUrl]); + } } diff --git a/src/app/item-page/orcid-page/orcid-queue/orcid-queue.component.html b/src/app/item-page/orcid-page/orcid-queue/orcid-queue.component.html new file mode 100644 index 0000000000..9358bcf835 --- /dev/null +++ b/src/app/item-page/orcid-page/orcid-queue/orcid-queue.component.html @@ -0,0 +1,51 @@ +
+ +
+

{{ 'person.orcid.registry.queue' | translate }}

+ + + {{ 'person.page.orcid.sync-queue.empty-message' | translate}} + + + +
+ + + + + + + + + + + + + + + +
{{'person.page.orcid.sync-queue.table.header.type' | translate}}{{'person.page.orcid.sync-queue.table.header.description' | translate}}{{'person.page.orcid.sync-queue.table.header.action' | translate}}
+ + + {{ entry.description }} + +
+ + +
+
+
+
+
+
diff --git a/src/app/item-page/orcid-page/orcid-queue/orcid-queue.component.scss b/src/app/item-page/orcid-page/orcid-queue/orcid-queue.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/item-page/orcid-page/orcid-queue/orcid-queue.component.spec.ts b/src/app/item-page/orcid-page/orcid-queue/orcid-queue.component.spec.ts new file mode 100644 index 0000000000..9107ac34ff --- /dev/null +++ b/src/app/item-page/orcid-page/orcid-queue/orcid-queue.component.spec.ts @@ -0,0 +1,151 @@ +import { OrcidQueueComponent } from './orcid-queue.component'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock'; +import { RouterTestingModule } from '@angular/router/testing'; +import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { OrcidQueueService } from '../../../core/orcid/orcid-queue.service'; +import { PaginationService } from '../../../core/pagination/pagination.service'; +import { PaginationServiceStub } from '../../../shared/testing/pagination-service.stub'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; +import { OrcidHistoryDataService } from '../../../core/orcid/orcid-history-data.service'; +import { OrcidQueue } from '../../../core/orcid/model/orcid-queue.model'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { createPaginatedList } from '../../../shared/testing/utils.test'; +import { PaginatedList } from '../../../core/data/paginated-list.model'; +import { By } from '@angular/platform-browser'; +import { Item } from '../../../core/shared/item.model'; +import { OrcidAuthService } from '../../../core/orcid/orcid-auth.service'; + +describe('OrcidQueueComponent test suite', () => { + let component: OrcidQueueComponent; + let fixture: ComponentFixture; + let debugElement: DebugElement; + let orcidQueueService: OrcidQueueService; + let orcidAuthService: jasmine.SpyObj; + + const testProfileItemId = 'test-owner-id'; + + const mockItemLinkedToOrcid: Item = Object.assign(new Item(), { + bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), + metadata: { + 'dc.title': [{ + value: 'test person' + }], + 'dspace.entity.type': [{ + 'value': 'Person' + }], + 'dspace.object.owner': [{ + 'value': 'test person', + 'language': null, + 'authority': 'deced3e7-68e2-495d-bf98-7c44fc33b8ff', + 'confidence': 600, + 'place': 0 + }], + 'dspace.orcid.authenticated': [{ + 'value': '2022-06-10T15:15:12.952872', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + }], + 'dspace.orcid.scope': [{ + 'value': '/authenticate', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + }, { + 'value': '/read-limited', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 1 + }, { + 'value': '/activities/update', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 2 + }, { + 'value': '/person/update', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 3 + }], + 'person.identifier.orcid': [{ + 'value': 'orcid-id', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + }] + } + }); + + function orcidQueueElement(id: number) { + return Object.assign(new OrcidQueue(), { + 'id': id, + 'profileItemId': testProfileItemId, + 'entityId': `test-entity-${id}`, + 'description': `test description ${id}`, + 'recordType': 'Publication', + 'operation': 'INSERT', + 'type': 'orcidqueue', + }); + } + + const orcidQueueElements = [orcidQueueElement(1), orcidQueueElement(2)]; + + const orcidQueueServiceSpy = jasmine.createSpyObj('orcidQueueService', ['searchByProfileItemId', 'clearFindByProfileItemRequests']); + orcidQueueServiceSpy.searchByProfileItemId.and.returnValue(createSuccessfulRemoteDataObject$>(createPaginatedList(orcidQueueElements))); + + beforeEach(waitForAsync(() => { + orcidAuthService = jasmine.createSpyObj('OrcidAuthService', { + getOrcidAuthorizeUrl: jasmine.createSpy('getOrcidAuthorizeUrl') + }); + + void TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), + RouterTestingModule.withRoutes([]) + ], + declarations: [OrcidQueueComponent], + providers: [ + { provide: OrcidAuthService, useValue: orcidAuthService }, + { provide: OrcidQueueService, useValue: orcidQueueServiceSpy }, + { provide: OrcidHistoryDataService, useValue: {} }, + { provide: PaginationService, useValue: new PaginationServiceStub() }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + + orcidQueueService = TestBed.inject(OrcidQueueService); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(OrcidQueueComponent); + component = fixture.componentInstance; + component.item = mockItemLinkedToOrcid; + debugElement = fixture.debugElement; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should show the ORCID queue elements', () => { + const table = debugElement.queryAll(By.css('[data-test="orcidQueueElementRow"]')); + expect(table.length).toBe(2); + }); + +}); diff --git a/src/app/item-page/orcid-page/orcid-queue/orcid-queue.component.ts b/src/app/item-page/orcid-page/orcid-queue/orcid-queue.component.ts new file mode 100644 index 0000000000..99ba33ee82 --- /dev/null +++ b/src/app/item-page/orcid-page/orcid-queue/orcid-queue.component.ts @@ -0,0 +1,302 @@ +import { Component, Input, OnDestroy, OnInit, SimpleChanges } from '@angular/core'; + +import { TranslateService } from '@ngx-translate/core'; +import { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs'; +import { debounceTime, distinctUntilChanged, switchMap, tap } from 'rxjs/operators'; + +import { PaginatedList } from '../../../core/data/paginated-list.model'; +import { RemoteData } from '../../../core/data/remote-data'; +import { OrcidHistory } from '../../../core/orcid/model/orcid-history.model'; +import { OrcidQueue } from '../../../core/orcid/model/orcid-queue.model'; +import { OrcidHistoryDataService } from '../../../core/orcid/orcid-history-data.service'; +import { OrcidQueueService } from '../../../core/orcid/orcid-queue.service'; +import { PaginationService } from '../../../core/pagination/pagination.service'; +import { getFirstCompletedRemoteData } from '../../../core/shared/operators'; +import { hasValue } from '../../../shared/empty.util'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +import { AlertType } from '../../../shared/alert/aletr-type'; +import { Item } from '../../../core/shared/item.model'; +import { OrcidAuthService } from '../../../core/orcid/orcid-auth.service'; + +@Component({ + selector: 'ds-orcid-queue', + templateUrl: './orcid-queue.component.html', + styleUrls: ['./orcid-queue.component.scss'] +}) +export class OrcidQueueComponent implements OnInit, OnDestroy { + + /** + * The item for which showing the orcid settings + */ + @Input() item: Item; + + /** + * Pagination config used to display the list + */ + public paginationOptions: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { + id: 'oqp', + pageSize: 5 + }); + + /** + * A boolean representing if results are loading + */ + public processing$ = new BehaviorSubject(false); + + /** + * A list of orcid queue records + */ + private list$: BehaviorSubject>> = new BehaviorSubject>>({} as any); + + /** + * The AlertType enumeration + * @type {AlertType} + */ + AlertTypeEnum = AlertType; + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + private subs: Subscription[] = []; + + constructor(private orcidAuthService: OrcidAuthService, + private orcidQueueService: OrcidQueueService, + protected translateService: TranslateService, + private paginationService: PaginationService, + private notificationsService: NotificationsService, + private orcidHistoryService: OrcidHistoryDataService, + ) { + } + + ngOnInit(): void { + this.updateList(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (!changes.item.isFirstChange() && changes.item.currentValue !== changes.item.previousValue) { + this.updateList(); + } + } + + /** + * Retrieve queue list + */ + updateList() { + this.subs.push( + this.paginationService.getCurrentPagination(this.paginationOptions.id, this.paginationOptions).pipe( + debounceTime(100), + distinctUntilChanged(), + tap(() => this.processing$.next(true)), + switchMap((config: PaginationComponentOptions) => this.orcidQueueService.searchByProfileItemId(this.item.id, config, false)), + getFirstCompletedRemoteData() + ).subscribe((result: RemoteData>) => { + this.processing$.next(false); + this.list$.next(result); + this.orcidQueueService.clearFindByProfileItemRequests(); + }) + ); + } + + /** + * Return the list of orcid queue records + */ + getList(): Observable>> { + return this.list$.asObservable(); + } + + /** + * Return the icon class for the queue object type + * + * @param orcidQueue The OrcidQueue object + */ + getIconClass(orcidQueue: OrcidQueue): string { + if (!orcidQueue.recordType) { + return 'fa fa-user'; + } + switch (orcidQueue.recordType.toLowerCase()) { + case 'publication': + return 'fas fa-book'; + case 'project': + return 'fas fa-wallet'; + case 'education': + return 'fas fa-school'; + case 'affiliation': + return 'fas fa-university'; + case 'country': + return 'fas fa-globe-europe'; + case 'external_ids': + case 'researcher_urls': + return 'fas fa-external-link-alt'; + default: + return 'fa fa-user'; + } + } + + /** + * Return the icon tooltip message for the queue object type + * + * @param orcidQueue The OrcidQueue object + */ + getIconTooltip(orcidQueue: OrcidQueue): string { + if (!orcidQueue.recordType) { + return ''; + } + + return 'person.page.orcid.sync-queue.tooltip.' + orcidQueue.recordType.toLowerCase(); + } + + /** + * Return the icon tooltip message for the queue object operation + * + * @param orcidQueue The OrcidQueue object + */ + getOperationTooltip(orcidQueue: OrcidQueue): string { + if (!orcidQueue.operation) { + return ''; + } + + return 'person.page.orcid.sync-queue.tooltip.' + orcidQueue.operation.toLowerCase(); + } + + /** + * Return the icon class for the queue object operation + * + * @param orcidQueue The OrcidQueue object + */ + getOperationClass(orcidQueue: OrcidQueue): string { + + if (!orcidQueue.operation) { + return ''; + } + + switch (orcidQueue.operation.toLowerCase()) { + case 'insert': + return 'fas fa-plus'; + case 'update': + return 'fas fa-edit'; + case 'delete': + return 'fas fa-trash-alt'; + default: + return ''; + } + } + + /** + * Discard a queue entry from the synchronization + * + * @param orcidQueue The OrcidQueue object to discard + */ + discardEntry(orcidQueue: OrcidQueue) { + this.processing$.next(true); + this.subs.push(this.orcidQueueService.deleteById(orcidQueue.id).pipe( + getFirstCompletedRemoteData() + ).subscribe((remoteData) => { + this.processing$.next(false); + if (remoteData.isSuccess) { + this.notificationsService.success(this.translateService.get('person.page.orcid.sync-queue.discard.success')); + this.updateList(); + } else { + this.notificationsService.error(this.translateService.get('person.page.orcid.sync-queue.discard.error')); + } + })); + } + + /** + * Send a queue entry to orcid for the synchronization + * + * @param orcidQueue The OrcidQueue object to synchronize + */ + send(orcidQueue: OrcidQueue) { + this.processing$.next(true); + this.subs.push(this.orcidHistoryService.sendToORCID(orcidQueue).pipe( + getFirstCompletedRemoteData() + ).subscribe((remoteData) => { + this.processing$.next(false); + if (remoteData.isSuccess) { + this.handleOrcidHistoryRecordCreation(remoteData.payload); + } else if (remoteData.statusCode === 422) { + this.handleValidationErrors(remoteData); + } else { + this.notificationsService.error(this.translateService.get('person.page.orcid.sync-queue.send.error')); + } + })); + } + + + /** + * Return the error message for Unauthorized response + * @private + */ + private getUnauthorizedErrorContent(): Observable { + return this.orcidAuthService.getOrcidAuthorizeUrl(this.item).pipe( + switchMap((authorizeUrl) => this.translateService.get( + 'person.page.orcid.sync-queue.send.unauthorized-error.content', + { orcid: authorizeUrl } + )) + ); + } + + /** + * Manage notification by response + * @private + */ + private handleOrcidHistoryRecordCreation(orcidHistory: OrcidHistory) { + switch (orcidHistory.status) { + case 200: + case 201: + case 204: + this.notificationsService.success(this.translateService.get('person.page.orcid.sync-queue.send.success')); + this.updateList(); + break; + case 400: + this.notificationsService.error(this.translateService.get('person.page.orcid.sync-queue.send.bad-request-error'), null, { timeOut: 0 }); + break; + case 401: + combineLatest([ + this.translateService.get('person.page.orcid.sync-queue.send.unauthorized-error.title'), + this.getUnauthorizedErrorContent()], + ).subscribe(([title, content]) => { + this.notificationsService.error(title, content, { timeOut: 0 }, true); + }); + break; + case 404: + this.notificationsService.warning(this.translateService.get('person.page.orcid.sync-queue.send.not-found-warning')); + break; + case 409: + this.notificationsService.error(this.translateService.get('person.page.orcid.sync-queue.send.conflict-error'), null, { timeOut: 0 }); + break; + default: + this.notificationsService.error(this.translateService.get('person.page.orcid.sync-queue.send.error'), null, { timeOut: 0 }); + } + } + + /** + * Manage validation errors + * @private + */ + private handleValidationErrors(remoteData: RemoteData) { + const translations = [this.translateService.get('person.page.orcid.sync-queue.send.validation-error')]; + const errorMessage = remoteData.errorMessage; + if (errorMessage && errorMessage.indexOf('Error codes:') > 0) { + errorMessage.substring(errorMessage.indexOf(':') + 1).trim().split(',') + .forEach((error) => translations.push(this.translateService.get('person.page.orcid.sync-queue.send.validation-error.' + error))); + } + combineLatest(translations).subscribe((messages) => { + const title = messages.shift(); + const content = '
    ' + messages.map((message) => `
  • ${message}
  • `).join('') + '
'; + this.notificationsService.error(title, content, { timeOut: 0 }, true); + }); + } + + /** + * Unsubscribe from all subscriptions + */ + ngOnDestroy(): void { + this.list$ = null; + this.subs.filter((subscription) => hasValue(subscription)) + .forEach((subscription) => subscription.unsubscribe()); + } + +} diff --git a/src/app/shared/testing/active-router.stub.ts b/src/app/shared/testing/active-router.stub.ts index aa4bfce438..13cb81b42e 100644 --- a/src/app/shared/testing/active-router.stub.ts +++ b/src/app/shared/testing/active-router.stub.ts @@ -54,6 +54,7 @@ export class ActivatedRouteStub { get snapshot() { return { params: this.testParams, + paramMap: convertToParamMap(this.params), queryParamMap: convertToParamMap(this.testParams) }; } diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index cb664e19f7..3d5f15b4f2 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -3735,6 +3735,8 @@ "submission.import-external.source.wos": "Web Of Science", + "submission.import-external.source.orcidWorks": "ORCID", + "submission.import-external.source.epo": "European Patent Office (EPO)", "submission.import-external.source.loading": "Loading ...", @@ -4510,8 +4512,6 @@ "researcherprofile.success.claim.title" : "Success", - "person.page.orcid": "ORCID", - "person.page.orcid.create": "Create an ORCID ID", "person.page.orcid.granted-authorizations": "Granted authorizations", @@ -4580,7 +4580,11 @@ "person.page.orcid.sync-queue.empty-message": "The ORCID queue registry is empty", - "person.page.orcid.sync-queue.description" : "Description", + "person.page.orcid.sync-queue.table.header.type" : "Type", + + "person.page.orcid.sync-queue.table.header.description" : "Description", + + "person.page.orcid.sync-queue.table.header.action" : "Action", "person.page.orcid.sync-queue.description.affiliation": "Affiliations", @@ -4606,7 +4610,7 @@ "person.page.orcid.sync-queue.tooltip.publication": "Publication", - "person.page.orcid.sync-queue.tooltip.funding": "Funding", + "person.page.orcid.sync-queue.tooltip.project": "Project", "person.page.orcid.sync-queue.tooltip.affiliation": "Affiliation", @@ -4648,21 +4652,25 @@ "person.page.orcid.sync-queue.send.validation-error.title.required": "The title is required", - "person.page.orcid.sync-queue.send.validation-error.type.required": "The type is required", + "person.page.orcid.sync-queue.send.validation-error.type.required": "The dc.type is required", "person.page.orcid.sync-queue.send.validation-error.start-date.required": "The start date is required", "person.page.orcid.sync-queue.send.validation-error.funder.required": "The funder is required", + "person.page.orcid.sync-queue.send.validation-error.country.invalid": "Invalid 2 digits ISO 3166 country", + "person.page.orcid.sync-queue.send.validation-error.organization.required": "The organization is required", "person.page.orcid.sync-queue.send.validation-error.organization.name-required": "The organization's name is required", + "person.page.orcid.sync-queue.send.validation-error.publication.date-invalid" : "The publication date must be one year after 1900", + "person.page.orcid.sync-queue.send.validation-error.organization.address-required": "The organization to be sent requires an address", "person.page.orcid.sync-queue.send.validation-error.organization.city-required": "The address of the organization to be sent requires a city", - "person.page.orcid.sync-queue.send.validation-error.organization.country-required": "The address of the organization to be sent requires a country", + "person.page.orcid.sync-queue.send.validation-error.organization.country-required": "The address of the organization to be sent requires a valid 2 digits ISO 3166 country", "person.page.orcid.sync-queue.send.validation-error.disambiguated-organization.required": "An identifier to disambiguate organizations is required. Supported ids are GRID, Ringgold, Legal Entity identifiers (LEIs) and Crossref Funder Registry identifiers",