From 88c324cb5a57af424a113b6780ea1ef3f0397b22 Mon Sep 17 00:00:00 2001 From: Pratik Rajkotiya Date: Tue, 12 Apr 2022 17:04:32 +0530 Subject: [PATCH 1/6] [CST-5307] Migrate Researcher Profile (Angular). --- angular.json | 1 + package.json | 3 +- src/app/core/breadcrumbs/dso-name.service.ts | 10 +- src/app/core/core.module.ts | 8 +- .../profile/model/researcher-profile.model.ts | 49 ++++ .../model/researcher-profile.resource-type.ts | 9 + .../profile/researcher-profile.service.ts | 267 ++++++++++++++++++ .../profile-claim/profile-claim.service.ts | 76 +++++ ...rofile-page-researcher-form.component.html | 35 +++ ...ile-page-researcher-form.component.spec.ts | 162 +++++++++++ .../profile-page-researcher-form.component.ts | 181 ++++++++++++ .../profile-page-security-form.component.html | 2 +- .../profile-page/profile-page.component.html | 9 + src/app/profile-page/profile-page.module.ts | 14 +- .../claim-item-selector.component.html | 37 +++ .../claim-item-selector.component.spec.ts | 45 +++ .../claim-item-selector.component.ts | 68 +++++ src/app/shared/shared.module.ts | 4 + src/assets/i18n/en.json5 | 46 ++- src/styles/_global-styles.scss | 6 + yarn.lock | 5 + 21 files changed, 1027 insertions(+), 10 deletions(-) create mode 100644 src/app/core/profile/model/researcher-profile.model.ts create mode 100644 src/app/core/profile/model/researcher-profile.resource-type.ts create mode 100644 src/app/core/profile/researcher-profile.service.ts create mode 100644 src/app/profile-page/profile-claim/profile-claim.service.ts create mode 100644 src/app/profile-page/profile-page-researcher-form/profile-page-researcher-form.component.html create mode 100644 src/app/profile-page/profile-page-researcher-form/profile-page-researcher-form.component.spec.ts create mode 100644 src/app/profile-page/profile-page-researcher-form/profile-page-researcher-form.component.ts create mode 100644 src/app/shared/dso-selector/modal-wrappers/claim-item-selector/claim-item-selector.component.html create mode 100644 src/app/shared/dso-selector/modal-wrappers/claim-item-selector/claim-item-selector.component.spec.ts create mode 100644 src/app/shared/dso-selector/modal-wrappers/claim-item-selector/claim-item-selector.component.ts diff --git a/angular.json b/angular.json index a0a4cd8ea1..aaa7cdb199 100644 --- a/angular.json +++ b/angular.json @@ -48,6 +48,7 @@ ], "styles": [ "src/styles/startup.scss", + "./node_modules/ngx-ui-switch/ui-switch.component.css", { "input": "src/styles/base-theme.scss", "inject": false, diff --git a/package.json b/package.json index 5e98af53dd..4ba231de9f 100644 --- a/package.json +++ b/package.json @@ -119,7 +119,8 @@ "url-parse": "^1.5.6", "uuid": "^8.3.2", "webfontloader": "1.6.28", - "zone.js": "^0.10.3" + "zone.js": "^0.10.3", + "ngx-ui-switch": "^11.0.1" }, "devDependencies": { "@angular-builders/custom-webpack": "10.0.1", diff --git a/src/app/core/breadcrumbs/dso-name.service.ts b/src/app/core/breadcrumbs/dso-name.service.ts index 38363d1989..02ead1615c 100644 --- a/src/app/core/breadcrumbs/dso-name.service.ts +++ b/src/app/core/breadcrumbs/dso-name.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { hasValue } from '../../shared/empty.util'; +import { hasValue, isEmpty } from '../../shared/empty.util'; import { DSpaceObject } from '../shared/dspace-object.model'; import { TranslateService } from '@ngx-translate/core'; @@ -27,7 +27,13 @@ export class DSONameService { */ private readonly factories = { Person: (dso: DSpaceObject): string => { - return `${dso.firstMetadataValue('person.familyName')}, ${dso.firstMetadataValue('person.givenName')}`; + const familyName = dso.firstMetadataValue('person.familyName'); + const givenName = dso.firstMetadataValue('person.givenName'); + if (isEmpty(familyName) && isEmpty(givenName)) { + return dso.firstMetadataValue('dc.title') || dso.name; + } else { + return `${familyName}, ${givenName}`; + } }, OrgUnit: (dso: DSpaceObject): string => { return dso.firstMetadataValue('organization.legalName'); diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 8d8a614a89..7e97c78b3b 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -162,6 +162,9 @@ import { SearchConfig } from './shared/search/search-filters/search-config.model import { SequenceService } from './shared/sequence.service'; import { GroupDataService } from './eperson/group-data.service'; import { SubmissionAccessesModel } from './config/models/config-submission-accesses.model'; +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'; /** * When not in production, endpoint responses can be mocked for testing purposes @@ -286,6 +289,8 @@ const PROVIDERS = [ SequenceService, GroupDataService, FeedbackDataService, + ResearcherProfileService, + ProfileClaimService ]; /** @@ -345,7 +350,8 @@ export const models = UsageReport, Root, SearchConfig, - SubmissionAccessesModel + SubmissionAccessesModel, + ResearcherProfile ]; @NgModule({ diff --git a/src/app/core/profile/model/researcher-profile.model.ts b/src/app/core/profile/model/researcher-profile.model.ts new file mode 100644 index 0000000000..1a9e75cbf6 --- /dev/null +++ b/src/app/core/profile/model/researcher-profile.model.ts @@ -0,0 +1,49 @@ +import { autoserialize, deserialize, deserializeAs } from 'cerialize'; +import { typedObject } from '../../cache/builders/build-decorators'; +import { CacheableObject } from '../../cache/object-cache.reducer'; +import { HALLink } from '../../shared/hal-link.model'; +import { ResourceType } from '../../shared/resource-type'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { RESEARCHER_PROFILE } from './researcher-profile.resource-type'; + +/** + * Class the represents a Researcher Profile. + */ +@typedObject +export class ResearcherProfile extends CacheableObject { + + static type = RESEARCHER_PROFILE; + + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The identifier of this Researcher Profile + */ + @autoserialize + id: string; + + @deserializeAs('id') + uuid: string; + + /** + * The visibility of this Researcher Profile + */ + @autoserialize + visible: boolean; + + /** + * The {@link HALLink}s for this Researcher Profile + */ + @deserialize + _links: { + self: HALLink, + item: HALLink, + eperson: HALLink + }; + +} diff --git a/src/app/core/profile/model/researcher-profile.resource-type.ts b/src/app/core/profile/model/researcher-profile.resource-type.ts new file mode 100644 index 0000000000..bfed441b0d --- /dev/null +++ b/src/app/core/profile/model/researcher-profile.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from '../../shared/resource-type'; + +/** + * The resource type for ResearcherProfile + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const RESEARCHER_PROFILE = new ResourceType('profile'); diff --git a/src/app/core/profile/researcher-profile.service.ts b/src/app/core/profile/researcher-profile.service.ts new file mode 100644 index 0000000000..c1f8952d39 --- /dev/null +++ b/src/app/core/profile/researcher-profile.service.ts @@ -0,0 +1,267 @@ +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { Operation, RemoveOperation, ReplaceOperation } from 'fast-json-patch'; +import { combineLatest, Observable, of as observableOf } from 'rxjs'; +import { catchError, find, map, switchMap, tap } from 'rxjs/operators'; +import { environment } from '../../../environments/environment'; +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 { CoreState } from '../core.reducers'; +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 { Item } from '../shared/item.model'; +import { NoContent } from '../shared/NoContent.model'; +import { + getFinishedRemoteData, + getFirstCompletedRemoteData, + getFirstSucceededRemoteDataPayload +} 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 } from '../../shared/empty.util'; + +/* tslint:disable:max-classes-per-file */ + +/** + * A private DataService implementation to delegate specific methods to. + */ +class ResearcherProfileServiceImpl extends DataService { + protected linkPath = 'profiles'; + + 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 researcher profile endpoint. + */ +@Injectable() +@dataService(RESEARCHER_PROFILE) +export class ResearcherProfileService { + + dataService: ResearcherProfileServiceImpl; + + 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 router: Router, + protected comparator: DefaultChangeAnalyzer, + protected itemService: ItemDataService, + protected configurationService: ConfigurationDataService ) { + + this.dataService = new ResearcherProfileServiceImpl(requestService, rdbService, store, objectCache, halService, + notificationsService, http, comparator); + + } + + /** + * Find the researcher profile with the given uuid. + * + * @param uuid the profile uuid + */ + findById(uuid: string): Observable { + return this.dataService.findById(uuid, false) + .pipe ( getFinishedRemoteData(), + map((remoteData) => remoteData.payload)); + } + + /** + * Create a new researcher profile for the current user. + */ + create(): Observable> { + return this.dataService.create( new ResearcherProfile()); + } + + /** + * Delete a researcher profile. + * + * @param researcherProfile the profile to delete + */ + delete(researcherProfile: ResearcherProfile): Observable { + return this.dataService.delete(researcherProfile.id).pipe( + getFirstCompletedRemoteData(), + tap((response: RemoteData) => { + if (response.isSuccess) { + this.requestService.setStaleByHrefSubstring(researcherProfile._links.self.href); + } + }), + map((response: RemoteData) => response.isSuccess) + ); + } + + /** + * Find the item id related to the given researcher profile. + * + * @param researcherProfile the profile to find for + */ + findRelatedItemId( researcherProfile: ResearcherProfile ): Observable { + return this.itemService.findByHref(researcherProfile._links.item.href, false) + .pipe (getFirstSucceededRemoteDataPayload(), + catchError((error) => { + console.debug(error); + return observableOf(null); + }), + map((item) => item != null ? item.id : null )); + } + + /** + * Change the visibility of the given researcher profile setting the given value. + * + * @param researcherProfile the profile to update + * @param visible the visibility value to set + */ + setVisibility(researcherProfile: ResearcherProfile, visible: boolean): Observable { + + const replaceOperation: ReplaceOperation = { + path: '/visible', + op: 'replace', + value: visible + }; + + return this.patch(researcherProfile, [replaceOperation]).pipe ( + switchMap( ( ) => this.findById(researcherProfile.id)) + ); + } + + patch(researcherProfile: ResearcherProfile, operations: Operation[]): Observable> { + return this.dataService.patch(researcherProfile, operations); + } + + /** + * Check if the given item is linked to an ORCID profile. + * + * @param item the item to check + * @returns the check result + */ + isLinkedToOrcid(item: Item): boolean { + return item.hasMetadata('cris.orcid.authenticated'); + } + + /** + * Returns true if only the admin users can disconnect a researcher profile from ORCID. + * + * @returns the check result + */ + onlyAdminCanDisconnectProfileFromOrcid(): Observable { + return this.getOrcidDisconnectionAllowedUsersConfiguration().pipe( + map((property) => property.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 + */ + ownerCanDisconnectProfileFromOrcid(): Observable { + return this.getOrcidDisconnectionAllowedUsersConfiguration().pipe( + map((property) => { + const values = property.values.map( (value) => value.toLowerCase()); + return values.includes('only_owner') || values.includes('admin_and_owner'); + }) + ); + } + + /** + * Returns true if the admin users can disconnect a researcher profile from ORCID. + * + * @returns the check result + */ + adminCanDisconnectProfileFromOrcid(): Observable { + return this.getOrcidDisconnectionAllowedUsersConfiguration().pipe( + map((property) => { + const values = property.values.map( (value) => value.toLowerCase()); + return values.includes('only_admin') || values.includes('admin_and_owner'); + }) + ); + } + + /** + * If the given item represents a profile unlink it from ORCID. + */ + unlinkOrcid(item: Item): Observable> { + + const operations: RemoveOperation[] = [{ + path:'/orcid', + op:'remove' + }]; + + return this.findById(item.firstMetadata('cris.owner').authority).pipe( + switchMap((profile) => this.patch(profile, operations)), + getFinishedRemoteData() + ); + } + + 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 = environment.rest.baseUrl + '/api/cris/orcid/' + profile.id + '/?url=' + encodeURIComponent(this.router.url); + 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. + */ + public createFromExternalSource(sourceUri: string): Observable> { + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'text/uri-list'); + options.headers = headers; + + const requestId = this.requestService.generateRequestId(); + const href$ = this.halService.getEndpoint(this.dataService.getLinkPath()); + + href$.pipe( + find((href: string) => hasValue(href)), + map((href: string) => { + const request = new PostRequest(requestId, href, sourceUri, options); + this.requestService.send(request); + }) + ).subscribe(); + + return this.rdbService.buildFromRequestUUID(requestId); + } + + private getOrcidDisconnectionAllowedUsersConfiguration(): Observable { + return this.configurationService.findByPropertyName('orcid.disconnection.allowed-users').pipe( + getFirstSucceededRemoteDataPayload() + ); + } + +} diff --git a/src/app/profile-page/profile-claim/profile-claim.service.ts b/src/app/profile-page/profile-claim/profile-claim.service.ts new file mode 100644 index 0000000000..f1841ac0b5 --- /dev/null +++ b/src/app/profile-page/profile-claim/profile-claim.service.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@angular/core'; +import { Observable, of } from 'rxjs'; +import { mergeMap, switchMap, take } from 'rxjs/operators'; +import { ConfigurationDataService } from '../../core/data/configuration-data.service'; +import { PaginatedList } from '../../core/data/paginated-list.model'; +import { RemoteData } from '../../core/data/remote-data'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { SearchService } from '../../core/shared/search/search.service'; +import { hasValue } from '../../shared/empty.util'; +import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; +import { SearchResult } from '../../shared/search/models/search-result.model'; +import { getFirstSucceededRemoteData, getFirstSucceededRemoteDataPayload } from './../../core/shared/operators'; + +@Injectable() +export class ProfileClaimService { + + constructor(private searchService: SearchService, + private configurationService: ConfigurationDataService) { + } + + canClaimProfiles(eperson: EPerson): Observable { + + const query = this.personQueryData(eperson); + + if (!hasValue(query) || query.length === 0) { + return of(false); + } + + return this.configurationService.findByPropertyName('claimable.entityType').pipe( + getFirstSucceededRemoteDataPayload(), + switchMap((claimableTypes) => { + if (!claimableTypes.values || claimableTypes.values.length === 0) { + return of(false); + } else { + return this.lookup(query).pipe( + mergeMap((rd: RemoteData>>) => of(rd.payload.totalElements > 0)) + ); + } + }) + ); + } + + search(eperson: EPerson): Observable>>> { + const query = this.personQueryData(eperson); + if (!hasValue(query) || query.length === 0) { + return of(null); + } + return this.lookup(query); + } + + private lookup(query: string): Observable>>> { + if (!hasValue(query)) { + return of(null); + } + return this.searchService.search(new PaginatedSearchOptions({ + configuration: 'eperson_claims', + query: query + })) + .pipe( + getFirstSucceededRemoteData(), + take(1)); + } + + private personQueryData(eperson: EPerson): string { + const querySections = []; + this.queryParam(querySections, 'dc.title', eperson.name); + this.queryParam(querySections, 'crisrp.name', eperson.name); + return querySections.join(' OR '); + } + + private queryParam(query: string[], metadata: string, value: string) { + if (!hasValue(value)) {return;} + query.push(metadata + ':' + value); + } +} diff --git a/src/app/profile-page/profile-page-researcher-form/profile-page-researcher-form.component.html b/src/app/profile-page/profile-page-researcher-form/profile-page-researcher-form.component.html new file mode 100644 index 0000000000..b2d53ea0e3 --- /dev/null +++ b/src/app/profile-page/profile-page-researcher-form/profile-page-researcher-form.component.html @@ -0,0 +1,35 @@ +
+
+

{{'researcher.profile.associated' | translate}}

+

+ {{'researcher.profile.status' | translate}} + +

+
+
+

{{'researcher.profile.not.associated' | translate}}

+
+ + + + + +
diff --git a/src/app/profile-page/profile-page-researcher-form/profile-page-researcher-form.component.spec.ts b/src/app/profile-page/profile-page-researcher-form/profile-page-researcher-form.component.spec.ts new file mode 100644 index 0000000000..bacb3469ad --- /dev/null +++ b/src/app/profile-page/profile-page-researcher-form/profile-page-researcher-form.component.spec.ts @@ -0,0 +1,162 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; + +import { of as observableOf } from 'rxjs'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; + +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { ResearcherProfile } from '../../core/profile/model/researcher-profile.model'; +import { ResearcherProfileService } from '../../core/profile/researcher-profile.service'; +import { VarDirective } from '../../shared/utils/var.directive'; +import { ProfilePageResearcherFormComponent } from './profile-page-researcher-form.component'; +import { ProfileClaimService } from '../profile-claim/profile-claim.service'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { AuthService } from 'src/app/core/auth/auth.service'; +import { EditItemDataService } from '../../core/submission/edititem-data.service'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { EditItemMode } from '../../core/submission/models/edititem-mode.model'; +import { EditItem } from '../../core/submission/models/edititem.model'; +import { createPaginatedList } from '../../shared/testing/utils.test'; + +describe('ProfilePageResearcherFormComponent', () => { + + let component: ProfilePageResearcherFormComponent; + let fixture: ComponentFixture; + let router: Router; + + let user: EPerson; + let profile: ResearcherProfile; + + let researcherProfileService: ResearcherProfileService; + + let notificationsServiceStub: NotificationsServiceStub; + + let profileClaimService: ProfileClaimService; + + let authService: AuthService; + + let editItemDataService: any; + + const editItemMode: EditItemMode = Object.assign(new EditItemMode(), { + name: 'test', + label: 'test' + }); + + const editItem: EditItem = Object.assign(new EditItem(), { + modes: createSuccessfulRemoteDataObject$(createPaginatedList([editItemMode])) + }); + + function init() { + + user = Object.assign(new EPerson(), { + id: 'beef9946-f4ce-479e-8f11-b90cbe9f7241' + }); + + profile = Object.assign(new ResearcherProfile(), { + id: 'beef9946-f4ce-479e-8f11-b90cbe9f7241', + visible: false, + type: 'profile' + }); + + authService = jasmine.createSpyObj('authService', { + getAuthenticatedUserFromStore: observableOf(user) + }); + + researcherProfileService = jasmine.createSpyObj('researcherProfileService', { + findById: observableOf(profile), + create: observableOf(profile), + setVisibility: observableOf(profile), + delete: observableOf(true), + findRelatedItemId: observableOf('a42557ca-cbb8-4442-af9c-3bb5cad2d075') + }); + + notificationsServiceStub = new NotificationsServiceStub(); + + profileClaimService = jasmine.createSpyObj('profileClaimService', { + canClaimProfiles: observableOf(false), + }); + + editItemDataService = jasmine.createSpyObj('EditItemDataService', { + findById: createSuccessfulRemoteDataObject$(editItem) + }); + + } + + beforeEach(waitForAsync(() => { + init(); + TestBed.configureTestingModule({ + declarations: [ProfilePageResearcherFormComponent, VarDirective], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], + providers: [ + NgbModal, + { provide: ResearcherProfileService, useValue: researcherProfileService }, + { provide: NotificationsService, useValue: notificationsServiceStub }, + { provide: ProfileClaimService, useValue: profileClaimService }, + { provide: AuthService, useValue: authService }, + { provide: EditItemDataService, useValue: editItemDataService } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ProfilePageResearcherFormComponent); + component = fixture.componentInstance; + component.user = user; + router = TestBed.inject(Router); + fixture.detectChanges(); + }); + + it('should search the researcher profile for the current user', () => { + expect(researcherProfileService.findById).toHaveBeenCalledWith(user.id); + }); + + describe('createProfile', () => { + + it('should create the profile', () => { + component.createProfile(); + expect(researcherProfileService.create).toHaveBeenCalledWith(); + }); + + }); + + describe('toggleProfileVisibility', () => { + + it('should set the profile visibility to true', () => { + profile.visible = false; + component.toggleProfileVisibility(profile); + expect(researcherProfileService.setVisibility).toHaveBeenCalledWith(profile, true); + }); + + it('should set the profile visibility to false', () => { + profile.visible = true; + component.toggleProfileVisibility(profile); + expect(researcherProfileService.setVisibility).toHaveBeenCalledWith(profile, false); + }); + + }); + + describe('deleteProfile', () => { + + it('should delete the profile', () => { + component.deleteProfile(profile); + expect(researcherProfileService.delete).toHaveBeenCalledWith(profile); + }); + + }); + + describe('viewProfile', () => { + + it('should open the item details page', () => { + spyOn(router, 'navigate'); + component.viewProfile(profile); + expect(router.navigate).toHaveBeenCalledWith(['items', 'a42557ca-cbb8-4442-af9c-3bb5cad2d075']); + }); + + }); + +}); diff --git a/src/app/profile-page/profile-page-researcher-form/profile-page-researcher-form.component.ts b/src/app/profile-page/profile-page-researcher-form/profile-page-researcher-form.component.ts new file mode 100644 index 0000000000..6a0b687afa --- /dev/null +++ b/src/app/profile-page/profile-page-researcher-form/profile-page-researcher-form.component.ts @@ -0,0 +1,181 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; + +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateService } from '@ngx-translate/core'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { filter, mergeMap, switchMap, take, tap } from 'rxjs/operators'; + +import { getFirstCompletedRemoteData } from '../../core/shared/operators'; +import { ClaimItemSelectorComponent } from '../../shared/dso-selector/modal-wrappers/claim-item-selector/claim-item-selector.component'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { AuthService } from '../../core/auth/auth.service'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { ResearcherProfile } from '../../core/profile/model/researcher-profile.model'; +import { ResearcherProfileService } from '../../core/profile/researcher-profile.service'; +import { ProfileClaimService } from '../profile-claim/profile-claim.service'; +import { isNotEmpty } from '../../shared/empty.util'; + +@Component({ + selector: 'ds-profile-page-researcher-form', + templateUrl: './profile-page-researcher-form.component.html', +}) +/** + * Component for a user to create/delete or change his researcher profile. + */ +export class ProfilePageResearcherFormComponent implements OnInit { + + /** + * The user to display the form for. + */ + @Input() user: EPerson; + + /** + * The researcher profile to show. + */ + researcherProfile$: BehaviorSubject = new BehaviorSubject(null); + + /** + * A boolean representing if a delete operation is pending + * @type {BehaviorSubject} + */ + processingDelete$: BehaviorSubject = new BehaviorSubject(false); + + /** + * A boolean representing if a create delete operation is pending + * @type {BehaviorSubject} + */ + processingCreate$: BehaviorSubject = new BehaviorSubject(false); + + /** + * If exists The uuid of the item associated to the researcher profile + */ + researcherProfileItemId: string; + + constructor(protected researcherProfileService: ResearcherProfileService, + protected profileClaimService: ProfileClaimService, + protected translationService: TranslateService, + protected notificationService: NotificationsService, + protected authService: AuthService, + protected router: Router, + protected modalService: NgbModal) { + + } + + /** + * Initialize the component searching the current user researcher profile. + */ + ngOnInit(): void { + // Retrieve researcherProfile if exists + this.initResearchProfile(); + } + + /** + * Create a new profile for the current user. + */ + createProfile(): void { + this.processingCreate$.next(true); + + this.authService.getAuthenticatedUserFromStore().pipe( + switchMap((currentUser) => this.profileClaimService.canClaimProfiles(currentUser))) + .subscribe((canClaimProfiles) => { + + if (canClaimProfiles) { + this.processingCreate$.next(false); + const modal = this.modalService.open(ClaimItemSelectorComponent); + modal.componentInstance.dso = this.user; + modal.componentInstance.create.pipe(take(1)).subscribe(() => { + this.createProfileFromScratch(); + }); + } else { + this.createProfileFromScratch(); + } + + }); + } + + /** + * Navigate to the items section to show the profile item details. + * + * @param researcherProfile the current researcher profile + */ + viewProfile(researcherProfile: ResearcherProfile): void { + if (this.researcherProfileItemId != null) { + this.router.navigate(['items', this.researcherProfileItemId]); + } + } + + /** + * Delete the given researcher profile. + * + * @param researcherProfile the profile to delete + */ + deleteProfile(researcherProfile: ResearcherProfile): void { + this.processingDelete$.next(true); + this.researcherProfileService.delete(researcherProfile) + .subscribe((deleted) => { + if (deleted) { + this.researcherProfile$.next(null); + this.researcherProfileItemId = null; + } + this.processingDelete$.next(false); + }); + } + + /** + * Toggle the visibility of the given researcher profile. + * + * @param researcherProfile the profile to update + */ + toggleProfileVisibility(researcherProfile: ResearcherProfile): void { + /* tslint:disable:no-empty */ + this.researcherProfileService.setVisibility(researcherProfile, !researcherProfile.visible) + .subscribe((updatedProfile) => {}); // this.researcherProfile$.next(updatedProfile); + /* tslint:enable:no-empty */ + } + + /** + * Return a boolean representing if a delete operation is pending. + * + * @return {Observable} + */ + isProcessingDelete(): Observable { + return this.processingDelete$.asObservable(); + } + + /** + * Return a boolean representing if a create operation is pending. + * + * @return {Observable} + */ + isProcessingCreate(): Observable { + return this.processingCreate$.asObservable(); + } + + createProfileFromScratch() { + this.processingCreate$.next(true); + this.researcherProfileService.create().pipe( + getFirstCompletedRemoteData() + ).subscribe((remoteData) => { + this.processingCreate$.next(false); + if (remoteData.isSuccess) { + this.initResearchProfile(); + this.notificationService.success(this.translationService.get('researcher.profile.create.success')); + } else { + this.notificationService.error(this.translationService.get('researcher.profile.create.fail')); + } + }); + } + + private initResearchProfile(): void { + this.researcherProfileService.findById(this.user.id).pipe( + take(1), + filter((researcherProfile) => isNotEmpty(researcherProfile)), + tap((researcherProfile) => this.researcherProfile$.next(researcherProfile)), + mergeMap((researcherProfile) => this.researcherProfileService.findRelatedItemId(researcherProfile)), + ).subscribe((itemId: string) => { + this.researcherProfileItemId = itemId; + }); + } + +} diff --git a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.html b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.html index cdaa3ce31c..7c1dff5bdf 100644 --- a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.html +++ b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.html @@ -1,4 +1,4 @@ -
{{FORM_PREFIX + 'info' | translate}}
+{{FORM_PREFIX + 'info' | translate}}

{{'profile.head' | translate}}

+
+
{{'profile.card.researcher' | translate}}
+
+
+ +
+ +
+
{{'profile.card.identify' | translate}}
diff --git a/src/app/profile-page/profile-page.module.ts b/src/app/profile-page/profile-page.module.ts index dc9595140b..83baec12dc 100644 --- a/src/app/profile-page/profile-page.module.ts +++ b/src/app/profile-page/profile-page.module.ts @@ -5,25 +5,33 @@ import { ProfilePageRoutingModule } from './profile-page-routing.module'; import { ProfilePageComponent } from './profile-page.component'; import { ProfilePageMetadataFormComponent } from './profile-page-metadata-form/profile-page-metadata-form.component'; import { ProfilePageSecurityFormComponent } from './profile-page-security-form/profile-page-security-form.component'; +import { ProfilePageResearcherFormComponent } from './profile-page-researcher-form/profile-page-researcher-form.component'; import { ThemedProfilePageComponent } from './themed-profile-page.component'; import { FormModule } from '../shared/form/form.module'; +import { UiSwitchModule } from 'ngx-ui-switch'; + @NgModule({ imports: [ ProfilePageRoutingModule, CommonModule, SharedModule, - FormModule + FormModule, + UiSwitchModule ], exports: [ + ProfilePageComponent, + ThemedProfilePageComponent, + ProfilePageMetadataFormComponent, ProfilePageSecurityFormComponent, - ProfilePageMetadataFormComponent + ProfilePageResearcherFormComponent ], declarations: [ ProfilePageComponent, ThemedProfilePageComponent, ProfilePageMetadataFormComponent, - ProfilePageSecurityFormComponent + ProfilePageSecurityFormComponent, + ProfilePageResearcherFormComponent ] }) export class ProfilePageModule { diff --git a/src/app/shared/dso-selector/modal-wrappers/claim-item-selector/claim-item-selector.component.html b/src/app/shared/dso-selector/modal-wrappers/claim-item-selector/claim-item-selector.component.html new file mode 100644 index 0000000000..9df49ba24b --- /dev/null +++ b/src/app/shared/dso-selector/modal-wrappers/claim-item-selector/claim-item-selector.component.html @@ -0,0 +1,37 @@ +
+ + + +
diff --git a/src/app/shared/dso-selector/modal-wrappers/claim-item-selector/claim-item-selector.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/claim-item-selector/claim-item-selector.component.spec.ts new file mode 100644 index 0000000000..464f2518ca --- /dev/null +++ b/src/app/shared/dso-selector/modal-wrappers/claim-item-selector/claim-item-selector.component.spec.ts @@ -0,0 +1,45 @@ +import { ActivatedRoute, Router } from '@angular/router'; +/* tslint:disable:no-unused-variable */ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TranslateModule } from '@ngx-translate/core'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ClaimItemSelectorComponent } from './claim-item-selector.component'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { ProfileClaimService } from '../../../../profile-page/profile-claim/profile-claim.service'; +import { of } from 'rxjs'; + +describe('ClaimItemSelectorComponent', () => { + let component: ClaimItemSelectorComponent; + let fixture: ComponentFixture; + + const profileClaimService = jasmine.createSpyObj('profileClaimService', { + search: of({ payload: {page: []}}) + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [ ClaimItemSelectorComponent ], + providers: [ + { provide: NgbActiveModal, useValue: {} }, + { provide: ActivatedRoute, useValue: {} }, + { provide: Router, useValue: {} }, + { provide: ProfileClaimService, useValue: profileClaimService } + ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ClaimItemSelectorComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + +}); diff --git a/src/app/shared/dso-selector/modal-wrappers/claim-item-selector/claim-item-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/claim-item-selector/claim-item-selector.component.ts new file mode 100644 index 0000000000..5c5ee42037 --- /dev/null +++ b/src/app/shared/dso-selector/modal-wrappers/claim-item-selector/claim-item-selector.component.ts @@ -0,0 +1,68 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { BehaviorSubject } from 'rxjs'; +import { PaginatedList } from '../../../../core/data/paginated-list.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { Item } from '../../../../core/shared/item.model'; +import { SearchResult } from '../../../search/models/search-result.model'; +import { DSOSelectorModalWrapperComponent } from '../dso-selector-modal-wrapper.component'; +import { getItemPageRoute } from '../../../../item-page/item-page-routing-paths'; +import { EPerson } from '../../../../core/eperson/models/eperson.model'; +import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; +import { ViewMode } from '../../../../core/shared/view-mode.model'; +import { ProfileClaimService } from '../../../../profile-page/profile-claim/profile-claim.service'; +import { CollectionElementLinkType } from '../../../object-collection/collection-element-link.type'; + + + +@Component({ + selector: 'ds-claim-item-selector', + templateUrl: './claim-item-selector.component.html' +}) +export class ClaimItemSelectorComponent extends DSOSelectorModalWrapperComponent implements OnInit { + + @Input() dso: DSpaceObject; + + listEntries$: BehaviorSubject>>> = new BehaviorSubject(null); + + viewMode = ViewMode.ListElement; + + // enum to be exposed + linkTypes = CollectionElementLinkType; + + checked = false; + + @Output() create: EventEmitter = new EventEmitter(); + + constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute, private router: Router, + private profileClaimService: ProfileClaimService) { + super(activeModal, route); + } + + ngOnInit(): void { + this.profileClaimService.search(this.dso as EPerson).subscribe( + (result) => this.listEntries$.next(result) + ); + } + + // triggered when an item is selected + selectItem(dso: DSpaceObject): void { + this.close(); + this.navigate(dso); + } + + navigate(dso: DSpaceObject) { + this.router.navigate([getItemPageRoute(dso as Item)]); + } + + toggleCheckbox() { + this.checked = !this.checked; + } + + createFromScratch() { + this.create.emit(); + this.close(); + } + +} diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 7b799bfaea..01649ee947 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -173,6 +173,7 @@ import { BitstreamRequestACopyPageComponent } from './bitstream-request-a-copy-p import { DsSelectComponent } from './ds-select/ds-select.component'; import { LogInOidcComponent } from './log-in/methods/oidc/log-in-oidc.component'; import { ThemedItemListPreviewComponent } from './object-list/my-dspace-result-list-element/item-list-preview/themed-item-list-preview.component'; +import { ClaimItemSelectorComponent } from './dso-selector/modal-wrappers/claim-item-selector/claim-item-selector.component'; const MODULES = [ // Do NOT include UniversalModule, HttpModule, or JsonpModule here @@ -343,6 +344,8 @@ const COMPONENTS = [ CommunitySidebarSearchListElementComponent, SearchNavbarComponent, ScopeSelectorModalComponent, + + ClaimItemSelectorComponent ]; const ENTRY_COMPONENTS = [ @@ -399,6 +402,7 @@ const ENTRY_COMPONENTS = [ OnClickMenuItemComponent, TextMenuItemComponent, ScopeSelectorModalComponent, + ClaimItemSelectorComponent ]; const SHARED_ITEM_PAGE_COMPONENTS = [ diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index c3c68a6882..d0ff85ba51 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -1311,7 +1311,13 @@ "dso-selector.set-scope.community.input-header": "Search for a community or collection", + "dso-selector.claim.item.head": "Profile tips", + "dso-selector.claim.item.body": "These are existing profiles that may be related to you. If you recognize yourself in one of these profiles, select it and on the detail page, among the options, choose to claim it. Otherwise you can create a new profile from scratch using the button below.", + + "dso-selector.claim.item.not-mine-label": "None of these are mine", + + "dso-selector.claim.item.create-from-scratch": "Create a new one", "confirmation-modal.export-metadata.header": "Export metadata for {{ dsoName }}", @@ -2907,7 +2913,7 @@ "profile.title": "Update Profile", - + "profile.card.researcher": "Researcher Profile", "project.listelement.badge": "Research Project", @@ -4165,5 +4171,41 @@ "idle-modal.log-out": "Log out", - "idle-modal.extend-session": "Extend session" + "idle-modal.extend-session": "Extend session", + + "researcher.profile.action.processing" : "Processing...", + + "researcher.profile.associated": "Researcher profile associated", + + "researcher.profile.create.new": "Create new", + + "researcher.profile.create.success": "Researcher profile created successfully", + + "researcher.profile.create.fail": "An error occurs during the researcher profile creation", + + "researcher.profile.delete": "Delete", + + "researcher.profile.expose": "Expose", + + "researcher.profile.hide": "Hide", + + "researcher.profile.not.associated": "Researcher profile not yet associated", + + "researcher.profile.view": "View", + + "researcher.profile.private.visibility" : "PRIVATE", + + "researcher.profile.public.visibility" : "PUBLIC", + + "researcher.profile.status": "Status:", + + "researcherprofile.claim.not-authorized": "You are not authorized to claim this item. For more details contact the administrator(s).", + + "researcherprofile.error.claim.body" : "An error occurred while claiming the profile, please try again later", + + "researcherprofile.error.claim.title" : "Error", + + "researcherprofile.success.claim.body" : "Profile claimed with success", + + "researcherprofile.success.claim.title" : "Success", } diff --git a/src/styles/_global-styles.scss b/src/styles/_global-styles.scss index e337539c15..cf251204e2 100644 --- a/src/styles/_global-styles.scss +++ b/src/styles/_global-styles.scss @@ -92,3 +92,9 @@ ngb-modal-backdrop { hyphens: auto; } +.researcher-profile-switch button:focus{ + outline: none !important; +} +.researcher-profile-switch .switch.checked{ + color: #fff; +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index c1903163dd..a4d0aff64b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9434,6 +9434,11 @@ ngx-sortablejs@^11.1.0: dependencies: tslib "^2.0.0" +ngx-ui-switch@^11.0.1: + version "11.0.1" + resolved "https://registry.yarnpkg.com/ngx-ui-switch/-/ngx-ui-switch-11.0.1.tgz#c7f1e97ebe698f827a26f49951b50492b22c7839" + integrity sha512-N8QYT/wW+xJdyh/aeebTSLPA6Sgrwp69H6KAcW0XZueg/LF+FKiqyG6Po/gFHq2gDhLikwyJEMpny8sudTI08w== + nice-try@^1.0.4: version "1.0.5" resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" From fbcb6f7d78561e265a5ba0719be0622156c09e1b Mon Sep 17 00:00:00 2001 From: Pratik Rajkotiya Date: Fri, 22 Apr 2022 18:10:26 +0530 Subject: [PATCH 2/6] [CST-5307] method releted to orcid is removed. --- .../profile/researcher-profile.service.ts | 84 ------------------- 1 file changed, 84 deletions(-) diff --git a/src/app/core/profile/researcher-profile.service.ts b/src/app/core/profile/researcher-profile.service.ts index c1f8952d39..c61c5ca9f9 100644 --- a/src/app/core/profile/researcher-profile.service.ts +++ b/src/app/core/profile/researcher-profile.service.ts @@ -156,84 +156,6 @@ export class ResearcherProfileService { return this.dataService.patch(researcherProfile, operations); } - /** - * Check if the given item is linked to an ORCID profile. - * - * @param item the item to check - * @returns the check result - */ - isLinkedToOrcid(item: Item): boolean { - return item.hasMetadata('cris.orcid.authenticated'); - } - - /** - * Returns true if only the admin users can disconnect a researcher profile from ORCID. - * - * @returns the check result - */ - onlyAdminCanDisconnectProfileFromOrcid(): Observable { - return this.getOrcidDisconnectionAllowedUsersConfiguration().pipe( - map((property) => property.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 - */ - ownerCanDisconnectProfileFromOrcid(): Observable { - return this.getOrcidDisconnectionAllowedUsersConfiguration().pipe( - map((property) => { - const values = property.values.map( (value) => value.toLowerCase()); - return values.includes('only_owner') || values.includes('admin_and_owner'); - }) - ); - } - - /** - * Returns true if the admin users can disconnect a researcher profile from ORCID. - * - * @returns the check result - */ - adminCanDisconnectProfileFromOrcid(): Observable { - return this.getOrcidDisconnectionAllowedUsersConfiguration().pipe( - map((property) => { - const values = property.values.map( (value) => value.toLowerCase()); - return values.includes('only_admin') || values.includes('admin_and_owner'); - }) - ); - } - - /** - * If the given item represents a profile unlink it from ORCID. - */ - unlinkOrcid(item: Item): Observable> { - - const operations: RemoveOperation[] = [{ - path:'/orcid', - op:'remove' - }]; - - return this.findById(item.firstMetadata('cris.owner').authority).pipe( - switchMap((profile) => this.patch(profile, operations)), - getFinishedRemoteData() - ); - } - - 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 = environment.rest.baseUrl + '/api/cris/orcid/' + profile.id + '/?url=' + encodeURIComponent(this.router.url); - 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. @@ -258,10 +180,4 @@ export class ResearcherProfileService { return this.rdbService.buildFromRequestUUID(requestId); } - private getOrcidDisconnectionAllowedUsersConfiguration(): Observable { - return this.configurationService.findByPropertyName('orcid.disconnection.allowed-users').pipe( - getFirstSucceededRemoteDataPayload() - ); - } - } From 1507bbf7338ab86ae76d407325d787102cbd8ab0 Mon Sep 17 00:00:00 2001 From: Pratik Rajkotiya Date: Tue, 26 Apr 2022 21:07:12 +0530 Subject: [PATCH 3/6] [CST-5307] claim item added. --- .../data/feature-authorization/feature-id.ts | 2 + .../full/full-item-page.component.html | 3 +- .../full/full-item-page.component.ts | 9 ++- .../item-page/simple/item-page.component.ts | 81 +++++++++++++++++-- src/assets/i18n/en.json5 | 2 + 5 files changed, 90 insertions(+), 7 deletions(-) diff --git a/src/app/core/data/feature-authorization/feature-id.ts b/src/app/core/data/feature-authorization/feature-id.ts index 029c75d9cb..a2082831e0 100644 --- a/src/app/core/data/feature-authorization/feature-id.ts +++ b/src/app/core/data/feature-authorization/feature-id.ts @@ -28,4 +28,6 @@ export enum FeatureID { CanCreateVersion = 'canCreateVersion', CanViewUsageStatistics = 'canViewUsageStatistics', CanSendFeedback = 'canSendFeedback', + ShowClaimItem = 'showClaimItem', + CanClaimItem = 'canClaimItem', } diff --git a/src/app/item-page/full/full-item-page.component.html b/src/app/item-page/full/full-item-page.component.html index e71dd92f96..d34d1077f0 100644 --- a/src/app/item-page/full/full-item-page.component.html +++ b/src/app/item-page/full/full-item-page.component.html @@ -10,6 +10,7 @@
+
-
+
\ No newline at end of file diff --git a/src/app/item-page/full/full-item-page.component.ts b/src/app/item-page/full/full-item-page.component.ts index 369769c77d..99cf083bc5 100644 --- a/src/app/item-page/full/full-item-page.component.ts +++ b/src/app/item-page/full/full-item-page.component.ts @@ -16,6 +16,10 @@ import { hasValue } from '../../shared/empty.util'; import { AuthService } from '../../core/auth/auth.service'; import { Location } from '@angular/common'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; +import { TranslateService } from '@ngx-translate/core'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { ResearcherProfileService } from '../../core/profile/researcher-profile.service'; +import { CollectionDataService } from '../../core/data/collection-data.service'; /** @@ -48,8 +52,11 @@ export class FullItemPageComponent extends ItemPageComponent implements OnInit, items: ItemDataService, authService: AuthService, authorizationService: AuthorizationDataService, + translate: TranslateService, + notificationsService: NotificationsService, + researcherProfileService: ResearcherProfileService, private _location: Location) { - super(route, router, items, authService, authorizationService); + super(route, router, items, authService, authorizationService, translate, notificationsService, researcherProfileService); } /*** AoT inheritance fix, will hopefully be resolved in the near future **/ diff --git a/src/app/item-page/simple/item-page.component.ts b/src/app/item-page/simple/item-page.component.ts index 95fbd7a2e0..8da5186270 100644 --- a/src/app/item-page/simple/item-page.component.ts +++ b/src/app/item-page/simple/item-page.component.ts @@ -1,20 +1,26 @@ -import { map } from 'rxjs/operators'; +import { map, mergeMap, take, tap } from 'rxjs/operators'; import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { Observable } from 'rxjs'; +import { BehaviorSubject, Observable } from 'rxjs'; import { ItemDataService } from '../../core/data/item-data.service'; import { RemoteData } from '../../core/data/remote-data'; import { Item } from '../../core/shared/item.model'; import { fadeInOut } from '../../shared/animations/fade'; -import { getAllSucceededRemoteDataPayload, redirectOn4xx } from '../../core/shared/operators'; +import { getAllSucceededRemoteDataPayload, getFirstSucceededRemoteData, redirectOn4xx } from '../../core/shared/operators'; import { ViewMode } from '../../core/shared/view-mode.model'; import { AuthService } from '../../core/auth/auth.service'; import { getItemPageRoute } from '../item-page-routing-paths'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; import { FeatureID } from '../../core/data/feature-authorization/feature-id'; +import { TranslateService } from '@ngx-translate/core'; +import { ResearcherProfileService } from '../../core/profile/researcher-profile.service'; +import { ResearcherProfile } from '../../core/profile/model/researcher-profile.model'; +import { isNotUndefined } from '../../shared/empty.util'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; + /** * This component renders a simple item page. @@ -55,13 +61,27 @@ export class ItemPageComponent implements OnInit { */ isAdmin$: Observable; + itemUrl: string; + + public claimable$: BehaviorSubject = new BehaviorSubject(false); + public isProcessing$: BehaviorSubject = new BehaviorSubject(false); + constructor( protected route: ActivatedRoute, private router: Router, private items: ItemDataService, private authService: AuthService, - private authorizationService: AuthorizationDataService - ) { } + private authorizationService: AuthorizationDataService, + private translate: TranslateService, + private notificationsService: NotificationsService, + private researcherProfileService: ResearcherProfileService + ) { + this.route.data.pipe( + map((data) => data.dso as RemoteData) + ).subscribe((data: RemoteData) => { + this.itemUrl = data?.payload?.self + }); + } /** * Initialize instance variables @@ -77,5 +97,56 @@ export class ItemPageComponent implements OnInit { ); this.isAdmin$ = this.authorizationService.isAuthorized(FeatureID.AdministratorOf); + + this.authorizationService.isAuthorized(FeatureID.ShowClaimItem, this.itemUrl).pipe( + take(1) + ).subscribe((isAuthorized: boolean) => { + this.claimable$.next(isAuthorized) + }); + } + + claim() { + this.isProcessing$.next(true); + + this.authorizationService.isAuthorized(FeatureID.CanClaimItem, this.itemUrl).pipe( + take(1) + ).subscribe((isAuthorized: boolean) => { + if (!isAuthorized) { + this.notificationsService.warning(this.translate.get('researcherprofile.claim.not-authorized')); + this.isProcessing$.next(false); + } else { + this.createFromExternalSource(); + } + }); + + } + + createFromExternalSource() { + this.researcherProfileService.createFromExternalSource(this.itemUrl).pipe( + tap((rd: any) => { + if (!rd.hasSucceeded) { + this.isProcessing$.next(false); + } + }), + getFirstSucceededRemoteData(), + mergeMap((rd: RemoteData) => { + return this.researcherProfileService.findRelatedItemId(rd.payload); + })) + .subscribe((id: string) => { + if (isNotUndefined(id)) { + this.notificationsService.success(this.translate.get('researcherprofile.success.claim.title'), + this.translate.get('researcherprofile.success.claim.body')); + this.claimable$.next(false); + this.isProcessing$.next(false); + } else { + this.notificationsService.error( + this.translate.get('researcherprofile.error.claim.title'), + this.translate.get('researcherprofile.error.claim.body')); + } + }); + } + + isClaimable(): Observable { + return this.claimable$; } } diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index d0ff85ba51..05047cefa7 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -2130,6 +2130,8 @@ "item.page.version.hasDraft": "A new version cannot be created because there is an inprogress submission in the version history", + "item.page.claim.button": "Claim", + "item.preview.dc.identifier.uri": "Identifier:", "item.preview.dc.contributor.author": "Authors:", From 7a193cc9a2971739980d9e486d7afb8c5db564ff Mon Sep 17 00:00:00 2001 From: Luca Giamminonni Date: Wed, 27 Apr 2022 11:14:47 +0200 Subject: [PATCH 4/6] [CST-5307] Fixed compilation --- .../profile/model/researcher-profile.model.ts | 2 +- .../core/profile/researcher-profile.service.ts | 2 +- .../profile-claim/profile-claim.service.ts | 18 ++++++------------ 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/src/app/core/profile/model/researcher-profile.model.ts b/src/app/core/profile/model/researcher-profile.model.ts index 1a9e75cbf6..e5098496d3 100644 --- a/src/app/core/profile/model/researcher-profile.model.ts +++ b/src/app/core/profile/model/researcher-profile.model.ts @@ -1,10 +1,10 @@ import { autoserialize, deserialize, deserializeAs } from 'cerialize'; import { typedObject } from '../../cache/builders/build-decorators'; -import { CacheableObject } from '../../cache/object-cache.reducer'; import { HALLink } from '../../shared/hal-link.model'; import { ResourceType } from '../../shared/resource-type'; import { excludeFromEquals } from '../../utilities/equals.decorators'; import { RESEARCHER_PROFILE } from './researcher-profile.resource-type'; +import {CacheableObject} from "../../cache/cacheable-object.model"; /** * Class the represents a Researcher Profile. diff --git a/src/app/core/profile/researcher-profile.service.ts b/src/app/core/profile/researcher-profile.service.ts index c61c5ca9f9..3b4144b77c 100644 --- a/src/app/core/profile/researcher-profile.service.ts +++ b/src/app/core/profile/researcher-profile.service.ts @@ -10,7 +10,6 @@ import { NotificationsService } from '../../shared/notifications/notifications.s import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { CoreState } from '../core.reducers'; import { ConfigurationDataService } from '../data/configuration-data.service'; import { DataService } from '../data/data.service'; import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service'; @@ -31,6 +30,7 @@ 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 } from '../../shared/empty.util'; +import {CoreState} from "../core-state.model"; /* tslint:disable:max-classes-per-file */ diff --git a/src/app/profile-page/profile-claim/profile-claim.service.ts b/src/app/profile-page/profile-claim/profile-claim.service.ts index f1841ac0b5..1c86ba29f1 100644 --- a/src/app/profile-page/profile-claim/profile-claim.service.ts +++ b/src/app/profile-page/profile-claim/profile-claim.service.ts @@ -10,7 +10,7 @@ import { SearchService } from '../../core/shared/search/search.service'; import { hasValue } from '../../shared/empty.util'; import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; import { SearchResult } from '../../shared/search/models/search-result.model'; -import { getFirstSucceededRemoteData, getFirstSucceededRemoteDataPayload } from './../../core/shared/operators'; +import { getFirstSucceededRemoteData } from './../../core/shared/operators'; @Injectable() export class ProfileClaimService { @@ -27,18 +27,10 @@ export class ProfileClaimService { return of(false); } - return this.configurationService.findByPropertyName('claimable.entityType').pipe( - getFirstSucceededRemoteDataPayload(), - switchMap((claimableTypes) => { - if (!claimableTypes.values || claimableTypes.values.length === 0) { - return of(false); - } else { - return this.lookup(query).pipe( - mergeMap((rd: RemoteData>>) => of(rd.payload.totalElements > 0)) - ); - } - }) + return this.lookup(query).pipe( + mergeMap((rd: RemoteData>>) => of(rd.payload.totalElements > 0)) ); + } search(eperson: EPerson): Observable>>> { @@ -73,4 +65,6 @@ export class ProfileClaimService { if (!hasValue(value)) {return;} query.push(metadata + ':' + value); } + + } From 6b3c6c2dda579a8d6e99b69c8b5fa8e9fe37bd10 Mon Sep 17 00:00:00 2001 From: Luca Giamminonni Date: Fri, 29 Apr 2022 15:34:57 +0200 Subject: [PATCH 5/6] [CST-5307] Fixed lint --- src/app/core/profile/model/researcher-profile.model.ts | 2 +- src/app/core/profile/researcher-profile.service.ts | 5 ++--- src/app/item-page/simple/item-page.component.ts | 4 ++-- .../profile-page-researcher-form.component.ts | 6 +++--- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/app/core/profile/model/researcher-profile.model.ts b/src/app/core/profile/model/researcher-profile.model.ts index e5098496d3..a07467476e 100644 --- a/src/app/core/profile/model/researcher-profile.model.ts +++ b/src/app/core/profile/model/researcher-profile.model.ts @@ -4,7 +4,7 @@ import { HALLink } from '../../shared/hal-link.model'; import { ResourceType } from '../../shared/resource-type'; import { excludeFromEquals } from '../../utilities/equals.decorators'; import { RESEARCHER_PROFILE } from './researcher-profile.resource-type'; -import {CacheableObject} from "../../cache/cacheable-object.model"; +import {CacheableObject} from '../../cache/cacheable-object.model'; /** * Class the represents a Researcher Profile. diff --git a/src/app/core/profile/researcher-profile.service.ts b/src/app/core/profile/researcher-profile.service.ts index 3b4144b77c..0220afb964 100644 --- a/src/app/core/profile/researcher-profile.service.ts +++ b/src/app/core/profile/researcher-profile.service.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-classes-per-file */ import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Router } from '@angular/router'; @@ -30,9 +31,7 @@ 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 } from '../../shared/empty.util'; -import {CoreState} from "../core-state.model"; - -/* tslint:disable:max-classes-per-file */ +import {CoreState} from '../core-state.model'; /** * A private DataService implementation to delegate specific methods to. diff --git a/src/app/item-page/simple/item-page.component.ts b/src/app/item-page/simple/item-page.component.ts index 32a0d9f4c1..34a059246d 100644 --- a/src/app/item-page/simple/item-page.component.ts +++ b/src/app/item-page/simple/item-page.component.ts @@ -80,7 +80,7 @@ export class ItemPageComponent implements OnInit { this.route.data.pipe( map((data) => data.dso as RemoteData) ).subscribe((data: RemoteData) => { - this.itemUrl = data?.payload?.self + this.itemUrl = data?.payload?.self; }); } @@ -102,7 +102,7 @@ export class ItemPageComponent implements OnInit { this.authorizationService.isAuthorized(FeatureID.ShowClaimItem, this.itemUrl).pipe( take(1) ).subscribe((isAuthorized: boolean) => { - this.claimable$.next(isAuthorized) + this.claimable$.next(isAuthorized); }); } diff --git a/src/app/profile-page/profile-page-researcher-form/profile-page-researcher-form.component.ts b/src/app/profile-page/profile-page-researcher-form/profile-page-researcher-form.component.ts index 6a0b687afa..e9d9cb6d05 100644 --- a/src/app/profile-page/profile-page-researcher-form/profile-page-researcher-form.component.ts +++ b/src/app/profile-page/profile-page-researcher-form/profile-page-researcher-form.component.ts @@ -128,10 +128,10 @@ export class ProfilePageResearcherFormComponent implements OnInit { * @param researcherProfile the profile to update */ toggleProfileVisibility(researcherProfile: ResearcherProfile): void { - /* tslint:disable:no-empty */ this.researcherProfileService.setVisibility(researcherProfile, !researcherProfile.visible) - .subscribe((updatedProfile) => {}); // this.researcherProfile$.next(updatedProfile); - /* tslint:enable:no-empty */ + .subscribe((updatedProfile) => { + this.researcherProfile$.next(updatedProfile); + }); } /** From 788a326592ee5e4462eb1ab27f69b090d1d8dcdd Mon Sep 17 00:00:00 2001 From: Luca Giamminonni Date: Fri, 29 Apr 2022 15:51:35 +0200 Subject: [PATCH 6/6] [CST-5307] Fixed test --- ...ile-page-researcher-form.component.spec.ts | 23 +------------------ 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/src/app/profile-page/profile-page-researcher-form/profile-page-researcher-form.component.spec.ts b/src/app/profile-page/profile-page-researcher-form/profile-page-researcher-form.component.spec.ts index bacb3469ad..d12c445ce4 100644 --- a/src/app/profile-page/profile-page-researcher-form/profile-page-researcher-form.component.spec.ts +++ b/src/app/profile-page/profile-page-researcher-form/profile-page-researcher-form.component.spec.ts @@ -16,11 +16,6 @@ import { ProfilePageResearcherFormComponent } from './profile-page-researcher-fo import { ProfileClaimService } from '../profile-claim/profile-claim.service'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { AuthService } from 'src/app/core/auth/auth.service'; -import { EditItemDataService } from '../../core/submission/edititem-data.service'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { EditItemMode } from '../../core/submission/models/edititem-mode.model'; -import { EditItem } from '../../core/submission/models/edititem.model'; -import { createPaginatedList } from '../../shared/testing/utils.test'; describe('ProfilePageResearcherFormComponent', () => { @@ -39,17 +34,6 @@ describe('ProfilePageResearcherFormComponent', () => { let authService: AuthService; - let editItemDataService: any; - - const editItemMode: EditItemMode = Object.assign(new EditItemMode(), { - name: 'test', - label: 'test' - }); - - const editItem: EditItem = Object.assign(new EditItem(), { - modes: createSuccessfulRemoteDataObject$(createPaginatedList([editItemMode])) - }); - function init() { user = Object.assign(new EPerson(), { @@ -80,10 +64,6 @@ describe('ProfilePageResearcherFormComponent', () => { canClaimProfiles: observableOf(false), }); - editItemDataService = jasmine.createSpyObj('EditItemDataService', { - findById: createSuccessfulRemoteDataObject$(editItem) - }); - } beforeEach(waitForAsync(() => { @@ -96,8 +76,7 @@ describe('ProfilePageResearcherFormComponent', () => { { provide: ResearcherProfileService, useValue: researcherProfileService }, { provide: NotificationsService, useValue: notificationsServiceStub }, { provide: ProfileClaimService, useValue: profileClaimService }, - { provide: AuthService, useValue: authService }, - { provide: EditItemDataService, useValue: editItemDataService } + { provide: AuthService, useValue: authService } ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents();