Merge branch 'CST-5668' into CST-5339

# Conflicts:
#	src/app/item-page/orcid-page/orcid-page.component.html
This commit is contained in:
Davide Negretti
2022-06-15 11:18:46 +02:00
15 changed files with 408 additions and 174 deletions

View File

@@ -23,14 +23,15 @@ import { ResearcherProfileService } from './researcher-profile.service';
import { RouterMock } from '../../shared/mocks/router.mock'; import { RouterMock } from '../../shared/mocks/router.mock';
import { ResearcherProfile } from './model/researcher-profile.model'; import { ResearcherProfile } from './model/researcher-profile.model';
import { Item } from '../shared/item.model'; import { Item } from '../shared/item.model';
import { RemoveOperation, ReplaceOperation } from 'fast-json-patch'; import { AddOperation, RemoveOperation, ReplaceOperation } from 'fast-json-patch';
import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { HttpOptions } from '../dspace-rest/dspace-rest.service';
import { PostRequest } from '../data/request.models'; import { PostRequest } from '../data/request.models';
import { followLink } from '../../shared/utils/follow-link-config.model'; import { followLink } from '../../shared/utils/follow-link-config.model';
import { ConfigurationProperty } from '../shared/configuration-property.model'; import { ConfigurationProperty } from '../shared/configuration-property.model';
import { ConfigurationDataService } from '../data/configuration-data.service'; import { ConfigurationDataService } from '../data/configuration-data.service';
import { createPaginatedList } from '../../shared/testing/utils.test'; import { createPaginatedList } from '../../shared/testing/utils.test';
import { environment } from '../../../environments/environment'; import { NativeWindowRefMock } from '../../shared/mocks/mock-native-window-ref';
import { URLCombiner } from '../url-combiner/url-combiner';
describe('ResearcherProfileService', () => { describe('ResearcherProfileService', () => {
let scheduler: TestScheduler; let scheduler: TestScheduler;
@@ -42,6 +43,8 @@ describe('ResearcherProfileService', () => {
let halService: HALEndpointService; let halService: HALEndpointService;
let responseCacheEntry: RequestEntry; let responseCacheEntry: RequestEntry;
let configurationDataService: ConfigurationDataService; let configurationDataService: ConfigurationDataService;
let nativeWindowService: NativeWindowRefMock;
let routerStub: any;
const researcherProfileId = 'beef9946-rt56-479e-8f11-b90cbe9f7241'; const researcherProfileId = 'beef9946-rt56-479e-8f11-b90cbe9f7241';
const itemId = 'beef9946-rt56-479e-8f11-b90cbe9f7241'; const itemId = 'beef9946-rt56-479e-8f11-b90cbe9f7241';
@@ -102,7 +105,14 @@ describe('ResearcherProfileService', () => {
}], }],
'dspace.entity.type': [{ 'dspace.entity.type': [{
'value': 'Person' 'value': 'Person'
}] }],
'dspace.object.owner': [{
'value': 'test person',
'language': null,
'authority': 'researcher-profile-id',
'confidence': 600,
'place': 0
}],
} }
}); });
@@ -238,15 +248,17 @@ describe('ResearcherProfileService', () => {
const notificationsService = {} as NotificationsService; const notificationsService = {} as NotificationsService;
const http = {} as HttpClient; const http = {} as HttpClient;
const comparator = {} as any; const comparator = {} as any;
const routerStub: any = new RouterMock(); routerStub = new RouterMock();
const itemService = jasmine.createSpyObj('ItemService', { const itemService = jasmine.createSpyObj('ItemService', {
findByHref: jasmine.createSpy('findByHref') findByHref: jasmine.createSpy('findByHref')
}); });
configurationDataService = jasmine.createSpyObj('configurationDataService', { configurationDataService = jasmine.createSpyObj('configurationDataService', {
findByPropertyName: jasmine.createSpy('findByPropertyName') findByPropertyName: jasmine.createSpy('findByPropertyName')
}); });
nativeWindowService = new NativeWindowRefMock();
service = new ResearcherProfileService( service = new ResearcherProfileService(
nativeWindowService,
requestService, requestService,
rdbService, rdbService,
objectCache, objectCache,
@@ -455,7 +467,28 @@ describe('ResearcherProfileService', () => {
}); });
}); });
describe('unlinkOrcid', () => { 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<string>[] = [{
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(() => { beforeEach(() => {
scheduler = getTestScheduler(); scheduler = getTestScheduler();
spyOn((service as any).dataService, 'patch').and.returnValue(createSuccessfulRemoteDataObject$(researcherProfilePatched)); spyOn((service as any).dataService, 'patch').and.returnValue(createSuccessfulRemoteDataObject$(researcherProfilePatched));
@@ -468,7 +501,7 @@ describe('ResearcherProfileService', () => {
op: 'remove' op: 'remove'
}]; }];
scheduler.schedule(() => service.unlinkOrcid(mockItemLinkedToOrcid).subscribe()); scheduler.schedule(() => service.unlinkOrcidByItem(mockItemLinkedToOrcid).subscribe());
scheduler.flush(); scheduler.flush();
expect((service as any).dataService.patch).toHaveBeenCalledWith(researcherProfile, operations); expect((service as any).dataService.patch).toHaveBeenCalledWith(researcherProfile, operations);
@@ -477,6 +510,7 @@ describe('ResearcherProfileService', () => {
describe('getOrcidAuthorizeUrl', () => { describe('getOrcidAuthorizeUrl', () => {
beforeEach(() => { beforeEach(() => {
routerStub.setRoute('/entities/person/uuid/orcid');
(service as any).configurationService.findByPropertyName.and.returnValues( (service as any).configurationService.findByPropertyName.and.returnValues(
createSuccessfulRemoteDataObject$(authorizeUrl), createSuccessfulRemoteDataObject$(authorizeUrl),
createSuccessfulRemoteDataObject$(appClientId), createSuccessfulRemoteDataObject$(appClientId),
@@ -486,7 +520,7 @@ describe('ResearcherProfileService', () => {
it('should build the url properly', () => { it('should build the url properly', () => {
const result = service.getOrcidAuthorizeUrl(mockItemUnlinkedToOrcid); const result = service.getOrcidAuthorizeUrl(mockItemUnlinkedToOrcid);
const redirectUri = environment.rest.baseUrl + '/api/eperson/orcid/' + mockItemUnlinkedToOrcid.id + '/?url=undefined'; 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 url = 'orcid.authorize-url?client_id=orcid.application-client-id&redirect_uri=' + redirectUri + '&response_type=code&scope=/authenticate /read-limited';
const expected = cold('(a|)', { const expected = cold('(a|)', {

View File

@@ -1,14 +1,12 @@
/* eslint-disable max-classes-per-file */ /* eslint-disable max-classes-per-file */
import { HttpClient, HttpHeaders } from '@angular/common/http'; import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Inject, Injectable } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { Operation, RemoveOperation, ReplaceOperation } from 'fast-json-patch'; import { AddOperation, Operation, RemoveOperation, ReplaceOperation } from 'fast-json-patch';
import { combineLatest, Observable } from 'rxjs'; import { combineLatest, Observable } from 'rxjs';
import { find, map, switchMap } from 'rxjs/operators'; import { find, map, switchMap } from 'rxjs/operators';
import { environment } from '../../../environments/environment';
import { NotificationsService } from '../../shared/notifications/notifications.service'; import { NotificationsService } from '../../shared/notifications/notifications.service';
import { dataService } from '../cache/builders/build-decorators'; import { dataService } from '../cache/builders/build-decorators';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
@@ -24,7 +22,6 @@ import { HALEndpointService } from '../shared/hal-endpoint.service';
import { NoContent } from '../shared/NoContent.model'; import { NoContent } from '../shared/NoContent.model';
import { import {
getAllCompletedRemoteData, getAllCompletedRemoteData,
getFinishedRemoteData,
getFirstCompletedRemoteData, getFirstCompletedRemoteData,
getFirstSucceededRemoteDataPayload getFirstSucceededRemoteDataPayload
} from '../shared/operators'; } from '../shared/operators';
@@ -37,6 +34,8 @@ import { CoreState } from '../core-state.model';
import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { Item } from '../shared/item.model'; import { Item } from '../shared/item.model';
import { createFailedRemoteDataObject$ } from '../../shared/remote-data.utils'; 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. * A private DataService implementation to delegate specific methods to.
@@ -70,6 +69,7 @@ export class ResearcherProfileService {
protected responseMsToLive: number = 10 * 1000; protected responseMsToLive: number = 10 * 1000;
constructor( constructor(
@Inject(NativeWindowService) protected _window: NativeWindowRef,
protected requestService: RequestService, protected requestService: RequestService,
protected rdbService: RemoteDataBuildService, protected rdbService: RemoteDataBuildService,
protected objectCache: ObjectCacheService, protected objectCache: ObjectCacheService,
@@ -202,18 +202,38 @@ export class ResearcherProfileService {
} }
/** /**
* If the given item represents a profile unlink it from ORCID. * 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 unlinkOrcid(item: Item): Observable<RemoteData<ResearcherProfile>> { public linkOrcidByItem(person: Item, code: string): Observable<RemoteData<ResearcherProfile>> {
const operations: AddOperation<string>[] = [{
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<RemoteData<ResearcherProfile>> {
const operations: RemoveOperation[] = [{ const operations: RemoveOperation[] = [{
path:'/orcid', path:'/orcid',
op:'remove' op:'remove'
}]; }];
return this.findById(item.firstMetadata('dspace.object.owner').authority).pipe( return this.findById(person.firstMetadata('dspace.object.owner').authority).pipe(
getFirstCompletedRemoteData(), getFirstCompletedRemoteData(),
switchMap((profileRD) => this.dataService.patch(profileRD.payload, operations)), switchMap((profileRD) => this.updateByOrcidOperations(profileRD.payload, operations))
getFinishedRemoteData()
); );
} }
@@ -229,7 +249,9 @@ export class ResearcherProfileService {
this.configurationService.findByPropertyName('orcid.scope').pipe(getFirstSucceededRemoteDataPayload())] this.configurationService.findByPropertyName('orcid.scope').pipe(getFirstSucceededRemoteDataPayload())]
).pipe( ).pipe(
map(([authorizeUrl, clientId, scopes]) => { map(([authorizeUrl, clientId, scopes]) => {
const redirectUri = environment.rest.baseUrl + '/api/eperson/orcid/' + profile.id + '/?url=' + encodeURIComponent(this.router.url); 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=' return authorizeUrl.values[0] + '?client_id=' + clientId.values[0] + '&redirect_uri=' + redirectUri + '&response_type=code&scope='
+ scopes.values.join(' '); + scopes.values.join(' ');
})); }));

View File

@@ -14,7 +14,9 @@ import { ThemedItemPageComponent } from './simple/themed-item-page.component';
import { ThemedFullItemPageComponent } from './full/themed-full-item-page.component'; import { ThemedFullItemPageComponent } from './full/themed-full-item-page.component';
import { MenuItemType } from '../shared/menu/menu-item-type.model'; import { MenuItemType } from '../shared/menu/menu-item-type.model';
import { VersionPageComponent } from './version-page/version-page/version-page.component'; import { VersionPageComponent } from './version-page/version-page/version-page.component';
import { BitstreamRequestACopyPageComponent } from '../shared/bitstream-request-a-copy-page/bitstream-request-a-copy-page.component'; import {
BitstreamRequestACopyPageComponent
} from '../shared/bitstream-request-a-copy-page/bitstream-request-a-copy-page.component';
import { REQUEST_COPY_MODULE_PATH } from '../app-routing-paths'; import { REQUEST_COPY_MODULE_PATH } from '../app-routing-paths';
import { OrcidPageComponent } from './orcid-page/orcid-page.component'; import { OrcidPageComponent } from './orcid-page/orcid-page.component';
import { OrcidPageGuard } from './orcid-page/orcid-page.guard'; import { OrcidPageGuard } from './orcid-page/orcid-page.guard';
@@ -56,7 +58,7 @@ import { OrcidPageGuard } from './orcid-page/orcid-page.guard';
{ {
path: ORCID_PATH, path: ORCID_PATH,
component: OrcidPageComponent, component: OrcidPageComponent,
canActivate: [OrcidPageGuard] canActivate: [AuthenticatedGuard, OrcidPageGuard]
} }
], ],
data: { data: {

View File

@@ -1,19 +1,12 @@
<div class="container custom-accordion mb-4"> <div class="container mb-5">
<ngb-accordion activeIds="auth"> <h2>{{'person.orcid.registry.auth' | translate}}</h2>
<ngb-panel title="{{'person.orcid.registry.auth' | translate}}" id="auth"> <ng-container *ngIf="(isLinkedToOrcid() | async); then orcidLinked; else orcidNotLinked"></ng-container>
<ng-template ngbPanelContent>
<div class="container">
<ng-container *ngIf="(isLinkedToOrcid() | async); then orcidLinked; else orcidNotLinked"></ng-container>
</div>
</ng-template>
</ngb-panel>
</ngb-accordion>
</div> </div>
<ng-template #orcidLinked> <ng-template #orcidLinked>
<div data-test="orcidLinked"> <div data-test="orcidLinked">
<div class="row mb-3"> <div class="row">
<div *ngIf="(hasOrcidAuthorizations() | async)" class="col-sm-6" data-test="hasOrcidAuthorizations"> <div *ngIf="(hasOrcidAuthorizations() | async)" class="col-sm-6 mb-3" data-test="hasOrcidAuthorizations">
<div class="card h-100"> <div class="card h-100">
<div class="card-header">{{ 'person.page.orcid.granted-authorizations'| translate }}</div> <div class="card-header">{{ 'person.page.orcid.granted-authorizations'| translate }}</div>
<div class="card-body"> <div class="card-body">
@@ -27,7 +20,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-sm-6"> <div class="col-sm-6 mb-3">
<div class="card h-100"> <div class="card h-100">
<div class="card-header">{{ 'person.page.orcid.missing-authorizations'| translate }}</div> <div class="card-header">{{ 'person.page.orcid.missing-authorizations'| translate }}</div>
<div class="card-body"> <div class="card-body">

View File

@@ -119,7 +119,7 @@ describe('OrcidAuthComponent test suite', () => {
isLinkedToOrcid: jasmine.createSpy('isLinkedToOrcid'), isLinkedToOrcid: jasmine.createSpy('isLinkedToOrcid'),
onlyAdminCanDisconnectProfileFromOrcid: jasmine.createSpy('onlyAdminCanDisconnectProfileFromOrcid'), onlyAdminCanDisconnectProfileFromOrcid: jasmine.createSpy('onlyAdminCanDisconnectProfileFromOrcid'),
ownerCanDisconnectProfileFromOrcid: jasmine.createSpy('ownerCanDisconnectProfileFromOrcid'), ownerCanDisconnectProfileFromOrcid: jasmine.createSpy('ownerCanDisconnectProfileFromOrcid'),
unlinkOrcid: jasmine.createSpy('unlinkOrcid') unlinkOrcidByItem: jasmine.createSpy('unlinkOrcidByItem')
}); });
void TestBed.configureTestingModule({ void TestBed.configureTestingModule({
@@ -200,7 +200,7 @@ describe('OrcidAuthComponent test suite', () => {
describe('and unlink is successfully', () => { describe('and unlink is successfully', () => {
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
comp.item = mockItemLinkedToOrcid; comp.item = mockItemLinkedToOrcid;
researcherProfileService.unlinkOrcid.and.returnValue(createSuccessfulRemoteDataObject$(new ResearcherProfile())); researcherProfileService.unlinkOrcidByItem.and.returnValue(createSuccessfulRemoteDataObject$(new ResearcherProfile()));
spyOn(comp.unlink, 'emit'); spyOn(comp.unlink, 'emit');
fixture.detectChanges(); fixture.detectChanges();
})); }));
@@ -217,7 +217,7 @@ describe('OrcidAuthComponent test suite', () => {
describe('and unlink is failed', () => { describe('and unlink is failed', () => {
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
comp.item = mockItemLinkedToOrcid; comp.item = mockItemLinkedToOrcid;
researcherProfileService.unlinkOrcid.and.returnValue(createFailedRemoteDataObject$()); researcherProfileService.unlinkOrcidByItem.and.returnValue(createFailedRemoteDataObject$());
fixture.detectChanges(); fixture.detectChanges();
})); }));

View File

@@ -10,6 +10,7 @@ import { Item } from '../../../core/shared/item.model';
import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { RemoteData } from '../../../core/data/remote-data'; import { RemoteData } from '../../../core/data/remote-data';
import { ResearcherProfile } from '../../../core/profile/model/researcher-profile.model'; import { ResearcherProfile } from '../../../core/profile/model/researcher-profile.model';
import { getFirstCompletedRemoteData } from '../../../core/shared/operators';
@Component({ @Component({
selector: 'ds-orcid-auth', selector: 'ds-orcid-auth',
@@ -169,7 +170,9 @@ export class OrcidAuthComponent implements OnInit, OnChanges {
*/ */
unlinkOrcid(): void { unlinkOrcid(): void {
this.unlinkProcessing.next(true); this.unlinkProcessing.next(true);
this.researcherProfileService.unlinkOrcid(this.item).subscribe((remoteData: RemoteData<ResearcherProfile>) => { this.researcherProfileService.unlinkOrcidByItem(this.item).pipe(
getFirstCompletedRemoteData()
).subscribe((remoteData: RemoteData<ResearcherProfile>) => {
this.unlinkProcessing.next(false); this.unlinkProcessing.next(false);
if (remoteData.isSuccess) { if (remoteData.isSuccess) {
this.notificationsService.success(this.translateService.get('person.page.orcid.unlink.success')); this.notificationsService.success(this.translateService.get('person.page.orcid.unlink.success'));

View File

@@ -1,4 +1,4 @@
<div *ngIf="(item | async)" class="container"> <div *ngIf="!(processingConnection | async) && (item | async)" class="container">
<div class="button-row bottom mb-3"> <div class="button-row bottom mb-3">
<div class="text-right"> <div class="text-right">
<a [routerLink]="getItemPage()" role="button" class="btn btn-outline-secondary" data-test="back-button"> <a [routerLink]="getItemPage()" role="button" class="btn btn-outline-secondary" data-test="back-button">
@@ -8,6 +8,12 @@
</div> </div>
</div> </div>
<ds-orcid-auth *ngIf="(item | async)" [item]="(item | async)" (unlink)="updateItem()"></ds-orcid-auth> <ds-loading *ngIf="(processingConnection | async)" [message]="'person.page.orcid.link.processing' | translate"></ds-loading>
<ds-orcid-sync-setting *ngIf="(item | async) && isLinkedToOrcid()" [item]="(item | async)"></ds-orcid-sync-setting> <div class="container" *ngIf="!(processingConnection | async) && !(connectionStatus | async)" data-test="error-box">
<ds-orcid-queue *ngIf="isLinkedToOrcid()" [item]="(item | async)"></ds-orcid-queue> <ds-alert [type]="'alert-danger'">{{'person.page.orcid.link.error.message' | translate}}</ds-alert>
</div>
<ng-container *ngIf="!(processingConnection | async) && (item | async) && (connectionStatus | async)" >
<ds-orcid-auth [item]="(item | async)" (unlink)="updateItem()" data-test="orcid-auth"></ds-orcid-auth>
<ds-orcid-sync-setting *ngIf="isLinkedToOrcid()" [item]="(item | async)" data-test="orcid-sync-setting"></ds-orcid-sync-setting>
<ds-orcid-queue *ngIf="isLinkedToOrcid()" [item]="(item | async)"></ds-orcid-queue>
</ng-container>

View File

@@ -1,4 +1,4 @@
import { NO_ERRORS_SCHEMA } from '@angular/core'; import { NO_ERRORS_SCHEMA, PLATFORM_ID } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { ComponentFixture, fakeAsync, TestBed, waitForAsync } from '@angular/core/testing'; import { ComponentFixture, fakeAsync, TestBed, waitForAsync } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing'; import { RouterTestingModule } from '@angular/router/testing';
@@ -13,11 +13,16 @@ import { AuthService } from '../../core/auth/auth.service';
import { ActivatedRouteStub } from '../../shared/testing/active-router.stub'; import { ActivatedRouteStub } from '../../shared/testing/active-router.stub';
import { ResearcherProfileService } from '../../core/profile/researcher-profile.service'; import { ResearcherProfileService } from '../../core/profile/researcher-profile.service';
import { OrcidPageComponent } from './orcid-page.component'; import { OrcidPageComponent } from './orcid-page.component';
import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import {
createFailedRemoteDataObject$,
createSuccessfulRemoteDataObject,
createSuccessfulRemoteDataObject$
} from '../../shared/remote-data.utils';
import { Item } from '../../core/shared/item.model'; import { Item } from '../../core/shared/item.model';
import { createPaginatedList } from '../../shared/testing/utils.test'; import { createPaginatedList } from '../../shared/testing/utils.test';
import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock';
import { ItemDataService } from '../../core/data/item-data.service'; import { ItemDataService } from '../../core/data/item-data.service';
import { ResearcherProfile } from '../../core/profile/model/researcher-profile.model';
describe('OrcidPageComponent test suite', () => { describe('OrcidPageComponent test suite', () => {
let comp: OrcidPageComponent; let comp: OrcidPageComponent;
@@ -29,7 +34,21 @@ describe('OrcidPageComponent test suite', () => {
let itemDataService: jasmine.SpyObj<ItemDataService>; let itemDataService: jasmine.SpyObj<ItemDataService>;
let researcherProfileService: jasmine.SpyObj<ResearcherProfileService>; let researcherProfileService: jasmine.SpyObj<ResearcherProfileService>;
const mockResearcherProfile: ResearcherProfile = Object.assign(new ResearcherProfile(), {
id: 'test-id',
visible: true,
type: 'profile',
_links: {
item: {
href: 'https://rest.api/rest/api/profiles/test-id/item'
},
self: {
href: 'https://rest.api/rest/api/profiles/test-id'
},
}
});
const mockItem: Item = Object.assign(new Item(), { const mockItem: Item = Object.assign(new Item(), {
id: 'test-id',
bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])),
metadata: { metadata: {
'dc.title': [ 'dc.title': [
@@ -41,6 +60,7 @@ describe('OrcidPageComponent test suite', () => {
} }
}); });
const mockItemLinkedToOrcid: Item = Object.assign(new Item(), { const mockItemLinkedToOrcid: Item = Object.assign(new Item(), {
id: 'test-id',
bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])),
metadata: { metadata: {
'dc.title': [ 'dc.title': [
@@ -66,12 +86,11 @@ describe('OrcidPageComponent test suite', () => {
dso: createSuccessfulRemoteDataObject(mockItem), dso: createSuccessfulRemoteDataObject(mockItem),
}; };
routeStub = Object.assign(new ActivatedRouteStub(), { routeStub = new ActivatedRouteStub({}, routeData);
data: observableOf(routeData)
});
researcherProfileService = jasmine.createSpyObj('researcherProfileService', { researcherProfileService = jasmine.createSpyObj('researcherProfileService', {
isLinkedToOrcid: jasmine.createSpy('isLinkedToOrcid') isLinkedToOrcid: jasmine.createSpy('isLinkedToOrcid'),
linkOrcidByItem: jasmine.createSpy('linkOrcidByItem'),
}); });
itemDataService = jasmine.createSpyObj('ItemDataService', { itemDataService = jasmine.createSpyObj('ItemDataService', {
@@ -94,6 +113,7 @@ describe('OrcidPageComponent test suite', () => {
{ provide: ResearcherProfileService, useValue: researcherProfileService }, { provide: ResearcherProfileService, useValue: researcherProfileService },
{ provide: AuthService, useValue: authService }, { provide: AuthService, useValue: authService },
{ provide: ItemDataService, useValue: itemDataService }, { provide: ItemDataService, useValue: itemDataService },
{ provide: PLATFORM_ID, useValue: 'browser' },
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
@@ -105,27 +125,96 @@ describe('OrcidPageComponent test suite', () => {
fixture = TestBed.createComponent(OrcidPageComponent); fixture = TestBed.createComponent(OrcidPageComponent);
comp = fixture.componentInstance; comp = fixture.componentInstance;
authService.isAuthenticated.and.returnValue(observableOf(true)); authService.isAuthenticated.and.returnValue(observableOf(true));
fixture.detectChanges();
})); }));
it('should create', () => { describe('whn has no query param', () => {
const btn = fixture.debugElement.queryAll(By.css('[data-test="back-button"]')); beforeEach(waitForAsync(() => {
expect(comp).toBeTruthy(); fixture.detectChanges();
expect(btn.length).toBe(1); }));
it('should create', () => {
const btn = fixture.debugElement.queryAll(By.css('[data-test="back-button"]'));
const auth = fixture.debugElement.query(By.css('[data-test="orcid-auth"]'));
const settings = fixture.debugElement.query(By.css('[data-test="orcid-sync-setting"]'));
expect(comp).toBeTruthy();
expect(btn.length).toBe(1);
expect(auth).toBeTruthy();
expect(settings).toBeTruthy();
expect(comp.itemId).toBe('test-id');
});
it('should call isLinkedToOrcid', () => {
comp.isLinkedToOrcid();
expect(researcherProfileService.isLinkedToOrcid).toHaveBeenCalledWith(comp.item.value);
});
it('should update item', fakeAsync(() => {
itemDataService.findById.and.returnValue(createSuccessfulRemoteDataObject$(mockItemLinkedToOrcid));
scheduler.schedule(() => comp.updateItem());
scheduler.flush();
expect(comp.item.value).toEqual(mockItemLinkedToOrcid);
}));
}); });
it('should call isLinkedToOrcid', () => { describe('when query param contains orcid code', () => {
comp.isLinkedToOrcid(); beforeEach(waitForAsync(() => {
spyOn(comp, 'updateItem').and.callThrough();
routeStub.testParams = {
code: 'orcid-code'
};
}));
expect(researcherProfileService.isLinkedToOrcid).toHaveBeenCalledWith(comp.item.value); describe('and linking to orcid profile is successfully', () => {
beforeEach(waitForAsync(() => {
researcherProfileService.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(comp.updateItem).toHaveBeenCalled();
});
it('should create', () => {
const btn = fixture.debugElement.queryAll(By.css('[data-test="back-button"]'));
const auth = fixture.debugElement.query(By.css('[data-test="orcid-auth"]'));
const settings = fixture.debugElement.query(By.css('[data-test="orcid-sync-setting"]'));
expect(comp).toBeTruthy();
expect(btn.length).toBe(1);
expect(auth).toBeTruthy();
expect(settings).toBeTruthy();
expect(comp.itemId).toBe('test-id');
});
});
describe('and linking to orcid profile is failed', () => {
beforeEach(waitForAsync(() => {
researcherProfileService.linkOrcidByItem.and.returnValue(createFailedRemoteDataObject$());
itemDataService.findById.and.returnValue(createSuccessfulRemoteDataObject$(mockItemLinkedToOrcid));
fixture.detectChanges();
}));
it('should call linkOrcidByItem', () => {
expect(researcherProfileService.linkOrcidByItem).toHaveBeenCalledWith(mockItem, 'orcid-code');
expect(comp.updateItem).not.toHaveBeenCalled();
});
it('should create', () => {
const btn = fixture.debugElement.queryAll(By.css('[data-test="back-button"]'));
const auth = fixture.debugElement.query(By.css('[data-test="orcid-auth"]'));
const settings = fixture.debugElement.query(By.css('[data-test="orcid-sync-setting"]'));
const error = fixture.debugElement.query(By.css('[data-test="error-box"]'));
expect(comp).toBeTruthy();
expect(btn.length).toBe(1);
expect(error).toBeTruthy();
expect(auth).toBeFalsy();
expect(settings).toBeFalsy();
});
});
}); });
it('should update item', fakeAsync(() => {
itemDataService.findById.and.returnValue(createSuccessfulRemoteDataObject$(mockItemLinkedToOrcid));
scheduler.schedule(() => comp.updateItem());
scheduler.flush();
expect(comp.item.value).toEqual(mockItemLinkedToOrcid);
}));
}); });

View File

@@ -1,8 +1,8 @@
import { Component, OnInit } from '@angular/core'; import { Component, Inject, OnInit, PLATFORM_ID } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, ParamMap, Router } from '@angular/router';
import { BehaviorSubject } from 'rxjs'; import { BehaviorSubject, combineLatest } from 'rxjs';
import { map } from 'rxjs/operators'; import { map, take } from 'rxjs/operators';
import { ResearcherProfileService } from '../../core/profile/researcher-profile.service'; import { ResearcherProfileService } from '../../core/profile/researcher-profile.service';
import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../../core/shared/operators'; import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../../core/shared/operators';
@@ -12,6 +12,9 @@ import { getItemPageRoute } from '../item-page-routing-paths';
import { AuthService } from '../../core/auth/auth.service'; import { AuthService } from '../../core/auth/auth.service';
import { redirectOn4xx } from '../../core/shared/authorized.operators'; import { redirectOn4xx } from '../../core/shared/authorized.operators';
import { ItemDataService } from '../../core/data/item-data.service'; 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 * A component that represents the orcid settings page
@@ -23,12 +26,28 @@ import { ItemDataService } from '../../core/data/item-data.service';
}) })
export class OrcidPageComponent implements OnInit { export class OrcidPageComponent implements OnInit {
/**
* A boolean representing if the connection operation with orcid profile is in progress
*/
connectionStatus: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
/** /**
* The item for which showing the orcid settings * The item for which showing the orcid settings
*/ */
item: BehaviorSubject<Item> = new BehaviorSubject<Item>(null); item: BehaviorSubject<Item> = new BehaviorSubject<Item>(null);
/**
* The item id for which showing the orcid settings
*/
itemId: string;
/**
* A boolean representing if the connection operation with orcid profile is in progress
*/
processingConnection: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(true);
constructor( constructor(
@Inject(PLATFORM_ID) private platformId: any,
private authService: AuthService, private authService: AuthService,
private itemService: ItemDataService, private itemService: ItemDataService,
private researcherProfileService: ResearcherProfileService, private researcherProfileService: ResearcherProfileService,
@@ -41,13 +60,33 @@ export class OrcidPageComponent implements OnInit {
* Retrieve the item for which showing the orcid settings * Retrieve the item for which showing the orcid settings
*/ */
ngOnInit(): void { ngOnInit(): void {
this.route.data.pipe( if (isPlatformBrowser(this.platformId)) {
map((data) => data.dso as RemoteData<Item>), const codeParam$ = this.route.queryParamMap.pipe(
redirectOn4xx(this.router, this.authService), take(1),
getFirstSucceededRemoteDataPayload() map((paramMap: ParamMap) => paramMap.get('code')),
).subscribe((item) => { );
this.item.next(item);
}); const item$ = this.route.data.pipe(
map((data) => data.dso as RemoteData<Item>),
redirectOn4xx(this.router, this.authService),
getFirstSucceededRemoteDataPayload()
);
combineLatest([codeParam$, item$]).subscribe(([codeParam, item]) => {
this.itemId = item.id;
/**
* Check if code is present in the query param. If so it means this page is loaded after attempting to
* link the person to the ORCID profile, otherwise the person is already linked to ORCID profile
*/
if (isNotEmpty(codeParam)) {
this.linkProfileToOrcid(item, codeParam);
} else {
this.item.next(item);
this.processingConnection.next(false);
this.connectionStatus.next(true);
}
});
}
} }
/** /**
@@ -70,7 +109,7 @@ export class OrcidPageComponent implements OnInit {
* Retrieve the updated profile item * Retrieve the updated profile item
*/ */
updateItem(): void { updateItem(): void {
this.itemService.findById(this.item.value.id, false).pipe( this.itemService.findById(this.itemId, false).pipe(
getFirstCompletedRemoteData() getFirstCompletedRemoteData()
).subscribe((itemRD: RemoteData<Item>) => { ).subscribe((itemRD: RemoteData<Item>) => {
if (itemRD.hasSucceeded) { if (itemRD.hasSucceeded) {
@@ -79,4 +118,28 @@ export class OrcidPageComponent implements OnInit {
}); });
} }
/**
* Link person item to ORCID profile by using the code received after redirect from ORCID.
*
* @param person The person item to link to ORCID profile
* @param code The auth-code received from ORCID
*/
private linkProfileToOrcid(person: Item, code: string) {
this.researcherProfileService.linkOrcidByItem(person, code).pipe(
getFirstCompletedRemoteData()
).subscribe((profileRD: RemoteData<ResearcherProfile>) => {
this.processingConnection.next(false);
if (profileRD.hasSucceeded) {
this.connectionStatus.next(true);
this.updateItem();
} else {
this.item.next(person);
this.connectionStatus.next(false);
}
// update route removing the code from query params
const redirectUrl = this.router.url.split('?')[0];
this.router.navigate([redirectUrl]);
});
}
} }

View File

@@ -1,98 +1,106 @@
<div class="container custom-accordion mb-4"> <div class="container mb-5">
<ngb-accordion activeIds="setting"> <h2>{{'person.orcid.sync.setting' | translate}}</h2>
<ngb-panel title="{{'person.orcid.sync.setting' | translate}}" id="setting"> <form #f="ngForm" (ngSubmit)="onSubmit(f.form)">
<ng-template ngbPanelContent> <div class="row mb-3">
<div class="container"> <div class="col-md">
<form #f="ngForm" (ngSubmit)="onSubmit(f.form)"> <div class="card" data-test="sync-mode">
<div class="row mb-3"> <div class="card-header">{{ 'person.page.orcid.synchronization-mode'| translate }}</div>
<div class="col-md"> <div class="card-body">
<div class="card" data-test="sync-mode"> <div class="container">
<div class="card-header">{{ 'person.page.orcid.synchronization-mode'| translate }}</div> <div class="row">
<div class="card-body"> <ds-alert [type]="'alert-info'">
<div class="container"> {{ 'person.page.orcid.synchronization-mode-message' | translate}}
<div class="row"> </ds-alert>
<ds-alert [type]="'alert-info'"> </div>
{{ 'person.page.orcid.synchronization-mode-message' | translate}} <div class="form-group row">
</ds-alert> <label for="syncMode">{{ 'person.page.orcid.synchronization-mode.label'| translate }}</label>
</div> <select class="form-control" [(ngModel)]="currentSyncMode" name="syncMode" id="syncMode"
<div class="form-group row"> required>
<label for="syncMode">{{ 'person.page.orcid.synchronization-mode.label'| translate }}</label> <option *ngFor="let syncMode of syncModes"
<select class="form-control" [(ngModel)]="currentSyncMode" name="syncMode" id="syncMode" [value]="syncMode.value">{{ syncMode.label | translate }}</option>
required> </select>
<option *ngFor="let syncMode of syncModes"
[value]="syncMode.value">{{ syncMode.label | translate }}</option>
</select>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
<div class="row mb-3"> </div>
<div class="col-md">
<div class="card" data-test="sync-mode-publication">
<div class="card-header">{{ 'person.page.orcid.publications-preferences'| translate }}</div>
<div class="card-body">
<div class="m-3">
<div class="form-group">
<div *ngFor="let option of syncPublicationOptions" class="row form-check">
<input type="radio" [(ngModel)]="currentSyncPublications"
name="syncPublications" id="publicationOption_{{option.value}}" [value]="option.value"
required>
<label for="publicationOption_{{option.value}}"
class="ml-2">{{option.label | translate}}</label>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md">
<div class="card" data-test="sync-mode-funding">
<div class="card-header">{{ 'person.page.orcid.funding-preferences'| translate }}</div>
<div class="card-body">
<div class="m-3">
<div class="form-group">
<div *ngFor="let option of syncFundingOptions" class="row form-check">
<input type="radio" [(ngModel)]="currentSyncFunding"
name="syncFundings" id="fundingOption_{{option.value}}" [value]="option.value"
required>
<label for="fundingOption_{{option.value}}" class="ml-2">{{option.label | translate}}</label>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md">
<div class="card" data-test="profile-preferences">
<div class="card-header">{{ 'person.page.orcid.profile-preferences'| translate }}</div>
<div class="card-body">
<div class="m-3">
<div class="form-group">
<div *ngFor="let option of syncProfileOptions" class="row form-check">
<input type="checkbox" [(ngModel)]="option.checked"
name="syncProfile_{{option.value}}" id="profileOption_{{option.value}}"
[value]="option.value">
<label for="profileOption_{{option.value}}" class="ml-2">{{option.label | translate}}</label>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col">
<button type="submit" class="btn btn-primary float-right">
<i class="fas fa-edit"></i>
{{ 'person.page.orcid.save.preference.changes' | translate }}
</button>
</div>
</div>
</form>
</div> </div>
</ng-template> </div>
</ngb-panel> </div>
</ngb-accordion> <div class="row">
<div class="col-md mb-3">
<div class="card h-100" data-test="sync-mode-publication">
<div class="card-header">{{ 'person.page.orcid.publications-preferences'| translate }}</div>
<div class="card-body">
<div class="container">
<div class="row">
<ds-alert [type]="'alert-info'">
{{ 'person.page.orcid.synchronization-mode-publication-message' | translate}}
</ds-alert>
</div>
<div class="form-group">
<div *ngFor="let option of syncPublicationOptions" class="row form-check">
<input type="radio" [(ngModel)]="currentSyncPublications"
name="syncPublications" id="publicationOption_{{option.value}}" [value]="option.value"
required>
<label for="publicationOption_{{option.value}}"
class="ml-2">{{option.label | translate}}</label>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md mb-3">
<div class="card h-100" data-test="sync-mode-funding">
<div class="card-header">{{ 'person.page.orcid.funding-preferences'| translate }}</div>
<div class="card-body">
<div class="container">
<div class="row">
<ds-alert [type]="'alert-info'">
{{ 'person.page.orcid.synchronization-mode-funding-message' | translate}}
</ds-alert>
</div>
<div class="form-group">
<div *ngFor="let option of syncFundingOptions" class="row form-check">
<input type="radio" [(ngModel)]="currentSyncFunding"
name="syncFundings" id="fundingOption_{{option.value}}" [value]="option.value"
required>
<label for="fundingOption_{{option.value}}" class="ml-2">{{option.label | translate}}</label>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md mb-3">
<div class="card h-100" data-test="profile-preferences">
<div class="card-header">{{ 'person.page.orcid.profile-preferences'| translate }}</div>
<div class="card-body">
<div class="container">
<div class="row">
<ds-alert [type]="'alert-info'">
{{ 'person.page.orcid.synchronization-mode-profile-message' | translate}}
</ds-alert>
</div>
<div class="form-group">
<div *ngFor="let option of syncProfileOptions" class="row form-check">
<input type="checkbox" [(ngModel)]="option.checked"
name="syncProfile_{{option.value}}" id="profileOption_{{option.value}}"
[value]="option.value">
<label for="profileOption_{{option.value}}" class="ml-2">{{option.label | translate}}</label>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col">
<button type="submit" class="btn btn-primary float-right">
<i class="fas fa-edit"></i>
{{ 'person.page.orcid.save.preference.changes' | translate }}
</button>
</div>
</div>
</form>
</div> </div>

View File

@@ -2,5 +2,5 @@
[ngbTooltip]="'item.page.orcid.tooltip' | translate" [ngbTooltip]="'item.page.orcid.tooltip' | translate"
[routerLink]="[pageRoute, 'orcid']" [routerLink]="[pageRoute, 'orcid']"
class="btn btn-dark btn-sm" class="btn btn-dark btn-sm"
role="button" >{{'item.page.orcid.title' | translate}} role="button" ><i class="fab fa-orcid fa-lg"></i>
</a> </a>

View File

@@ -29,4 +29,8 @@ export class RouterMock {
createUrlTree(commands, navExtras = {}) { createUrlTree(commands, navExtras = {}) {
return {}; return {};
} }
get url() {
return this.routerState.snapshot.url;
}
} }

View File

@@ -1,4 +1,4 @@
<div class="alert {{notification.type}} alert-dismissible m-3 shadow" role="alert" <div class="notification alert {{notification.type}} alert-dismissible m-3 shadow" role="alert"
[@enterLeave]="animate"> [@enterLeave]="animate">
<div class="notification-progress-loader position-absolute w-100" *ngIf="showProgressBar"> <div class="notification-progress-loader position-absolute w-100" *ngIf="showProgressBar">

View File

@@ -1,4 +1,4 @@
.alert { .notification {
display: inline-block; display: inline-block;
min-width: var(--bs-modal-sm); min-width: var(--bs-modal-sm);
text-align: left; text-align: left;

View File

@@ -4499,6 +4499,10 @@
"person.page.orcid.link": "Connect to ORCID ID", "person.page.orcid.link": "Connect to ORCID ID",
"person.page.orcid.link.processing": "Linking profile to ORCID...",
"person.page.orcid.link.error.message": "Something went wrong while connecting the profile with ORCID. If the problem persists, contact the administrator.",
"person.page.orcid.orcid-not-linked-message": "The ORCID iD of this profile ({{ orcid }}) has not yet been connected to an account on the ORCID registry or the connection is expired.", "person.page.orcid.orcid-not-linked-message": "The ORCID iD of this profile ({{ orcid }}) has not yet been connected to an account on the ORCID registry or the connection is expired.",
"person.page.orcid.unlink": "Disconnect from ORCID", "person.page.orcid.unlink": "Disconnect from ORCID",
@@ -4657,7 +4661,13 @@
"person.page.orcid.synchronization-mode.label": "Synchronization mode", "person.page.orcid.synchronization-mode.label": "Synchronization mode",
"person.page.orcid.synchronization-mode-message": "Please select how you would like synchronization with orcid to occur. It can be set as 'Manual' so you must send your data to ORCID Registry manually, or as 'Batch' so the synchronization on ORCID will be done by the system with a scheduled script", "person.page.orcid.synchronization-mode-message": "Please select how you would like synchronization to ORCID to occur. The options include \"Manual\" (you must send your data to ORCID manually), or \"Batch\" (the system will send your data to ORCID via a scheduled script).",
"person.page.orcid.synchronization-mode-funding-message": "Select whether to send your linked Project entities to your ORCID record's list of funding information.",
"person.page.orcid.synchronization-mode-publication-message": "Select whether to send your linked Publication entities to your ORCID record's list of works.",
"person.page.orcid.synchronization-mode-profile-message": "Select whether to send your biographical data or personal identifiers to your ORCID record.",
"person.page.orcid.synchronization-settings-update.success": "The synchronization settings have been updated successfully", "person.page.orcid.synchronization-settings-update.success": "The synchronization settings have been updated successfully",