diff --git a/src/app/profile-page/profile-page.component.spec.ts b/src/app/profile-page/profile-page.component.spec.ts
index 46f83c964b..6893ac2437 100644
--- a/src/app/profile-page/profile-page.component.spec.ts
+++ b/src/app/profile-page/profile-page.component.spec.ts
@@ -11,16 +11,18 @@ import { AuthTokenInfo } from '../core/auth/models/auth-token-info.model';
import { EPersonDataService } from '../core/eperson/eperson-data.service';
import { NotificationsService } from '../shared/notifications/notifications.service';
import { authReducer } from '../core/auth/auth.reducer';
-import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils';
+import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils';
import { createPaginatedList } from '../shared/testing/utils.test';
import { BehaviorSubject, of as observableOf } from 'rxjs';
import { AuthService } from '../core/auth/auth.service';
import { RestResponse } from '../core/cache/response.models';
import { provideMockStore } from '@ngrx/store/testing';
import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service';
-import { getTestScheduler } from 'jasmine-marbles';
+import { cold, getTestScheduler } from 'jasmine-marbles';
import { By } from '@angular/platform-browser';
import { EmptySpecialGroupDataMock$, SpecialGroupDataMock$ } from '../shared/testing/special-group.mock';
+import { ConfigurationDataService } from '../core/data/configuration-data.service';
+import { ConfigurationProperty } from '../core/shared/configuration-property.model';
describe('ProfilePageComponent', () => {
let component: ProfilePageComponent;
@@ -29,16 +31,28 @@ describe('ProfilePageComponent', () => {
let initialState: any;
let authService;
+ let authorizationService;
let epersonService;
let notificationsService;
+ let configurationService;
const canChangePassword = new BehaviorSubject(true);
+ const validConfiguration = Object.assign(new ConfigurationProperty(), {
+ name: 'researcher-profile.entity-type',
+ values: [
+ 'Person'
+ ]
+ });
+ const emptyConfiguration = Object.assign(new ConfigurationProperty(), {
+ name: 'researcher-profile.entity-type',
+ values: []
+ });
function init() {
user = Object.assign(new EPerson(), {
id: 'userId',
groups: createSuccessfulRemoteDataObject$(createPaginatedList([])),
- _links: {self: {href: 'test.com/uuid/1234567654321'}}
+ _links: { self: { href: 'test.com/uuid/1234567654321' } }
});
initialState = {
core: {
@@ -53,7 +67,7 @@ describe('ProfilePageComponent', () => {
}
}
};
-
+ authorizationService = jasmine.createSpyObj('authorizationService', { isAuthorized: canChangePassword });
authService = jasmine.createSpyObj('authService', {
getAuthenticatedUserFromStore: observableOf(user),
getSpecialGroupsFromAuthStatus: SpecialGroupDataMock$
@@ -67,6 +81,9 @@ describe('ProfilePageComponent', () => {
error: {},
warning: {}
});
+ configurationService = jasmine.createSpyObj('configurationDataService', {
+ findByPropertyName: jasmine.createSpy('findByPropertyName')
+ });
}
beforeEach(waitForAsync(() => {
@@ -82,7 +99,8 @@ describe('ProfilePageComponent', () => {
{ provide: EPersonDataService, useValue: epersonService },
{ provide: NotificationsService, useValue: notificationsService },
{ provide: AuthService, useValue: authService },
- { provide: AuthorizationDataService, useValue: jasmine.createSpyObj('authorizationService', { isAuthorized: canChangePassword }) },
+ { provide: ConfigurationDataService, useValue: configurationService },
+ { provide: AuthorizationDataService, useValue: authorizationService },
provideMockStore({ initialState }),
],
schemas: [NO_ERRORS_SCHEMA]
@@ -92,151 +110,157 @@ describe('ProfilePageComponent', () => {
beforeEach(() => {
fixture = TestBed.createComponent(ProfilePageComponent);
component = fixture.componentInstance;
- fixture.detectChanges();
});
- describe('updateProfile', () => {
- describe('when the metadata form returns false and the security form returns true', () => {
- beforeEach(() => {
- component.metadataForm = jasmine.createSpyObj('metadataForm', {
- updateProfile: false
+ describe('', () => {
+
+ beforeEach(() => {
+ configurationService.findByPropertyName.and.returnValue(createSuccessfulRemoteDataObject$(validConfiguration));
+ fixture.detectChanges();
+ });
+
+ describe('updateProfile', () => {
+ describe('when the metadata form returns false and the security form returns true', () => {
+ beforeEach(() => {
+ component.metadataForm = jasmine.createSpyObj('metadataForm', {
+ updateProfile: false
+ });
+ spyOn(component, 'updateSecurity').and.returnValue(true);
+ component.updateProfile();
});
- spyOn(component, 'updateSecurity').and.returnValue(true);
- component.updateProfile();
- });
- it('should not display a warning', () => {
- expect(notificationsService.warning).not.toHaveBeenCalled();
- });
- });
-
- describe('when the metadata form returns true and the security form returns false', () => {
- beforeEach(() => {
- component.metadataForm = jasmine.createSpyObj('metadataForm', {
- updateProfile: true
+ it('should not display a warning', () => {
+ expect(notificationsService.warning).not.toHaveBeenCalled();
});
- component.updateProfile();
});
- it('should not display a warning', () => {
- expect(notificationsService.warning).not.toHaveBeenCalled();
- });
- });
-
- describe('when the metadata form returns true and the security form returns true', () => {
- beforeEach(() => {
- component.metadataForm = jasmine.createSpyObj('metadataForm', {
- updateProfile: true
+ describe('when the metadata form returns true and the security form returns false', () => {
+ beforeEach(() => {
+ component.metadataForm = jasmine.createSpyObj('metadataForm', {
+ updateProfile: true
+ });
+ component.updateProfile();
});
- component.updateProfile();
- });
- it('should not display a warning', () => {
- expect(notificationsService.warning).not.toHaveBeenCalled();
- });
- });
-
- describe('when the metadata form returns false and the security form returns false', () => {
- beforeEach(() => {
- component.metadataForm = jasmine.createSpyObj('metadataForm', {
- updateProfile: false
+ it('should not display a warning', () => {
+ expect(notificationsService.warning).not.toHaveBeenCalled();
});
- component.updateProfile();
});
- it('should display a warning', () => {
- expect(notificationsService.warning).toHaveBeenCalled();
- });
- });
- });
+ describe('when the metadata form returns true and the security form returns true', () => {
+ beforeEach(() => {
+ component.metadataForm = jasmine.createSpyObj('metadataForm', {
+ updateProfile: true
+ });
+ component.updateProfile();
+ });
- describe('updateSecurity', () => {
- describe('when no password value present', () => {
- let result;
-
- beforeEach(() => {
- component.setPasswordValue('');
-
- result = component.updateSecurity();
+ it('should not display a warning', () => {
+ expect(notificationsService.warning).not.toHaveBeenCalled();
+ });
});
- it('should return false', () => {
- expect(result).toEqual(false);
- });
+ describe('when the metadata form returns false and the security form returns false', () => {
+ beforeEach(() => {
+ component.metadataForm = jasmine.createSpyObj('metadataForm', {
+ updateProfile: false
+ });
+ component.updateProfile();
+ });
- it('should not call epersonService.patch', () => {
- expect(epersonService.patch).not.toHaveBeenCalled();
+ it('should display a warning', () => {
+ expect(notificationsService.warning).toHaveBeenCalled();
+ });
});
});
- describe('when password is filled in, but the password is invalid', () => {
- let result;
+ describe('updateSecurity', () => {
+ describe('when no password value present', () => {
+ let result;
- beforeEach(() => {
- component.setPasswordValue('test');
- component.setInvalid(true);
- result = component.updateSecurity();
+ beforeEach(() => {
+ component.setPasswordValue('');
+
+ result = component.updateSecurity();
+ });
+
+ it('should return false', () => {
+ expect(result).toEqual(false);
+ });
+
+ it('should not call epersonService.patch', () => {
+ expect(epersonService.patch).not.toHaveBeenCalled();
+ });
});
- it('should return true', () => {
- expect(result).toEqual(true);
- expect(epersonService.patch).not.toHaveBeenCalled();
+ describe('when password is filled in, but the password is invalid', () => {
+ let result;
+
+ beforeEach(() => {
+ component.setPasswordValue('test');
+ component.setInvalid(true);
+ result = component.updateSecurity();
+ });
+
+ it('should return true', () => {
+ expect(result).toEqual(true);
+ expect(epersonService.patch).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when password is filled in, and is valid', () => {
+ let result;
+ let operations;
+
+ beforeEach(() => {
+ component.setPasswordValue('testest');
+ component.setInvalid(false);
+
+ operations = [{ op: 'add', path: '/password', value: 'testest' }];
+ result = component.updateSecurity();
+ });
+
+ it('should return true', () => {
+ expect(result).toEqual(true);
+ });
+
+ it('should return call epersonService.patch', () => {
+ expect(epersonService.patch).toHaveBeenCalledWith(user, operations);
+ });
});
});
- describe('when password is filled in, and is valid', () => {
- let result;
- let operations;
+ describe('canChangePassword$', () => {
+ describe('when the user is allowed to change their password', () => {
+ beforeEach(() => {
+ canChangePassword.next(true);
+ });
- beforeEach(() => {
- component.setPasswordValue('testest');
- component.setInvalid(false);
+ it('should contain true', () => {
+ getTestScheduler().expectObservable(component.canChangePassword$).toBe('(a)', { a: true });
+ });
- operations = [{ op: 'add', path: '/password', value: 'testest' }];
- result = component.updateSecurity();
+ it('should show the security section on the page', () => {
+ fixture.detectChanges();
+ expect(fixture.debugElement.query(By.css('.security-section'))).not.toBeNull();
+ });
});
- it('should return true', () => {
- expect(result).toEqual(true);
- });
+ describe('when the user is not allowed to change their password', () => {
+ beforeEach(() => {
+ canChangePassword.next(false);
+ });
- it('should return call epersonService.patch', () => {
- expect(epersonService.patch).toHaveBeenCalledWith(user, operations);
+ it('should contain false', () => {
+ getTestScheduler().expectObservable(component.canChangePassword$).toBe('(a)', { a: false });
+ });
+
+ it('should not show the security section on the page', () => {
+ fixture.detectChanges();
+ expect(fixture.debugElement.query(By.css('.security-section'))).toBeNull();
+ });
});
});
- });
-
- describe('canChangePassword$', () => {
- describe('when the user is allowed to change their password', () => {
- beforeEach(() => {
- canChangePassword.next(true);
- });
-
- it('should contain true', () => {
- getTestScheduler().expectObservable(component.canChangePassword$).toBe('(a)', { a: true });
- });
-
- it('should show the security section on the page', () => {
- fixture.detectChanges();
- expect(fixture.debugElement.query(By.css('.security-section'))).not.toBeNull();
- });
- });
-
- describe('when the user is not allowed to change their password', () => {
- beforeEach(() => {
- canChangePassword.next(false);
- });
-
- it('should contain false', () => {
- getTestScheduler().expectObservable(component.canChangePassword$).toBe('(a)', { a: false });
- });
-
- it('should not show the security section on the page', () => {
- fixture.detectChanges();
- expect(fixture.debugElement.query(By.css('.security-section'))).toBeNull();
- });
- });
- });
describe('check for specialGroups', () => {
it('should contains specialGroups list', () => {
@@ -258,4 +282,56 @@ describe('ProfilePageComponent', () => {
expect(specialGroupsEle).toBeFalsy();
});
});
+ });
+
+ describe('isResearcherProfileEnabled', () => {
+
+ describe('when configuration service return values', () => {
+
+ beforeEach(() => {
+ configurationService.findByPropertyName.and.returnValue(createSuccessfulRemoteDataObject$(validConfiguration));
+ fixture.detectChanges();
+ });
+
+ it('should return true', () => {
+ const result = component.isResearcherProfileEnabled();
+ const expected = cold('a', {
+ a: true
+ });
+ expect(result).toBeObservable(expected);
+ });
+ });
+
+ describe('when configuration service return no values', () => {
+
+ beforeEach(() => {
+ configurationService.findByPropertyName.and.returnValue(createSuccessfulRemoteDataObject$(emptyConfiguration));
+ fixture.detectChanges();
+ });
+
+ it('should return false', () => {
+ const result = component.isResearcherProfileEnabled();
+ const expected = cold('a', {
+ a: false
+ });
+ expect(result).toBeObservable(expected);
+ });
+ });
+
+ describe('when configuration service return an error', () => {
+
+ beforeEach(() => {
+ configurationService.findByPropertyName.and.returnValue(createFailedRemoteDataObject$());
+ fixture.detectChanges();
+ });
+
+ it('should return false', () => {
+ const result = component.isResearcherProfileEnabled();
+ const expected = cold('a', {
+ a: false
+ });
+ expect(result).toBeObservable(expected);
+ });
+ });
+ });
});
diff --git a/src/app/profile-page/profile-page.component.ts b/src/app/profile-page/profile-page.component.ts
index 7623e9e6ea..5629a1ae18 100644
--- a/src/app/profile-page/profile-page.component.ts
+++ b/src/app/profile-page/profile-page.component.ts
@@ -1,5 +1,5 @@
import { Component, OnInit, ViewChild } from '@angular/core';
-import { Observable } from 'rxjs';
+import { BehaviorSubject, Observable } from 'rxjs';
import { EPerson } from '../core/eperson/models/eperson.model';
import { ProfilePageMetadataFormComponent } from './profile-page-metadata-form/profile-page-metadata-form.component';
import { NotificationsService } from '../shared/notifications/notifications.service';
@@ -16,6 +16,8 @@ import { AuthService } from '../core/auth/auth.service';
import { Operation } from 'fast-json-patch';
import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../core/data/feature-authorization/feature-id';
+import { ConfigurationDataService } from '../core/data/configuration-data.service';
+import { ConfigurationProperty } from '../core/shared/configuration-property.model';
@Component({
selector: 'ds-profile-page',
@@ -72,11 +74,14 @@ export class ProfilePageComponent implements OnInit {
private currentUser: EPerson;
canChangePassword$: Observable
;
+ isResearcherProfileEnabled$: BehaviorSubject = new BehaviorSubject(false);
+
constructor(private authService: AuthService,
private notificationsService: NotificationsService,
private translate: TranslateService,
private epersonService: EPersonDataService,
- private authorizationService: AuthorizationDataService) {
+ private authorizationService: AuthorizationDataService,
+ private configurationService: ConfigurationDataService) {
}
ngOnInit(): void {
@@ -90,6 +95,12 @@ export class ProfilePageComponent implements OnInit {
this.groupsRD$ = this.user$.pipe(switchMap((user: EPerson) => user.groups));
this.canChangePassword$ = this.user$.pipe(switchMap((user: EPerson) => this.authorizationService.isAuthorized(FeatureID.CanChangePassword, user._links.self.href)));
this.specialGroupsRD$ = this.authService.getSpecialGroupsFromAuthStatus();
+
+ this.configurationService.findByPropertyName('researcher-profile.entity-type').pipe(
+ getFirstCompletedRemoteData()
+ ).subscribe((configRD: RemoteData) => {
+ this.isResearcherProfileEnabled$.next(configRD.hasSucceeded && configRD.payload.values.length > 0);
+ });
}
/**
@@ -165,4 +176,12 @@ export class ProfilePageComponent implements OnInit {
submit() {
this.updateProfile();
}
+
+ /**
+ * Returns true if the researcher profile feature is enabled, false otherwise.
+ */
+ isResearcherProfileEnabled(): Observable {
+ return this.isResearcherProfileEnabled$.asObservable();
+ }
+
}
diff --git a/src/app/profile-page/profile-page.module.ts b/src/app/profile-page/profile-page.module.ts
index dc9595140b..0e2902de33 100644
--- a/src/app/profile-page/profile-page.module.ts
+++ b/src/app/profile-page/profile-page.module.ts
@@ -5,25 +5,37 @@ 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';
+import { ProfileClaimItemModalComponent } from './profile-claim-item-modal/profile-claim-item-modal.component';
+
@NgModule({
imports: [
ProfilePageRoutingModule,
CommonModule,
SharedModule,
- FormModule
+ FormModule,
+ UiSwitchModule
],
exports: [
+ ProfilePageComponent,
+ ThemedProfilePageComponent,
+ ProfilePageMetadataFormComponent,
ProfilePageSecurityFormComponent,
- ProfilePageMetadataFormComponent
+ ProfilePageResearcherFormComponent
],
declarations: [
ProfilePageComponent,
ThemedProfilePageComponent,
+ ProfileClaimItemModalComponent,
ProfilePageMetadataFormComponent,
- ProfilePageSecurityFormComponent
+ ProfilePageSecurityFormComponent,
+ ProfilePageResearcherFormComponent
]
})
export class ProfilePageModule {
diff --git a/src/app/root.module.ts b/src/app/root.module.ts
index e5a8aad949..8577f0d728 100644
--- a/src/app/root.module.ts
+++ b/src/app/root.module.ts
@@ -40,6 +40,8 @@ import {
import {
PageInternalServerErrorComponent
} from './page-internal-server-error/page-internal-server-error.component';
+import { ThemedPageErrorComponent } from './page-error/themed-page-error.component';
+import { PageErrorComponent } from './page-error/page-error.component';
const IMPORTS = [
CommonModule,
@@ -74,7 +76,9 @@ const DECLARATIONS = [
ThemedForbiddenComponent,
IdleModalComponent,
ThemedPageInternalServerErrorComponent,
- PageInternalServerErrorComponent
+ PageInternalServerErrorComponent,
+ ThemedPageErrorComponent,
+ PageErrorComponent
];
const EXPORTS = [
diff --git a/src/app/shared/comcol/comcol-page-handle/comcol-page-handle.component.html b/src/app/shared/comcol/comcol-page-handle/comcol-page-handle.component.html
index b3ca75bf94..552854a0c0 100644
--- a/src/app/shared/comcol/comcol-page-handle/comcol-page-handle.component.html
+++ b/src/app/shared/comcol/comcol-page-handle/comcol-page-handle.component.html
@@ -1,4 +1,4 @@
{{ title | translate }}
-
+
diff --git a/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.scss b/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.scss
index e8b7d689a3..8b13789179 100644
--- a/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.scss
+++ b/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.scss
@@ -1,3 +1 @@
-.btn-dark {
- background-color: var(--ds-admin-sidebar-bg);
-}
+
diff --git a/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.html b/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.html
new file mode 100644
index 0000000000..305900ae33
--- /dev/null
+++ b/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.html
@@ -0,0 +1,6 @@
+
+
diff --git a/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.scss b/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.scss
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.spec.ts b/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.spec.ts
new file mode 100644
index 0000000000..c70ec4b808
--- /dev/null
+++ b/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.spec.ts
@@ -0,0 +1,76 @@
+import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
+import { DSpaceObject } from '../../../core/shared/dspace-object.model';
+import { Item } from '../../../core/shared/item.model';
+import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
+import { of as observableOf } from 'rxjs';
+import { TranslateModule } from '@ngx-translate/core';
+import { RouterTestingModule } from '@angular/router/testing';
+import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
+import { By } from '@angular/platform-browser';
+import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
+import { DsoPageOrcidButtonComponent } from './dso-page-orcid-button.component';
+
+describe('DsoPageOrcidButtonComponent', () => {
+ let component: DsoPageOrcidButtonComponent;
+ let fixture: ComponentFixture;
+
+ let authorizationService: AuthorizationDataService;
+ let dso: DSpaceObject;
+
+ beforeEach(waitForAsync(() => {
+ dso = Object.assign(new Item(), {
+ id: 'test-item',
+ _links: {
+ self: { href: 'test-item-selflink' }
+ }
+ });
+ authorizationService = jasmine.createSpyObj('authorizationService', {
+ isAuthorized: observableOf(true)
+ });
+ TestBed.configureTestingModule({
+ declarations: [DsoPageOrcidButtonComponent],
+ imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NgbModule],
+ providers: [
+ { provide: AuthorizationDataService, useValue: authorizationService }
+ ]
+ }).compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(DsoPageOrcidButtonComponent);
+ component = fixture.componentInstance;
+ component.dso = dso;
+ component.pageRoute = 'test';
+ fixture.detectChanges();
+ });
+
+ it('should check the authorization of the current user', () => {
+ expect(authorizationService.isAuthorized).toHaveBeenCalledWith(FeatureID.CanSynchronizeWithORCID, dso.self);
+ });
+
+ describe('when the user is authorized', () => {
+ beforeEach(() => {
+ (authorizationService.isAuthorized as jasmine.Spy).and.returnValue(observableOf(true));
+ component.ngOnInit();
+ fixture.detectChanges();
+ });
+
+ it('should render a link', () => {
+ const link = fixture.debugElement.query(By.css('a'));
+ expect(link).not.toBeNull();
+ });
+ });
+
+ describe('when the user is not authorized', () => {
+ beforeEach(() => {
+ (authorizationService.isAuthorized as jasmine.Spy).and.returnValue(observableOf(false));
+ component.ngOnInit();
+ fixture.detectChanges();
+ });
+
+ it('should not render a link', () => {
+ const link = fixture.debugElement.query(By.css('a'));
+ expect(link).toBeNull();
+ });
+ });
+});
diff --git a/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.ts b/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.ts
new file mode 100644
index 0000000000..c345d8cbdc
--- /dev/null
+++ b/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.ts
@@ -0,0 +1,39 @@
+import { Component, Input, OnInit } from '@angular/core';
+
+import { BehaviorSubject } from 'rxjs';
+import { take } from 'rxjs/operators';
+
+import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
+import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
+import { DSpaceObject } from '../../../core/shared/dspace-object.model';
+
+@Component({
+ selector: 'ds-dso-page-orcid-button',
+ templateUrl: './dso-page-orcid-button.component.html',
+ styleUrls: ['./dso-page-orcid-button.component.scss']
+})
+export class DsoPageOrcidButtonComponent implements OnInit {
+ /**
+ * The DSpaceObject to display a button to the edit page for
+ */
+ @Input() dso: DSpaceObject;
+
+ /**
+ * The prefix of the route to the edit page (before the object's UUID, e.g. "items")
+ */
+ @Input() pageRoute: string;
+
+ /**
+ * Whether or not the current user is authorized to edit the DSpaceObject
+ */
+ isAuthorized: BehaviorSubject = new BehaviorSubject(false);
+
+ constructor(protected authorizationService: AuthorizationDataService) { }
+
+ ngOnInit() {
+ this.authorizationService.isAuthorized(FeatureID.CanSynchronizeWithORCID, this.dso.self).pipe(take(1)).subscribe((isAuthorized: boolean) => {
+ this.isAuthorized.next(isAuthorized);
+ });
+ }
+
+}
diff --git a/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.html b/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.html
new file mode 100644
index 0000000000..c4bba286bf
--- /dev/null
+++ b/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.html
@@ -0,0 +1,7 @@
+
diff --git a/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.scss b/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.scss
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.spec.ts b/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.spec.ts
new file mode 100644
index 0000000000..168517b47a
--- /dev/null
+++ b/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.spec.ts
@@ -0,0 +1,186 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+
+import { of as observableOf } from 'rxjs';
+import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
+
+import { PersonPageClaimButtonComponent } from './person-page-claim-button.component';
+import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
+import { NotificationsService } from '../../notifications/notifications.service';
+import { NotificationsServiceStub } from '../../testing/notifications-service.stub';
+import { TranslateLoaderMock } from '../../mocks/translate-loader.mock';
+import { ResearcherProfileService } from '../../../core/profile/researcher-profile.service';
+import { RouteService } from '../../../core/services/route.service';
+import { routeServiceStub } from '../../testing/route-service.stub';
+import { Item } from '../../../core/shared/item.model';
+import { ResearcherProfile } from '../../../core/profile/model/researcher-profile.model';
+import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../remote-data.utils';
+import { getTestScheduler } from 'jasmine-marbles';
+import { TestScheduler } from 'rxjs/testing';
+
+describe('PersonPageClaimButtonComponent', () => {
+ let scheduler: TestScheduler;
+ let component: PersonPageClaimButtonComponent;
+ let fixture: ComponentFixture;
+
+ const mockItem: Item = Object.assign(new Item(), {
+ metadata: {
+ 'person.email': [
+ {
+ language: 'en_US',
+ value: 'fake@email.com'
+ }
+ ],
+ 'person.birthDate': [
+ {
+ language: 'en_US',
+ value: '1993'
+ }
+ ],
+ 'person.jobTitle': [
+ {
+ language: 'en_US',
+ value: 'Developer'
+ }
+ ],
+ 'person.familyName': [
+ {
+ language: 'en_US',
+ value: 'Doe'
+ }
+ ],
+ 'person.givenName': [
+ {
+ language: 'en_US',
+ value: 'John'
+ }
+ ]
+ },
+ _links: {
+ self: {
+ href: 'item-href'
+ }
+ }
+ });
+
+ 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 notificationsService = new NotificationsServiceStub();
+
+ const authorizationDataService = jasmine.createSpyObj('authorizationDataService', {
+ isAuthorized: jasmine.createSpy('isAuthorized')
+ });
+
+ const researcherProfileService = jasmine.createSpyObj('researcherProfileService', {
+ createFromExternalSource: jasmine.createSpy('createFromExternalSource'),
+ findRelatedItemId: jasmine.createSpy('findRelatedItemId'),
+ });
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [
+ TranslateModule.forRoot({
+ loader: {
+ provide: TranslateLoader,
+ useClass: TranslateLoaderMock
+ }
+ })
+ ],
+ declarations: [PersonPageClaimButtonComponent],
+ providers: [
+ { provide: AuthorizationDataService, useValue: authorizationDataService },
+ { provide: NotificationsService, useValue: notificationsService },
+ { provide: ResearcherProfileService, useValue: researcherProfileService },
+ { provide: RouteService, useValue: routeServiceStub },
+ ]
+ })
+ .compileComponents();
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(PersonPageClaimButtonComponent);
+ component = fixture.componentInstance;
+ component.object = mockItem;
+ });
+
+ describe('when item can be claimed', () => {
+ beforeEach(() => {
+ authorizationDataService.isAuthorized.and.returnValue(observableOf(true));
+ researcherProfileService.createFromExternalSource.calls.reset();
+ researcherProfileService.findRelatedItemId.calls.reset();
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should create claim button', () => {
+ const btn = fixture.debugElement.query(By.css('[data-test="item-claim"]'));
+ expect(btn).toBeTruthy();
+ });
+
+ describe('claim', () => {
+ describe('when successfully', () => {
+ beforeEach(() => {
+ scheduler = getTestScheduler();
+ researcherProfileService.createFromExternalSource.and.returnValue(createSuccessfulRemoteDataObject$(mockResearcherProfile));
+ researcherProfileService.findRelatedItemId.and.returnValue(observableOf('test-id'));
+ });
+
+ it('should display success notification', () => {
+ scheduler.schedule(() => component.claim());
+ scheduler.flush();
+
+ expect(researcherProfileService.findRelatedItemId).toHaveBeenCalled();
+ expect(notificationsService.success).toHaveBeenCalled();
+ });
+ });
+
+ describe('when not successfully', () => {
+ beforeEach(() => {
+ scheduler = getTestScheduler();
+ researcherProfileService.createFromExternalSource.and.returnValue(createFailedRemoteDataObject$());
+ });
+
+ it('should display success notification', () => {
+ scheduler.schedule(() => component.claim());
+ scheduler.flush();
+
+ expect(researcherProfileService.findRelatedItemId).not.toHaveBeenCalled();
+ expect(notificationsService.error).toHaveBeenCalled();
+ });
+ });
+ });
+
+ });
+
+ describe('when item cannot be claimed', () => {
+ beforeEach(() => {
+ authorizationDataService.isAuthorized.and.returnValue(observableOf(false));
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should create claim button', () => {
+ const btn = fixture.debugElement.query(By.css('[data-test="item-claim"]'));
+ expect(btn).toBeFalsy();
+ });
+
+ });
+});
diff --git a/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.ts b/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.ts
new file mode 100644
index 0000000000..903b9d3679
--- /dev/null
+++ b/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.ts
@@ -0,0 +1,84 @@
+import { Component, Input, OnInit } from '@angular/core';
+
+import { BehaviorSubject, Observable, of as observableOf } from 'rxjs';
+import { mergeMap, take } from 'rxjs/operators';
+import { TranslateService } from '@ngx-translate/core';
+
+import { RouteService } from '../../../core/services/route.service';
+import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
+import { NotificationsService } from '../../notifications/notifications.service';
+import { ResearcherProfileService } from '../../../core/profile/researcher-profile.service';
+import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
+import { getFirstCompletedRemoteData } from '../../../core/shared/operators';
+import { RemoteData } from '../../../core/data/remote-data';
+import { ResearcherProfile } from '../../../core/profile/model/researcher-profile.model';
+import { isNotEmpty } from '../../empty.util';
+import { DSpaceObject } from '../../../core/shared/dspace-object.model';
+
+@Component({
+ selector: 'ds-person-page-claim-button',
+ templateUrl: './person-page-claim-button.component.html',
+ styleUrls: ['./person-page-claim-button.component.scss']
+})
+export class PersonPageClaimButtonComponent implements OnInit {
+
+ /**
+ * The target person item to claim
+ */
+ @Input() object: DSpaceObject;
+
+ /**
+ * A boolean representing if item can be claimed or not
+ */
+ claimable$: BehaviorSubject = new BehaviorSubject(false);
+
+ constructor(protected routeService: RouteService,
+ protected authorizationService: AuthorizationDataService,
+ protected notificationsService: NotificationsService,
+ protected translate: TranslateService,
+ protected researcherProfileService: ResearcherProfileService) {
+ }
+
+ ngOnInit(): void {
+ this.authorizationService.isAuthorized(FeatureID.CanClaimItem, this.object._links.self.href, null, false).pipe(
+ take(1)
+ ).subscribe((isAuthorized: boolean) => {
+ this.claimable$.next(isAuthorized);
+ });
+
+ }
+
+ /**
+ * Create a new researcher profile claiming the current item.
+ */
+ claim() {
+ this.researcherProfileService.createFromExternalSource(this.object._links.self.href).pipe(
+ getFirstCompletedRemoteData(),
+ mergeMap((rd: RemoteData) => {
+ if (rd.hasSucceeded) {
+ return this.researcherProfileService.findRelatedItemId(rd.payload);
+ } else {
+ return observableOf(null);
+ }
+ }))
+ .subscribe((id: string) => {
+ if (isNotEmpty(id)) {
+ this.notificationsService.success(this.translate.get('researcherprofile.success.claim.title'),
+ this.translate.get('researcherprofile.success.claim.body'));
+ this.claimable$.next(false);
+ } else {
+ this.notificationsService.error(
+ this.translate.get('researcherprofile.error.claim.title'),
+ this.translate.get('researcherprofile.error.claim.body'));
+ }
+ });
+ }
+
+ /**
+ * Returns true if the item is claimable, false otherwise.
+ */
+ isClaimable(): Observable {
+ return this.claimable$;
+ }
+
+}
diff --git a/src/app/shared/handle.service.spec.ts b/src/app/shared/handle.service.spec.ts
new file mode 100644
index 0000000000..b326eb0416
--- /dev/null
+++ b/src/app/shared/handle.service.spec.ts
@@ -0,0 +1,47 @@
+import { HandleService } from './handle.service';
+
+describe('HandleService', () => {
+ let service: HandleService;
+
+ beforeEach(() => {
+ service = new HandleService();
+ });
+
+ describe(`normalizeHandle`, () => {
+ it(`should simply return an already normalized handle`, () => {
+ let input, output;
+
+ input = '123456789/123456';
+ output = service.normalizeHandle(input);
+ expect(output).toEqual(input);
+
+ input = '12.3456.789/123456';
+ output = service.normalizeHandle(input);
+ expect(output).toEqual(input);
+ });
+
+ it(`should normalize a handle url`, () => {
+ let input, output;
+
+ input = 'https://hdl.handle.net/handle/123456789/123456';
+ output = service.normalizeHandle(input);
+ expect(output).toEqual('123456789/123456');
+
+ input = 'https://rest.api/server/handle/123456789/123456';
+ output = service.normalizeHandle(input);
+ expect(output).toEqual('123456789/123456');
+ });
+
+ it(`should return null if the input doesn't contain a handle`, () => {
+ let input, output;
+
+ input = 'https://hdl.handle.net/handle/123456789';
+ output = service.normalizeHandle(input);
+ expect(output).toBeNull();
+
+ input = 'something completely different';
+ output = service.normalizeHandle(input);
+ expect(output).toBeNull();
+ });
+ });
+});
diff --git a/src/app/shared/handle.service.ts b/src/app/shared/handle.service.ts
new file mode 100644
index 0000000000..da0f17f7de
--- /dev/null
+++ b/src/app/shared/handle.service.ts
@@ -0,0 +1,41 @@
+import { Injectable } from '@angular/core';
+import { isNotEmpty, isEmpty } from './empty.util';
+
+const PREFIX_REGEX = /handle\/([^\/]+\/[^\/]+)$/;
+const NO_PREFIX_REGEX = /^([^\/]+\/[^\/]+)$/;
+
+@Injectable({
+ providedIn: 'root'
+})
+export class HandleService {
+
+
+ /**
+ * Turns a handle string into the default 123456789/12345 format
+ *
+ * @param handle the input handle
+ *
+ * normalizeHandle('123456789/123456') // '123456789/123456'
+ * normalizeHandle('12.3456.789/123456') // '12.3456.789/123456'
+ * normalizeHandle('https://hdl.handle.net/handle/123456789/123456') // '123456789/123456'
+ * normalizeHandle('https://rest.api/server/handle/123456789/123456') // '123456789/123456'
+ * normalizeHandle('https://rest.api/server/handle/123456789') // null
+ */
+ normalizeHandle(handle: string): string {
+ let matches: string[];
+ if (isNotEmpty(handle)) {
+ matches = handle.match(PREFIX_REGEX);
+ }
+
+ if (isEmpty(matches) || matches.length < 2) {
+ matches = handle.match(NO_PREFIX_REGEX);
+ }
+
+ if (isEmpty(matches) || matches.length < 2) {
+ return null;
+ } else {
+ return matches[1];
+ }
+ }
+
+}
diff --git a/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.html b/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.html
index 3b150a46c9..726dc9ca0e 100644
--- a/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.html
+++ b/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.html
@@ -27,7 +27,7 @@