diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index eeddb37441..04d426d091 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,7 +22,7 @@ jobs: strategy: # Create a matrix of Node versions to test against (in parallel) matrix: - node-version: [12.x, 14.x] + node-version: [14.x, 16.x] # Do NOT exit immediately if one matrix job fails fail-fast: false # These are the actual CI steps to perform per job @@ -82,11 +82,11 @@ jobs: run: yarn run test:headless # NOTE: Angular CLI only supports code coverage for specs. See https://github.com/angular/angular-cli/issues/6286 - # Upload coverage reports to Codecov (for Node v12 only) + # Upload coverage reports to Codecov (for one version of Node only) # https://github.com/codecov/codecov-action - name: Upload coverage to Codecov.io uses: codecov/codecov-action@v2 - if: matrix.node-version == '12.x' + if: matrix.node-version == '16.x' # Using docker-compose start backend using CI configuration # and load assetstore from a cached copy diff --git a/README.md b/README.md index 0e26d9e492..cef95a45fa 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ https://wiki.lyrasis.org/display/DSDOC7x/Installing+DSpace Quick start ----------- -**Ensure you're running [Node](https://nodejs.org) `v12.x`, `v14.x` or `v16.x`, [npm](https://www.npmjs.com/) >= `v5.x` and [yarn](https://yarnpkg.com) == `v1.x`** +**Ensure you're running [Node](https://nodejs.org) `v14.x` or `v16.x`, [npm](https://www.npmjs.com/) >= `v5.x` and [yarn](https://yarnpkg.com) == `v1.x`** ```bash # clone the repo @@ -90,7 +90,7 @@ Requirements ------------ - [Node.js](https://nodejs.org) and [yarn](https://yarnpkg.com) -- Ensure you're running node `v12.x`, `v14.x` or `v16.x` and yarn == `v1.x` +- Ensure you're running node `v14.x` or `v16.x` and yarn == `v1.x` If you have [`nvm`](https://github.com/creationix/nvm#install-script) or [`nvm-windows`](https://github.com/coreybutler/nvm-windows) installed, which is highly recommended, you can run `nvm install --lts && nvm use` to install and start using the latest Node LTS. diff --git a/angular.json b/angular.json index 56e06bd86c..2ece0c5e7d 100644 --- a/angular.json +++ b/angular.json @@ -63,7 +63,8 @@ "bundleName": "dspace-theme" } ], - "scripts": [] + "scripts": [], + "baseHref": "/" }, "configurations": { "development": { diff --git a/docker/db.entities.yml b/docker/db.entities.yml index d1dfdf4a26..6473bf2e38 100644 --- a/docker/db.entities.yml +++ b/docker/db.entities.yml @@ -25,7 +25,7 @@ services: ### OVERRIDE default 'entrypoint' in 'docker-compose-rest.yml' #### # Ensure that the database is ready BEFORE starting tomcat # 1. While a TCP connection to dspacedb port 5432 is not available, continue to sleep - # 2. Then, run database migration to init database tables + # 2. Then, run database migration to init database tables (including any out-of-order ignored migrations, if any) # 3. (Custom for Entities) enable Entity-specific collection submission mappings in item-submission.xml # This 'sed' command inserts the sample configurations specific to the Entities data set, see: # https://github.com/DSpace/DSpace/blob/main/dspace/config/item-submission.xml#L36-L49 @@ -35,7 +35,7 @@ services: - '-c' - | while (! /dev/null 2>&1; do sleep 1; done; - /dspace/bin/dspace database migrate + /dspace/bin/dspace database migrate ignored sed -i '/name-map collection-handle="default".*/a \\n \ \ \ diff --git a/docker/docker-compose-ci.yml b/docker/docker-compose-ci.yml index 3bd8f52630..dbe9500499 100644 --- a/docker/docker-compose-ci.yml +++ b/docker/docker-compose-ci.yml @@ -46,14 +46,14 @@ services: - solr_configs:/dspace/solr # Ensure that the database is ready BEFORE starting tomcat # 1. While a TCP connection to dspacedb port 5432 is not available, continue to sleep - # 2. Then, run database migration to init database tables + # 2. Then, run database migration to init database tables (including any out-of-order ignored migrations, if any) # 3. Finally, start Tomcat entrypoint: - /bin/bash - '-c' - | while (! /dev/null 2>&1; do sleep 1; done; - /dspace/bin/dspace database migrate + /dspace/bin/dspace database migrate ignored catalina.sh run # DSpace database container # NOTE: This is customized to use our loadsql image, so that we are using a database with existing test data diff --git a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts index 334d69f19a..a6ea7e4946 100644 --- a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts +++ b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts @@ -18,8 +18,8 @@ import { ItemAdminSearchResultGridElementComponent } from './item-admin-search-r import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/remote-data.utils'; import { getMockThemeService } from '../../../../../shared/mocks/theme-service.mock'; import { ThemeService } from '../../../../../shared/theme-support/theme.service'; -import { AccessStatusDataService } from 'src/app/core/data/access-status-data.service'; -import { AccessStatusObject } from 'src/app/shared/object-list/access-status-badge/access-status.model'; +import { AccessStatusDataService } from '../../../../../core/data/access-status-data.service'; +import { AccessStatusObject } from '../../../../../shared/object-list/access-status-badge/access-status.model'; describe('ItemAdminSearchResultGridElementComponent', () => { let component: ItemAdminSearchResultGridElementComponent; diff --git a/src/app/app-routing-paths.ts b/src/app/app-routing-paths.ts index b54036cf5a..e9a6376884 100644 --- a/src/app/app-routing-paths.ts +++ b/src/app/app-routing-paths.ts @@ -107,6 +107,8 @@ export function getPageInternalServerErrorRoute() { return `/${INTERNAL_SERVER_ERROR}`; } +export const ERROR_PAGE = 'error'; + export const INFO_MODULE_PATH = 'info'; export function getInfoModulePath() { return `/${INFO_MODULE_PATH}`; diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index d00e1d7b0a..d426b041ce 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -10,6 +10,7 @@ import { ACCESS_CONTROL_MODULE_PATH, ADMIN_MODULE_PATH, BITSTREAM_MODULE_PATH, + ERROR_PAGE, FORBIDDEN_PATH, FORGOT_PASSWORD_PATH, HEALTH_PAGE_PATH, @@ -38,11 +39,13 @@ import { } from './page-internal-server-error/themed-page-internal-server-error.component'; import { ServerCheckGuard } from './core/server-check/server-check.guard'; import { MenuResolver } from './menu.resolver'; +import { ThemedPageErrorComponent } from './page-error/themed-page-error.component'; @NgModule({ imports: [ RouterModule.forRoot([ { path: INTERNAL_SERVER_ERROR, component: ThemedPageInternalServerErrorComponent }, + { path: ERROR_PAGE , component: ThemedPageErrorComponent }, { path: '', canActivate: [AuthBlockingGuard], diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 2c743e219d..ee8c4d685f 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -22,7 +22,7 @@ import { import { isEqual } from 'lodash'; import { BehaviorSubject, Observable, of } from 'rxjs'; import { select, Store } from '@ngrx/store'; -import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { NgbModal, NgbModalConfig } from '@ng-bootstrap/ng-bootstrap'; import { TranslateService } from '@ngx-translate/core'; import { Angulartics2GoogleAnalytics } from 'angulartics2'; @@ -49,6 +49,7 @@ import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service'; import { IdleModalComponent } from './shared/idle-modal/idle-modal.component'; import { getDefaultThemeConfig } from '../config/config.util'; import { AppConfig, APP_CONFIG } from 'src/config/app-config.interface'; +import { ModalBeforeDismiss } from './shared/interfaces/modal-before-dismiss.interface'; @Component({ selector: 'ds-app', @@ -106,6 +107,7 @@ export class AppComponent implements OnInit, AfterViewInit { private localeService: LocaleService, private breadcrumbsService: BreadcrumbsService, private modalService: NgbModal, + private modalConfig: NgbModalConfig, @Optional() private cookiesService: KlaroService, @Optional() private googleAnalyticsService: GoogleAnalyticsService, ) { @@ -166,6 +168,16 @@ export class AppComponent implements OnInit, AfterViewInit { } ngOnInit() { + /** Implement behavior for interface {@link ModalBeforeDismiss} */ + this.modalConfig.beforeDismiss = async function () { + if (typeof this?.componentInstance?.beforeDismiss === 'function') { + return this.componentInstance.beforeDismiss(); + } + + // fall back to default behavior + return true; + }; + this.isAuthBlocking$ = this.store.pipe(select(isAuthenticationBlocking)).pipe( distinctUntilChanged() ); diff --git a/src/app/core/auth/models/auth.method-type.ts b/src/app/core/auth/models/auth.method-type.ts index 9d999c4c3f..594d6d8b39 100644 --- a/src/app/core/auth/models/auth.method-type.ts +++ b/src/app/core/auth/models/auth.method-type.ts @@ -4,5 +4,6 @@ export enum AuthMethodType { Ldap = 'ldap', Ip = 'ip', X509 = 'x509', - Oidc = 'oidc' + Oidc = 'oidc', + Orcid = 'orcid' } diff --git a/src/app/core/auth/models/auth.method.ts b/src/app/core/auth/models/auth.method.ts index 5a362e8606..0579ae0cd1 100644 --- a/src/app/core/auth/models/auth.method.ts +++ b/src/app/core/auth/models/auth.method.ts @@ -34,6 +34,11 @@ export class AuthMethod { this.location = location; break; } + case 'orcid': { + this.authMethodType = AuthMethodType.Orcid; + this.location = location; + break; + } default: { break; diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index fd219537a0..b16930e819 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -38,7 +38,7 @@ import { SubmissionSectionModel } from './config/models/config-submission-sectio import { SubmissionUploadsModel } from './config/models/config-submission-uploads.model'; import { SubmissionFormsConfigService } from './config/submission-forms-config.service'; import { coreEffects } from './core.effects'; -import { coreReducers} from './core.reducers'; +import { coreReducers } from './core.reducers'; import { BitstreamFormatDataService } from './data/bitstream-format-data.service'; import { CollectionDataService } from './data/collection-data.service'; import { CommunityDataService } from './data/community-data.service'; @@ -132,11 +132,15 @@ import { Feature } from './shared/feature.model'; import { Authorization } from './shared/authorization.model'; import { FeatureDataService } from './data/feature-authorization/feature-data.service'; import { AuthorizationDataService } from './data/feature-authorization/authorization-data.service'; -import { SiteAdministratorGuard } from './data/feature-authorization/feature-authorization-guard/site-administrator.guard'; +import { + SiteAdministratorGuard +} from './data/feature-authorization/feature-authorization-guard/site-administrator.guard'; import { Registration } from './shared/registration.model'; import { MetadataSchemaDataService } from './data/metadata-schema-data.service'; import { MetadataFieldDataService } from './data/metadata-field-data.service'; -import { DsDynamicTypeBindRelationService } from '../shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service'; +import { + DsDynamicTypeBindRelationService +} from '../shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service'; import { TokenResponseParsingService } from './auth/token-response-parsing.service'; import { SubmissionCcLicenseDataService } from './submission/submission-cc-license-data.service'; import { SubmissionCcLicence } from './submission/models/submission-cc-license.model'; @@ -169,6 +173,11 @@ import { LinkHeadService } from './services/link-head.service'; import { ResearcherProfileService } from './profile/researcher-profile.service'; import { ProfileClaimService } from '../profile-page/profile-claim/profile-claim.service'; import { ResearcherProfile } from './profile/model/researcher-profile.model'; +import { OrcidQueueService } from './orcid/orcid-queue.service'; +import { OrcidHistoryDataService } from './orcid/orcid-history-data.service'; +import { OrcidQueue } from './orcid/model/orcid-queue.model'; +import { OrcidHistory } from './orcid/model/orcid-history.model'; +import { OrcidAuthService } from './orcid/orcid-auth.service'; /** * When not in production, endpoint responses can be mocked for testing purposes @@ -296,7 +305,10 @@ const PROVIDERS = [ GroupDataService, FeedbackDataService, ResearcherProfileService, - ProfileClaimService + ProfileClaimService, + OrcidAuthService, + OrcidQueueService, + OrcidHistoryDataService, ]; /** @@ -358,7 +370,10 @@ export const models = SearchConfig, SubmissionAccessesModel, AccessStatusObject, - ResearcherProfile + ResearcherProfile, + OrcidQueue, + OrcidHistory, + AccessStatusObject ]; @NgModule({ diff --git a/src/app/core/data/feature-authorization/feature-id.ts b/src/app/core/data/feature-authorization/feature-id.ts index 59514b65cd..3cb18bf515 100644 --- a/src/app/core/data/feature-authorization/feature-id.ts +++ b/src/app/core/data/feature-authorization/feature-id.ts @@ -29,4 +29,5 @@ export enum FeatureID { CanViewUsageStatistics = 'canViewUsageStatistics', CanSendFeedback = 'canSendFeedback', CanClaimItem = 'canClaimItem', + CanSynchronizeWithORCID = 'canSynchronizeWithORCID' } diff --git a/src/app/core/orcid/model/orcid-history.model.ts b/src/app/core/orcid/model/orcid-history.model.ts new file mode 100644 index 0000000000..ef8f30e0a3 --- /dev/null +++ b/src/app/core/orcid/model/orcid-history.model.ts @@ -0,0 +1,89 @@ +import { autoserialize, deserialize } from 'cerialize'; +import { typedObject } from '../../cache/builders/build-decorators'; +import { HALLink } from '../../shared/hal-link.model'; +import { ResourceType } from '../../shared/resource-type'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { ORCID_HISTORY } from './orcid-history.resource-type'; +import { CacheableObject } from '../../cache/cacheable-object.model'; + +/** + * Class the represents a Orcid History. + */ +@typedObject +export class OrcidHistory extends CacheableObject { + + static type = ORCID_HISTORY; + + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The identifier of this Orcid History record + */ + @autoserialize + id: number; + + /** + * The name of the related entity + */ + @autoserialize + entityName: string; + + /** + * The identifier of the profileItem of this Orcid History record. + */ + @autoserialize + profileItemId: string; + + /** + * The identifier of the entity related to this Orcid History record. + */ + @autoserialize + entityId: string; + + /** + * The type of the entity related to this Orcid History record. + */ + @autoserialize + entityType: string; + + /** + * The response status coming from ORCID api. + */ + @autoserialize + status: number; + + /** + * The putCode assigned by ORCID to the entity. + */ + @autoserialize + putCode: string; + + /** + * The last send attempt timestamp. + */ + lastAttempt: string; + + /** + * The success send attempt timestamp. + */ + successAttempt: string; + + /** + * The response coming from ORCID. + */ + responseMessage: string; + + /** + * The {@link HALLink}s for this Orcid History record + */ + @deserialize + _links: { + self: HALLink, + }; + +} diff --git a/src/app/core/orcid/model/orcid-history.resource-type.ts b/src/app/core/orcid/model/orcid-history.resource-type.ts new file mode 100644 index 0000000000..45da8cbf68 --- /dev/null +++ b/src/app/core/orcid/model/orcid-history.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from '../../shared/resource-type'; + +/** + * The resource type for OrcidHistory + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const ORCID_HISTORY = new ResourceType('orcidhistory'); diff --git a/src/app/core/orcid/model/orcid-queue.model.ts b/src/app/core/orcid/model/orcid-queue.model.ts new file mode 100644 index 0000000000..2a1c3f1d82 --- /dev/null +++ b/src/app/core/orcid/model/orcid-queue.model.ts @@ -0,0 +1,68 @@ +import { autoserialize, deserialize } from 'cerialize'; +import { typedObject } from '../../cache/builders/build-decorators'; +import { HALLink } from '../../shared/hal-link.model'; +import { ResourceType } from '../../shared/resource-type'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { ORCID_QUEUE } from './orcid-queue.resource-type'; +import { CacheableObject } from '../../cache/cacheable-object.model'; + +/** + * Class the represents a Orcid Queue. + */ +@typedObject +export class OrcidQueue extends CacheableObject { + + static type = ORCID_QUEUE; + + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The identifier of this Orcid Queue record + */ + @autoserialize + id: number; + + /** + * The record description. + */ + @autoserialize + description: string; + + /** + * The identifier of the profileItem of this Orcid Queue record. + */ + @autoserialize + profileItemId: string; + + /** + * The identifier of the entity related to this Orcid Queue record. + */ + @autoserialize + entityId: string; + + /** + * The type of this Orcid Queue record. + */ + @autoserialize + recordType: string; + + /** + * The operation related to this Orcid Queue record. + */ + @autoserialize + operation: string; + + /** + * The {@link HALLink}s for this Orcid Queue record + */ + @deserialize + _links: { + self: HALLink, + }; + +} diff --git a/src/app/core/orcid/model/orcid-queue.resource-type.ts b/src/app/core/orcid/model/orcid-queue.resource-type.ts new file mode 100644 index 0000000000..a7f40d70ec --- /dev/null +++ b/src/app/core/orcid/model/orcid-queue.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from '../../shared/resource-type'; + +/** + * The resource type for OrcidQueue + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const ORCID_QUEUE = new ResourceType('orcidqueue'); diff --git a/src/app/core/orcid/orcid-auth.service.spec.ts b/src/app/core/orcid/orcid-auth.service.spec.ts new file mode 100644 index 0000000000..27a33a85b1 --- /dev/null +++ b/src/app/core/orcid/orcid-auth.service.spec.ts @@ -0,0 +1,329 @@ +import { cold, getTestScheduler } from 'jasmine-marbles'; +import { TestScheduler } from 'rxjs/testing'; +import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { RouterMock } from '../../shared/mocks/router.mock'; +import { ResearcherProfile } from '../profile/model/researcher-profile.model'; +import { Item } from '../shared/item.model'; +import { AddOperation, RemoveOperation } from 'fast-json-patch'; +import { ConfigurationProperty } from '../shared/configuration-property.model'; +import { ConfigurationDataService } from '../data/configuration-data.service'; +import { createPaginatedList } from '../../shared/testing/utils.test'; +import { NativeWindowRefMock } from '../../shared/mocks/mock-native-window-ref'; +import { URLCombiner } from '../url-combiner/url-combiner'; +import { OrcidAuthService } from './orcid-auth.service'; +import { ResearcherProfileService } from '../profile/researcher-profile.service'; + +describe('OrcidAuthService', () => { + let scheduler: TestScheduler; + let service: OrcidAuthService; + let serviceAsAny: any; + + let researcherProfileService: jasmine.SpyObj; + let configurationDataService: ConfigurationDataService; + let nativeWindowService: NativeWindowRefMock; + let routerStub: any; + + const researcherProfileId = 'beef9946-rt56-479e-8f11-b90cbe9f7241'; + const itemId = 'beef9946-rt56-479e-8f11-b90cbe9f7241'; + + const researcherProfile: ResearcherProfile = Object.assign(new ResearcherProfile(), { + id: researcherProfileId, + visible: false, + type: 'profile', + _links: { + item: { + href: `https://rest.api/rest/api/profiles/${researcherProfileId}/item` + }, + self: { + href: `https://rest.api/rest/api/profiles/${researcherProfileId}` + }, + } + }); + + const researcherProfilePatched: ResearcherProfile = Object.assign(new ResearcherProfile(), { + id: researcherProfileId, + visible: true, + type: 'profile', + _links: { + item: { + href: `https://rest.api/rest/api/profiles/${researcherProfileId}/item` + }, + self: { + href: `https://rest.api/rest/api/profiles/${researcherProfileId}` + }, + } + }); + + const mockItemUnlinkedToOrcid: Item = Object.assign(new Item(), { + id: 'mockItemUnlinkedToOrcid', + bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), + metadata: { + 'dc.title': [{ + value: 'test person' + }], + 'dspace.entity.type': [{ + 'value': 'Person' + }], + 'dspace.object.owner': [{ + 'value': 'test person', + 'language': null, + 'authority': 'researcher-profile-id', + 'confidence': 600, + 'place': 0 + }], + } + }); + + const mockItemLinkedToOrcid: Item = Object.assign(new Item(), { + bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), + metadata: { + 'dc.title': [{ + value: 'test person' + }], + 'dspace.entity.type': [{ + 'value': 'Person' + }], + 'dspace.object.owner': [{ + 'value': 'test person', + 'language': null, + 'authority': 'researcher-profile-id', + 'confidence': 600, + 'place': 0 + }], + 'dspace.orcid.authenticated': [{ + 'value': '2022-06-10T15:15:12.952872', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + }], + 'dspace.orcid.scope': [{ + 'value': '/authenticate', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + }, { + 'value': '/read-limited', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 1 + }, { + 'value': '/activities/update', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 2 + }, { + 'value': '/person/update', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 3 + }], + 'person.identifier.orcid': [{ + 'value': 'orcid-id', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + }] + } + }); + + const disconnectionAllowAdmin = { + uuid: 'orcid.disconnection.allowed-users', + name: 'orcid.disconnection.allowed-users', + values: ['only_admin'] + } as ConfigurationProperty; + + const disconnectionAllowAdminOwner = { + uuid: 'orcid.disconnection.allowed-users', + name: 'orcid.disconnection.allowed-users', + values: ['admin_and_owner'] + } as ConfigurationProperty; + + const authorizeUrl = { + uuid: 'orcid.authorize-url', + name: 'orcid.authorize-url', + values: ['orcid.authorize-url'] + } as ConfigurationProperty; + const appClientId = { + uuid: 'orcid.application-client-id', + name: 'orcid.application-client-id', + values: ['orcid.application-client-id'] + } as ConfigurationProperty; + const orcidScope = { + uuid: 'orcid.scope', + name: 'orcid.scope', + values: ['/authenticate', '/read-limited'] + } as ConfigurationProperty; + + beforeEach(() => { + scheduler = getTestScheduler(); + routerStub = new RouterMock(); + researcherProfileService = jasmine.createSpyObj('ResearcherProfileService', { + findById: jasmine.createSpy('findById'), + updateByOrcidOperations: jasmine.createSpy('updateByOrcidOperations') + }); + configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: jasmine.createSpy('findByPropertyName') + }); + nativeWindowService = new NativeWindowRefMock(); + + service = new OrcidAuthService( + nativeWindowService, + configurationDataService, + researcherProfileService, + routerStub); + + serviceAsAny = service; + }); + + + describe('isLinkedToOrcid', () => { + it('should return true when item has metadata', () => { + const result = service.isLinkedToOrcid(mockItemLinkedToOrcid); + expect(result).toBeTrue(); + }); + + it('should return true when item has no metadata', () => { + const result = service.isLinkedToOrcid(mockItemUnlinkedToOrcid); + expect(result).toBeFalse(); + }); + }); + + describe('onlyAdminCanDisconnectProfileFromOrcid', () => { + it('should return true when property is only_admin', () => { + spyOn((service as any), 'getOrcidDisconnectionAllowedUsersConfiguration').and.returnValue(createSuccessfulRemoteDataObject$(disconnectionAllowAdmin)); + const result = service.onlyAdminCanDisconnectProfileFromOrcid(); + const expected = cold('(a|)', { + a: true + }); + expect(result).toBeObservable(expected); + }); + + it('should return false on faild', () => { + spyOn((service as any), 'getOrcidDisconnectionAllowedUsersConfiguration').and.returnValue(createFailedRemoteDataObject$()); + const result = service.onlyAdminCanDisconnectProfileFromOrcid(); + const expected = cold('(a|)', { + a: false + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('ownerCanDisconnectProfileFromOrcid', () => { + it('should return true when property is admin_and_owner', () => { + spyOn((service as any), 'getOrcidDisconnectionAllowedUsersConfiguration').and.returnValue(createSuccessfulRemoteDataObject$(disconnectionAllowAdminOwner)); + const result = service.ownerCanDisconnectProfileFromOrcid(); + const expected = cold('(a|)', { + a: true + }); + expect(result).toBeObservable(expected); + }); + + it('should return false on faild', () => { + spyOn((service as any), 'getOrcidDisconnectionAllowedUsersConfiguration').and.returnValue(createFailedRemoteDataObject$()); + const result = service.ownerCanDisconnectProfileFromOrcid(); + const expected = cold('(a|)', { + a: false + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('linkOrcidByItem', () => { + beforeEach(() => { + scheduler = getTestScheduler(); + researcherProfileService.updateByOrcidOperations.and.returnValue(createSuccessfulRemoteDataObject$(researcherProfilePatched)); + researcherProfileService.findById.and.returnValue(createSuccessfulRemoteDataObject$(researcherProfile)); + }); + + it('should call updateByOrcidOperations method properly', () => { + const operations: AddOperation[] = [{ + path: '/orcid', + op: 'add', + value: 'test-code' + }]; + + scheduler.schedule(() => service.linkOrcidByItem(mockItemUnlinkedToOrcid, 'test-code').subscribe()); + scheduler.flush(); + + expect(researcherProfileService.updateByOrcidOperations).toHaveBeenCalledWith(researcherProfile, operations); + }); + }); + + describe('unlinkOrcidByItem', () => { + beforeEach(() => { + scheduler = getTestScheduler(); + researcherProfileService.updateByOrcidOperations.and.returnValue(createSuccessfulRemoteDataObject$(researcherProfilePatched)); + researcherProfileService.findById.and.returnValue(createSuccessfulRemoteDataObject$(researcherProfile)); + }); + + it('should call updateByOrcidOperations method properly', () => { + const operations: RemoveOperation[] = [{ + path: '/orcid', + op: 'remove' + }]; + + scheduler.schedule(() => service.unlinkOrcidByItem(mockItemLinkedToOrcid).subscribe()); + scheduler.flush(); + + expect(researcherProfileService.updateByOrcidOperations).toHaveBeenCalledWith(researcherProfile, operations); + }); + }); + + describe('getOrcidAuthorizeUrl', () => { + beforeEach(() => { + routerStub.setRoute('/entities/person/uuid/orcid'); + (service as any).configurationService.findByPropertyName.and.returnValues( + createSuccessfulRemoteDataObject$(authorizeUrl), + createSuccessfulRemoteDataObject$(appClientId), + createSuccessfulRemoteDataObject$(orcidScope) + ); + }); + + it('should build the url properly', () => { + const result = service.getOrcidAuthorizeUrl(mockItemUnlinkedToOrcid); + const redirectUri: string = new URLCombiner(nativeWindowService.nativeWindow.origin, encodeURIComponent(routerStub.url.split('?')[0])).toString(); + const url = 'orcid.authorize-url?client_id=orcid.application-client-id&redirect_uri=' + redirectUri + '&response_type=code&scope=/authenticate /read-limited'; + + const expected = cold('(a|)', { + a: url + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('getOrcidAuthorizationScopesByItem', () => { + it('should return list of scopes saved in the item', () => { + const orcidScopes = [ + '/authenticate', + '/read-limited', + '/activities/update', + '/person/update' + ]; + const result = service.getOrcidAuthorizationScopesByItem(mockItemLinkedToOrcid); + expect(result).toEqual(orcidScopes); + }); + }); + + describe('getOrcidAuthorizationScopes', () => { + it('should return list of scopes by configuration', () => { + (service as any).configurationService.findByPropertyName.and.returnValue( + createSuccessfulRemoteDataObject$(orcidScope) + ); + const orcidScopes = [ + '/authenticate', + '/read-limited' + ]; + const expected = cold('(a|)', { + a: orcidScopes + }); + const result = service.getOrcidAuthorizationScopes(); + expect(result).toBeObservable(expected); + }); + }); +}); diff --git a/src/app/core/orcid/orcid-auth.service.ts b/src/app/core/orcid/orcid-auth.service.ts new file mode 100644 index 0000000000..cf7bc2b259 --- /dev/null +++ b/src/app/core/orcid/orcid-auth.service.ts @@ -0,0 +1,145 @@ +import { Inject, Injectable } from '@angular/core'; +import { Router } from '@angular/router'; + +import { combineLatest, Observable } from 'rxjs'; +import { map, switchMap } from 'rxjs/operators'; +import { AddOperation, RemoveOperation } from 'fast-json-patch'; + +import { ResearcherProfileService } from '../profile/researcher-profile.service'; +import { Item } from '../shared/item.model'; +import { isNotEmpty } from '../../shared/empty.util'; +import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../shared/operators'; +import { RemoteData } from '../data/remote-data'; +import { ConfigurationProperty } from '../shared/configuration-property.model'; +import { ConfigurationDataService } from '../data/configuration-data.service'; +import { ResearcherProfile } from '../profile/model/researcher-profile.model'; +import { URLCombiner } from '../url-combiner/url-combiner'; +import { NativeWindowRef, NativeWindowService } from '../services/window.service'; + +@Injectable() +export class OrcidAuthService { + + constructor( + @Inject(NativeWindowService) protected _window: NativeWindowRef, + private configurationService: ConfigurationDataService, + private researcherProfileService: ResearcherProfileService, + private router: Router) { + } + + /** + * Check if the given item is linked to an ORCID profile. + * + * @param item the item to check + * @returns the check result + */ + public isLinkedToOrcid(item: Item): boolean { + return item.hasMetadata('dspace.orcid.authenticated'); + } + + /** + * Returns true if only the admin users can disconnect a researcher profile from ORCID. + * + * @returns the check result + */ + public onlyAdminCanDisconnectProfileFromOrcid(): Observable { + return this.getOrcidDisconnectionAllowedUsersConfiguration().pipe( + map((propertyRD: RemoteData) => { + return propertyRD.hasSucceeded && propertyRD.payload.values.map((value) => value.toLowerCase()).includes('only_admin'); + }) + ); + } + + /** + * Returns true if the profile's owner can disconnect that profile from ORCID. + * + * @returns the check result + */ + public ownerCanDisconnectProfileFromOrcid(): Observable { + return this.getOrcidDisconnectionAllowedUsersConfiguration().pipe( + map((propertyRD: RemoteData) => { + return propertyRD.hasSucceeded && propertyRD.payload.values.map( (value) => value.toLowerCase()).includes('admin_and_owner'); + }) + ); + } + + /** + * Perform a link operation to ORCID profile. + * + * @param person The person item related to the researcher profile + * @param code The auth-code received from orcid + */ + public linkOrcidByItem(person: Item, code: string): Observable> { + const operations: AddOperation[] = [{ + path: '/orcid', + op: 'add', + value: code + }]; + + return this.researcherProfileService.findById(person.firstMetadata('dspace.object.owner').authority).pipe( + getFirstCompletedRemoteData(), + switchMap((profileRD) => this.researcherProfileService.updateByOrcidOperations(profileRD.payload, operations)) + ); + } + + /** + * Perform unlink operation from ORCID profile. + * + * @param person The person item related to the researcher profile + */ + public unlinkOrcidByItem(person: Item): Observable> { + const operations: RemoveOperation[] = [{ + path:'/orcid', + op:'remove' + }]; + + return this.researcherProfileService.findById(person.firstMetadata('dspace.object.owner').authority).pipe( + getFirstCompletedRemoteData(), + switchMap((profileRD) => this.researcherProfileService.updateByOrcidOperations(profileRD.payload, operations)) + ); + } + + /** + * Build and return the url to authenticate with orcid + * + * @param profile + */ + public getOrcidAuthorizeUrl(profile: Item): Observable { + return combineLatest([ + this.configurationService.findByPropertyName('orcid.authorize-url').pipe(getFirstSucceededRemoteDataPayload()), + this.configurationService.findByPropertyName('orcid.application-client-id').pipe(getFirstSucceededRemoteDataPayload()), + this.configurationService.findByPropertyName('orcid.scope').pipe(getFirstSucceededRemoteDataPayload())] + ).pipe( + map(([authorizeUrl, clientId, scopes]) => { + const redirectUri = new URLCombiner(this._window.nativeWindow.origin, encodeURIComponent(this.router.url.split('?')[0])); + console.log(redirectUri.toString()); + return authorizeUrl.values[0] + '?client_id=' + clientId.values[0] + '&redirect_uri=' + redirectUri + '&response_type=code&scope=' + + scopes.values.join(' '); + })); + } + + /** + * Return all orcid authorization scopes saved in the given item + * + * @param item + */ + public getOrcidAuthorizationScopesByItem(item: Item): string[] { + return isNotEmpty(item) ? item.allMetadataValues('dspace.orcid.scope') : []; + } + + /** + * Return all orcid authorization scopes available by configuration + */ + public getOrcidAuthorizationScopes(): Observable { + return this.configurationService.findByPropertyName('orcid.scope').pipe( + getFirstCompletedRemoteData(), + map((propertyRD: RemoteData) => propertyRD.hasSucceeded ? propertyRD.payload.values : []) + ); + } + + private getOrcidDisconnectionAllowedUsersConfiguration(): Observable> { + return this.configurationService.findByPropertyName('orcid.disconnection.allowed-users').pipe( + getFirstCompletedRemoteData() + ); + } + +} diff --git a/src/app/core/orcid/orcid-history-data.service.ts b/src/app/core/orcid/orcid-history-data.service.ts new file mode 100644 index 0000000000..cef3efbe78 --- /dev/null +++ b/src/app/core/orcid/orcid-history-data.service.ts @@ -0,0 +1,126 @@ +// eslint-disable-next-line max-classes-per-file +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { map, switchMap } from 'rxjs/operators'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { dataService } from '../cache/builders/build-decorators'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { DataService } from '../data/data.service'; +import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service'; +import { ItemDataService } from '../data/item-data.service'; +import { RemoteData } from '../data/remote-data'; +import { PostRequest } from '../data/request.models'; +import { RequestService } from '../data/request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { OrcidHistory } from './model/orcid-history.model'; +import { ORCID_HISTORY } from './model/orcid-history.resource-type'; +import { OrcidQueue } from './model/orcid-queue.model'; +import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { CoreState } from '../core-state.model'; +import { RestRequest } from '../data/rest-request.model'; +import { sendRequest } from '../shared/request.operators'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { FindListOptions } from '../data/find-list-options.model'; +import { PaginatedList } from '../data/paginated-list.model'; + +/** + * A private DataService implementation to delegate specific methods to. + */ +class OrcidHistoryServiceImpl extends DataService { + public linkPath = 'orcidhistories'; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer) { + super(); + } + +} + +/** + * A service that provides methods to make REST requests with Orcid History endpoint. + */ +@Injectable() +@dataService(ORCID_HISTORY) +export class OrcidHistoryDataService { + + dataService: OrcidHistoryServiceImpl; + + responseMsToLive: number = 10 * 1000; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer, + protected itemService: ItemDataService ) { + + this.dataService = new OrcidHistoryServiceImpl(requestService, rdbService, store, objectCache, halService, + notificationsService, http, comparator); + + } + + sendToORCID(orcidQueue: OrcidQueue): Observable> { + const requestId = this.requestService.generateRequestId(); + return this.getEndpoint().pipe( + map((endpointURL: string) => { + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'text/uri-list'); + options.headers = headers; + return new PostRequest(requestId, endpointURL, orcidQueue._links.self.href, options); + }), + sendRequest(this.requestService), + switchMap((request: RestRequest) => this.rdbService.buildFromRequestUUID(request.uuid) as Observable>) + ); + } + + getEndpoint(): Observable { + return this.halService.getEndpoint(this.dataService.linkPath); + } + + /** + * Returns an observable of {@link RemoteData} of an object, based on its ID, with a list of + * {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object + * @param id ID of object we want to retrieve + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + */ + findById(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { + return this.dataService.findById(id, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Returns a list of observables of {@link RemoteData} of {@link OrcidHistory}s, based on an href, with a list of {@link FollowLinkConfig}, + * to automatically resolve {@link HALLink}s of the {@link OrcidHistory} + * @param href The url of object we want to retrieve + * @param findListOptions Find list options object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + */ + findAllByHref(href: string, findListOptions: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.dataService.findAllByHref(href, findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + +} diff --git a/src/app/core/orcid/orcid-queue.service.ts b/src/app/core/orcid/orcid-queue.service.ts new file mode 100644 index 0000000000..30b9580b96 --- /dev/null +++ b/src/app/core/orcid/orcid-queue.service.ts @@ -0,0 +1,110 @@ +// eslint-disable-next-line max-classes-per-file +import { DataService } from '../data/data.service'; +import { OrcidQueue } from './model/orcid-queue.model'; +import { RequestService } from '../data/request.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { Store } from '@ngrx/store'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { HttpClient } from '@angular/common/http'; +import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service'; +import { Injectable } from '@angular/core'; +import { dataService } from '../cache/builders/build-decorators'; +import { ORCID_QUEUE } from './model/orcid-queue.resource-type'; +import { ItemDataService } from '../data/item-data.service'; +import { Observable } from 'rxjs'; +import { RemoteData } from '../data/remote-data'; +import { PaginatedList } from '../data/paginated-list.model'; +import { RequestParam } from '../cache/models/request-param.model'; +import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; +import { NoContent } from '../shared/NoContent.model'; +import { ConfigurationDataService } from '../data/configuration-data.service'; +import { Router } from '@angular/router'; +import { CoreState } from '../core-state.model'; + +/** + * A private DataService implementation to delegate specific methods to. + */ +class OrcidQueueServiceImpl extends DataService { + public linkPath = 'orcidqueues'; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer) { + super(); + } + +} + +/** + * A service that provides methods to make REST requests with Orcid Queue endpoint. + */ +@Injectable() +@dataService(ORCID_QUEUE) +export class OrcidQueueService { + + dataService: OrcidQueueServiceImpl; + + responseMsToLive: number = 10 * 1000; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer, + protected configurationService: ConfigurationDataService, + protected router: Router, + protected itemService: ItemDataService ) { + + this.dataService = new OrcidQueueServiceImpl(requestService, rdbService, store, objectCache, halService, + notificationsService, http, comparator); + + } + + /** + * @param itemId It represent an Id of profileItem + * @param paginationOptions The pagination options object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @returns { OrcidQueue } + */ + searchByProfileItemId(itemId: string, paginationOptions: PaginationComponentOptions, useCachedVersionIfAvailable = true, reRequestOnStale = true): Observable>> { + return this.dataService.searchBy('findByProfileItem', { + searchParams: [new RequestParam('profileItemId', itemId)], + elementsPerPage: paginationOptions.pageSize, + currentPage: paginationOptions.currentPage + }, + useCachedVersionIfAvailable, + reRequestOnStale + ); + } + + /** + * @param orcidQueueId represents a id of orcid queue + * @returns { NoContent } + */ + deleteById(orcidQueueId: number): Observable> { + return this.dataService.delete(orcidQueueId.toString()); + } + + /** + * This method will set linkPath to stale + */ + clearFindByProfileItemRequests() { + this.requestService.setStaleByHrefSubstring(this.dataService.linkPath + '/search/findByProfileItem'); + } + +} diff --git a/src/app/core/pagination/pagination.service.spec.ts b/src/app/core/pagination/pagination.service.spec.ts index 94b6b48d59..66349e8a9e 100644 --- a/src/app/core/pagination/pagination.service.spec.ts +++ b/src/app/core/pagination/pagination.service.spec.ts @@ -12,7 +12,7 @@ describe('PaginationService', () => { let routeService; const defaultPagination = new PaginationComponentOptions(); - const defaultSort = new SortOptions('id', SortDirection.DESC); + const defaultSort = new SortOptions('dc.title', SortDirection.ASC); const defaultFindListOptions = new FindListOptions(); beforeEach(() => { @@ -39,7 +39,6 @@ describe('PaginationService', () => { service = new PaginationService(routeService, router); }); - describe('getCurrentPagination', () => { it('should retrieve the current pagination info from the routerService', () => { service.getCurrentPagination('test-id', defaultPagination).subscribe((currentPagination) => { @@ -56,6 +55,26 @@ describe('PaginationService', () => { expect(currentSort).toEqual(Object.assign(new SortOptions('score', SortDirection.ASC ))); }); }); + it('should return default sort when no sort specified', () => { + // This is same as routeService (defined above), but returns no sort field or direction + routeService = { + getQueryParameterValue: (param) => { + let value; + if (param.endsWith('.page')) { + value = 5; + } + if (param.endsWith('.rpp')) { + value = 10; + } + return observableOf(value); + } + }; + service = new PaginationService(routeService, router); + + service.getCurrentSort('test-id', defaultSort).subscribe((currentSort) => { + expect(currentSort).toEqual(defaultSort); + }); + }); }); describe('getFindListOptions', () => { it('should retrieve the current findListOptions info from the routerService', () => { diff --git a/src/app/core/pagination/pagination.service.ts b/src/app/core/pagination/pagination.service.ts index a6f8052c4b..40e13d654f 100644 --- a/src/app/core/pagination/pagination.service.ts +++ b/src/app/core/pagination/pagination.service.ts @@ -24,7 +24,11 @@ import { isNumeric } from '../../shared/numeric.util'; */ export class PaginationService { - private defaultSortOptions = new SortOptions('id', SortDirection.ASC); + /** + * Sort on title ASC by default + * @type {SortOptions} + */ + private defaultSortOptions = new SortOptions('dc.title', SortDirection.ASC); private clearParams = {}; diff --git a/src/app/core/profile/researcher-profile.service.spec.ts b/src/app/core/profile/researcher-profile.service.spec.ts index eb5ff776fe..899867ec8e 100644 --- a/src/app/core/profile/researcher-profile.service.spec.ts +++ b/src/app/core/profile/researcher-profile.service.spec.ts @@ -26,6 +26,8 @@ import { ReplaceOperation } from 'fast-json-patch'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { PostRequest } from '../data/request.models'; import { followLink } from '../../shared/utils/follow-link-config.model'; +import { ConfigurationProperty } from '../shared/configuration-property.model'; +import { createPaginatedList } from '../../shared/testing/utils.test'; describe('ResearcherProfileService', () => { let scheduler: TestScheduler; @@ -36,6 +38,7 @@ describe('ResearcherProfileService', () => { let objectCache: ObjectCacheService; let halService: HALEndpointService; let responseCacheEntry: RequestEntry; + let routerStub: any; const researcherProfileId = 'beef9946-rt56-479e-8f11-b90cbe9f7241'; const itemId = 'beef9946-rt56-479e-8f11-b90cbe9f7241'; @@ -86,6 +89,113 @@ describe('ResearcherProfileService', () => { }, } }); + + const mockItemUnlinkedToOrcid: Item = Object.assign(new Item(), { + id: 'mockItemUnlinkedToOrcid', + bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), + metadata: { + 'dc.title': [{ + value: 'test person' + }], + 'dspace.entity.type': [{ + 'value': 'Person' + }], + 'dspace.object.owner': [{ + 'value': 'test person', + 'language': null, + 'authority': 'researcher-profile-id', + 'confidence': 600, + 'place': 0 + }], + } + }); + + const mockItemLinkedToOrcid: Item = Object.assign(new Item(), { + bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), + metadata: { + 'dc.title': [{ + value: 'test person' + }], + 'dspace.entity.type': [{ + 'value': 'Person' + }], + 'dspace.object.owner': [{ + 'value': 'test person', + 'language': null, + 'authority': 'researcher-profile-id', + 'confidence': 600, + 'place': 0 + }], + 'dspace.orcid.authenticated': [{ + 'value': '2022-06-10T15:15:12.952872', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + }], + 'dspace.orcid.scope': [{ + 'value': '/authenticate', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + }, { + 'value': '/read-limited', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 1 + }, { + 'value': '/activities/update', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 2 + }, { + 'value': '/person/update', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 3 + }], + 'person.identifier.orcid': [{ + 'value': 'orcid-id', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + }] + } + }); + + const disconnectionAllowAdmin = { + uuid: 'orcid.disconnection.allowed-users', + name: 'orcid.disconnection.allowed-users', + values: ['only_admin'] + } as ConfigurationProperty; + + const disconnectionAllowAdminOwner = { + uuid: 'orcid.disconnection.allowed-users', + name: 'orcid.disconnection.allowed-users', + values: ['admin_and_owner'] + } as ConfigurationProperty; + + const authorizeUrl = { + uuid: 'orcid.authorize-url', + name: 'orcid.authorize-url', + values: ['orcid.authorize-url'] + } as ConfigurationProperty; + const appClientId = { + uuid: 'orcid.application-client-id', + name: 'orcid.application-client-id', + values: ['orcid.application-client-id'] + } as ConfigurationProperty; + const orcidScope = { + uuid: 'orcid.scope', + name: 'orcid.scope', + values: ['/authenticate', '/read-limited'] + } as ConfigurationProperty; + const endpointURL = `https://rest.api/rest/api/profiles`; const endpointURLWithEmbed = 'https://rest.api/rest/api/profiles?embed=item'; const sourceUri = `https://rest.api/rest/api/external-source/profile`; @@ -132,7 +242,7 @@ describe('ResearcherProfileService', () => { const notificationsService = {} as NotificationsService; const http = {} as HttpClient; const comparator = {} as any; - const routerStub: any = new RouterMock(); + routerStub = new RouterMock(); const itemService = jasmine.createSpyObj('ItemService', { findByHref: jasmine.createSpy('findByHref') }); @@ -271,7 +381,7 @@ describe('ResearcherProfileService', () => { }); describe('createFromExternalSource', () => { - let patchSpy; + beforeEach(() => { spyOn((service as any).dataService, 'patch').and.returnValue(createSuccessfulRemoteDataObject$(researcherProfilePatched)); spyOn((service as any), 'findById').and.returnValue(createSuccessfulRemoteDataObject$(researcherProfilePatched)); @@ -293,4 +403,17 @@ describe('ResearcherProfileService', () => { }); }); + describe('updateByOrcidOperations', () => { + beforeEach(() => { + scheduler = getTestScheduler(); + spyOn((service as any).dataService, 'patch').and.returnValue(createSuccessfulRemoteDataObject$(researcherProfilePatched)); + }); + + it('should call patch method properly', () => { + scheduler.schedule(() => service.updateByOrcidOperations(researcherProfile, []).subscribe()); + scheduler.flush(); + + expect((service as any).dataService.patch).toHaveBeenCalledWith(researcherProfile, []); + }); + }); }); diff --git a/src/app/core/profile/researcher-profile.service.ts b/src/app/core/profile/researcher-profile.service.ts index 006b0b554b..882845d133 100644 --- a/src/app/core/profile/researcher-profile.service.ts +++ b/src/app/core/profile/researcher-profile.service.ts @@ -4,10 +4,9 @@ import { Injectable } from '@angular/core'; import { Router } from '@angular/router'; import { Store } from '@ngrx/store'; -import { ReplaceOperation } from 'fast-json-patch'; +import { Operation, ReplaceOperation } from 'fast-json-patch'; import { Observable } from 'rxjs'; import { find, map } from 'rxjs/operators'; - import { NotificationsService } from '../../shared/notifications/notifications.service'; import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; @@ -24,10 +23,11 @@ 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'; +import { hasValue, isEmpty } from '../../shared/empty.util'; import { CoreState } from '../core-state.model'; import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { Item } from '../shared/item.model'; +import { createFailedRemoteDataObject$ } from '../../shared/remote-data.utils'; /** * A private DataService implementation to delegate specific methods to. @@ -56,9 +56,9 @@ class ResearcherProfileServiceImpl extends DataService { @dataService(RESEARCHER_PROFILE) export class ResearcherProfileService { - dataService: ResearcherProfileServiceImpl; + protected dataService: ResearcherProfileServiceImpl; - responseMsToLive: number = 10 * 1000; + protected responseMsToLive: number = 10 * 1000; constructor( protected requestService: RequestService, @@ -112,6 +112,20 @@ export class ResearcherProfileService { ); } + /** + * Find a researcher profile by its own related item + * + * @param item + */ + public findByRelatedItem(item: Item): Observable> { + const profileId = item.firstMetadata('dspace.object.owner')?.authority; + if (isEmpty(profileId)) { + return createFailedRemoteDataObject$(); + } else { + return this.findById(profileId); + } + } + /** * Find the item id related to the given researcher profile. * @@ -164,4 +178,17 @@ export class ResearcherProfileService { return this.rdbService.buildFromRequestUUID(requestId, followLink('item')); } + + /** + * Update researcher profile by patch orcid operation + * + * @param researcherProfile + * @param operations + */ + public updateByOrcidOperations(researcherProfile: ResearcherProfile, operations: Operation[]): Observable> { + return this.dataService.patch(researcherProfile, operations); + } + + + } diff --git a/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html b/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html index 6e73935672..dbd9d03994 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html +++ b/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html @@ -3,6 +3,9 @@ {{'journalissue.page.titleprefix' | translate}}
+
diff --git a/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.ts b/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.ts index f96379dafd..f5e9dc9b2b 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.ts +++ b/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; -import { ItemComponent } from '../../../../item-page/simple/item-types/shared/item.component'; import { ViewMode } from '../../../../core/shared/view-mode.model'; import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; +import { VersionedItemComponent } from '../../../../item-page/simple/item-types/versioned-item/versioned-item.component'; @listableObjectComponent('JournalIssue', ViewMode.StandalonePage) @Component({ @@ -12,5 +12,5 @@ import { listableObjectComponent } from '../../../../shared/object-collection/sh /** * The component for displaying metadata and relations of an item of the type Journal Issue */ -export class JournalIssueComponent extends ItemComponent { +export class JournalIssueComponent extends VersionedItemComponent { } diff --git a/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html b/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html index 5d4d8d06ce..8b19c37033 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html +++ b/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html @@ -3,6 +3,9 @@ {{'journalvolume.page.titleprefix' | translate}}
+
diff --git a/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.ts b/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.ts index eeb93e7070..cc09be7959 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.ts +++ b/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; -import { ItemComponent } from '../../../../item-page/simple/item-types/shared/item.component'; import { ViewMode } from '../../../../core/shared/view-mode.model'; import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; +import { VersionedItemComponent } from '../../../../item-page/simple/item-types/versioned-item/versioned-item.component'; @listableObjectComponent('JournalVolume', ViewMode.StandalonePage) @Component({ @@ -12,5 +12,5 @@ import { listableObjectComponent } from '../../../../shared/object-collection/sh /** * The component for displaying metadata and relations of an item of the type Journal Volume */ -export class JournalVolumeComponent extends ItemComponent { +export class JournalVolumeComponent extends VersionedItemComponent { } diff --git a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.html b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.html index d51c55e5d6..45cbc1f839 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.html +++ b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.html @@ -3,6 +3,9 @@ {{'journal.page.titleprefix' | translate}}
+
@@ -44,7 +47,8 @@ diff --git a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.spec.ts b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.spec.ts index 0e756b7dc9..3ed73e7891 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.spec.ts +++ b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.spec.ts @@ -29,6 +29,11 @@ import { TruncatableService } from '../../../../shared/truncatable/truncatable.s import { TruncatePipe } from '../../../../shared/utils/truncate.pipe'; import { JournalComponent } from './journal.component'; import { RouteService } from '../../../../core/services/route.service'; +import { RouterTestingModule } from '@angular/router/testing'; +import { VersionHistoryDataService } from '../../../../core/data/version-history-data.service'; +import { VersionDataService } from '../../../../core/data/version-data.service'; +import { WorkspaceitemDataService } from '../../../../core/submission/workspaceitem-data.service'; +import { SearchService } from '../../../../core/shared/search/search.service'; let comp: JournalComponent; let fixture: ComponentFixture; @@ -65,12 +70,15 @@ describe('JournalComponent', () => { }; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot({ - loader: { - provide: TranslateLoader, - useClass: TranslateLoaderMock - } - })], + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), + RouterTestingModule, + ], declarations: [JournalComponent, GenericItemPageFieldComponent, TruncatePipe], providers: [ { provide: ItemDataService, useValue: {} }, @@ -86,7 +94,11 @@ describe('JournalComponent', () => { { provide: DSOChangeAnalyzer, useValue: {} }, { provide: NotificationsService, useValue: {} }, { provide: DefaultChangeAnalyzer, useValue: {} }, + { provide: VersionHistoryDataService, useValue: {} }, + { provide: VersionDataService, useValue: {} }, { provide: BitstreamDataService, useValue: mockBitstreamDataService }, + { provide: WorkspaceitemDataService, useValue: {} }, + { provide: SearchService, useValue: {} }, { provide: RouteService, useValue: {} } ], diff --git a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.ts b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.ts index 3fe0903145..acfd31d8f6 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.ts +++ b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; -import { ItemComponent } from '../../../../item-page/simple/item-types/shared/item.component'; import { ViewMode } from '../../../../core/shared/view-mode.model'; import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; +import { VersionedItemComponent } from '../../../../item-page/simple/item-types/versioned-item/versioned-item.component'; @listableObjectComponent('Journal', ViewMode.StandalonePage) @Component({ @@ -12,5 +12,5 @@ import { listableObjectComponent } from '../../../../shared/object-collection/sh /** * The component for displaying metadata and relations of an item of the type Journal */ -export class JournalComponent extends ItemComponent { +export class JournalComponent extends VersionedItemComponent { } diff --git a/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/org-unit/org-unit-search-result-list-element.component.html b/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/org-unit/org-unit-search-result-list-element.component.html index fb0ad21b6e..40f837bcd1 100644 --- a/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/org-unit/org-unit-search-result-list-element.component.html +++ b/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/org-unit/org-unit-search-result-list-element.component.html @@ -7,13 +7,11 @@ class="lead" [innerHTML]="firstMetadataValue('organization.legalName')"> - - - - - - + + + + diff --git a/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html b/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html index 4315d2a91c..ff4bc4d226 100644 --- a/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html +++ b/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html @@ -3,6 +3,9 @@ {{'orgunit.page.titleprefix' | translate}}
+
@@ -54,12 +57,12 @@ [relationTypes]="[{ label: 'isOrgUnitOfPerson', filter: 'isOrgUnitOfPerson', - configuration: 'person' + configuration: 'person-relationships' }, { label: 'isOrgUnitOfProject', filter: 'isOrgUnitOfProject', - configuration: 'project' + configuration: 'project-relationships' }]"> diff --git a/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.ts b/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.ts index ab756db562..cbf8497f35 100644 --- a/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.ts +++ b/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; -import { ItemComponent } from '../../../../item-page/simple/item-types/shared/item.component'; import { ViewMode } from '../../../../core/shared/view-mode.model'; import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; +import { VersionedItemComponent } from '../../../../item-page/simple/item-types/versioned-item/versioned-item.component'; @listableObjectComponent('OrgUnit', ViewMode.StandalonePage) @Component({ @@ -12,5 +12,5 @@ import { listableObjectComponent } from '../../../../shared/object-collection/sh /** * The component for displaying metadata and relations of an item of the type Organisation Unit */ -export class OrgUnitComponent extends ItemComponent { +export class OrgUnitComponent extends VersionedItemComponent { } diff --git a/src/app/entity-groups/research-entities/item-pages/person/person.component.html b/src/app/entity-groups/research-entities/item-pages/person/person.component.html index 96cfdbfacd..ace42f844e 100644 --- a/src/app/entity-groups/research-entities/item-pages/person/person.component.html +++ b/src/app/entity-groups/research-entities/item-pages/person/person.component.html @@ -3,6 +3,10 @@ {{'person.page.titleprefix' | translate}}
+ +
@@ -62,7 +66,8 @@ diff --git a/src/app/entity-groups/research-entities/item-pages/person/person.component.ts b/src/app/entity-groups/research-entities/item-pages/person/person.component.ts index 27fdd2ab15..8fde5ee69a 100644 --- a/src/app/entity-groups/research-entities/item-pages/person/person.component.ts +++ b/src/app/entity-groups/research-entities/item-pages/person/person.component.ts @@ -1,9 +1,7 @@ import { Component } from '@angular/core'; -import { ItemComponent } from '../../../../item-page/simple/item-types/shared/item.component'; import { ViewMode } from '../../../../core/shared/view-mode.model'; -import { - listableObjectComponent -} from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; +import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; +import { VersionedItemComponent } from '../../../../item-page/simple/item-types/versioned-item/versioned-item.component'; import { MetadataValue } from '../../../../core/shared/metadata.models'; @listableObjectComponent('Person', ViewMode.StandalonePage) @@ -15,7 +13,7 @@ import { MetadataValue } from '../../../../core/shared/metadata.models'; /** * The component for displaying metadata and relations of an item of the type Person */ -export class PersonComponent extends ItemComponent { +export class PersonComponent extends VersionedItemComponent { /** * Returns the metadata values to be used for the page title. @@ -36,4 +34,5 @@ export class PersonComponent extends ItemComponent { } return metadataValues; } + } diff --git a/src/app/entity-groups/research-entities/item-pages/project/project.component.html b/src/app/entity-groups/research-entities/item-pages/project/project.component.html index 7960631f3d..a068878fb4 100644 --- a/src/app/entity-groups/research-entities/item-pages/project/project.component.html +++ b/src/app/entity-groups/research-entities/item-pages/project/project.component.html @@ -3,6 +3,9 @@ {{'project.page.titleprefix' | translate}}
+
diff --git a/src/app/entity-groups/research-entities/item-pages/project/project.component.ts b/src/app/entity-groups/research-entities/item-pages/project/project.component.ts index e53d8afd69..066427fc0d 100644 --- a/src/app/entity-groups/research-entities/item-pages/project/project.component.ts +++ b/src/app/entity-groups/research-entities/item-pages/project/project.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; -import { ItemComponent } from '../../../../item-page/simple/item-types/shared/item.component'; import { ViewMode } from '../../../../core/shared/view-mode.model'; import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; +import { VersionedItemComponent } from '../../../../item-page/simple/item-types/versioned-item/versioned-item.component'; @listableObjectComponent('Project', ViewMode.StandalonePage) @Component({ @@ -12,5 +12,5 @@ import { listableObjectComponent } from '../../../../shared/object-collection/sh /** * The component for displaying metadata and relations of an item of the type Project */ -export class ProjectComponent extends ItemComponent { +export class ProjectComponent extends VersionedItemComponent { } diff --git a/src/app/item-page/edit-item-page/edit-item-page.module.ts b/src/app/item-page/edit-item-page/edit-item-page.module.ts index 97901bd7c8..cf4a3de74b 100644 --- a/src/app/item-page/edit-item-page/edit-item-page.module.ts +++ b/src/app/item-page/edit-item-page/edit-item-page.module.ts @@ -1,7 +1,7 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; +import { NgbTooltipModule, NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { SharedModule } from '../../shared/shared.module'; import { EditItemPageRoutingModule } from './edit-item-page.routing.module'; @@ -48,7 +48,8 @@ import { ResourcePoliciesModule } from '../../shared/resource-policies/resource- EditItemPageRoutingModule, SearchPageModule, DragDropModule, - ResourcePoliciesModule + ResourcePoliciesModule, + NgbModule ], declarations: [ EditItemPageComponent, diff --git a/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.html b/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.html index 71aa7b44de..5437525185 100644 --- a/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.html +++ b/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.html @@ -1,13 +1,33 @@
- - - - - + + + + + + +
+
+ +
+
+ + + +
+ +
+
+
+
+ +
- diff --git a/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.scss b/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.scss new file mode 100644 index 0000000000..c3694e6784 --- /dev/null +++ b/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.scss @@ -0,0 +1,4 @@ +.auth-bitstream-container { + margin-top: -1em; + margin-bottom: 1.5em; +} diff --git a/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.spec.ts b/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.spec.ts index 97280c3ea0..2fe8a562c6 100644 --- a/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.spec.ts +++ b/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.spec.ts @@ -1,11 +1,12 @@ +import { Observable } from 'rxjs/internal/Observable'; import { waitForAsync, ComponentFixture, inject, TestBed } from '@angular/core/testing'; import { Component, NO_ERRORS_SCHEMA } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { of as observableOf } from 'rxjs'; +import { of as observableOf, of } from 'rxjs'; import { TranslateModule } from '@ngx-translate/core'; import { cold } from 'jasmine-marbles'; -import { ItemAuthorizationsComponent } from './item-authorizations.component'; +import { ItemAuthorizationsComponent, BitstreamMapValue } from './item-authorizations.component'; import { Bitstream } from '../../../core/shared/bitstream.model'; import { Bundle } from '../../../core/shared/bundle.model'; import { Item } from '../../../core/shared/item.model'; @@ -57,8 +58,6 @@ describe('ItemAuthorizationsComponent test suite', () => { bitstreams: createSuccessfulRemoteDataObject$(createPaginatedList([bitstream3, bitstream4])) }); const bundles = [bundle1, bundle2]; - const bitstreamList1: PaginatedList = buildPaginatedList(new PageInfo(), [bitstream1, bitstream2]); - const bitstreamList2: PaginatedList = buildPaginatedList(new PageInfo(), [bitstream3, bitstream4]); const item = Object.assign(new Item(), { uuid: 'item', @@ -142,13 +141,12 @@ describe('ItemAuthorizationsComponent test suite', () => { expect(compAsAny.bundleBitstreamsMap.has('bundle1')).toBeTruthy(); expect(compAsAny.bundleBitstreamsMap.has('bundle2')).toBeTruthy(); let bitstreamList = compAsAny.bundleBitstreamsMap.get('bundle1'); - expect(bitstreamList).toBeObservable(cold('(a|)', { - a: bitstreamList1 + expect(bitstreamList.bitstreams).toBeObservable(cold('(a|)', { + a : [bitstream1, bitstream2] })); - bitstreamList = compAsAny.bundleBitstreamsMap.get('bundle2'); - expect(bitstreamList).toBeObservable(cold('(a|)', { - a: bitstreamList2 + expect(bitstreamList.bitstreams).toBeObservable(cold('(a|)', { + a: [bitstream3, bitstream4] })); }); diff --git a/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.ts b/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.ts index 76597a135b..8ed2f9a12e 100644 --- a/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.ts +++ b/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.ts @@ -1,3 +1,5 @@ +import { isEqual } from 'lodash'; +import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; import { Component, OnDestroy, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; @@ -6,7 +8,8 @@ import { catchError, filter, first, map, mergeMap, take } from 'rxjs/operators'; import { buildPaginatedList, PaginatedList } from '../../../core/data/paginated-list.model'; import { - getFirstSucceededRemoteDataPayload, getFirstSucceededRemoteDataWithNotEmptyPayload, + getFirstSucceededRemoteDataPayload, + getFirstSucceededRemoteDataWithNotEmptyPayload, } from '../../../core/shared/operators'; import { Item } from '../../../core/shared/item.model'; import { followLink } from '../../../shared/utils/follow-link-config.model'; @@ -25,7 +28,8 @@ interface BundleBitstreamsMapEntry { @Component({ selector: 'ds-item-authorizations', - templateUrl: './item-authorizations.component.html' + templateUrl: './item-authorizations.component.html', + styleUrls:['./item-authorizations.component.scss'] }) /** * Component that handles the item Authorizations @@ -36,13 +40,13 @@ export class ItemAuthorizationsComponent implements OnInit, OnDestroy { * A map that contains all bitstream of the item's bundles * @type {Observable>>>} */ - public bundleBitstreamsMap: Map>> = new Map>>(); + public bundleBitstreamsMap: Map = new Map(); /** - * The list of bundle for the item + * The list of all bundles for the item * @type {Observable>} */ - private bundles$: BehaviorSubject = new BehaviorSubject([]); + bundles$: BehaviorSubject = new BehaviorSubject([]); /** * The target editing item @@ -56,15 +60,48 @@ export class ItemAuthorizationsComponent implements OnInit, OnDestroy { */ private subs: Subscription[] = []; + /** + * The size of the bundles to be loaded on demand + * @type {number} + */ + bundlesPerPage = 6; + + /** + * The number of current page + * @type {number} + */ + bundlesPageSize = 1; + + /** + * The flag to show or not the 'Load more' button + * based on the condition if all the bundles are loaded or not + * @type {boolean} + */ + allBundlesLoaded = false; + + /** + * Initial size of loaded bitstreams + * The size of incrementation used in bitstream pagination + */ + bitstreamSize = 4; + + /** + * The size of the loaded bitstremas at a certain moment + * @private + */ + private bitstreamPageSize = 4; + /** * Initialize instance variables * * @param {LinkService} linkService * @param {ActivatedRoute} route + * @param nameService */ constructor( private linkService: LinkService, - private route: ActivatedRoute + private route: ActivatedRoute, + private nameService: DSONameService ) { } @@ -72,16 +109,53 @@ export class ItemAuthorizationsComponent implements OnInit, OnDestroy { * Initialize the component, setting up the bundle and bitstream within the item */ ngOnInit(): void { + this.getBundlesPerItem(); + } + + /** + * Return the item's UUID + */ + getItemUUID(): Observable { + return this.item$.pipe( + map((item: Item) => item.id), + first((UUID: string) => isNotEmpty(UUID)) + ); + } + + /** + * Return the item's name + */ + getItemName(): Observable { + return this.item$.pipe( + map((item: Item) => this.nameService.getName(item)) + ); + } + + /** + * Return all item's bundles + * + * @return an observable that emits all item's bundles + */ + getItemBundles(): Observable { + return this.bundles$.asObservable(); + } + + /** + * Get all bundles per item + * and all the bitstreams per bundle + * @param page number of current page + */ + getBundlesPerItem(page: number = 1) { this.item$ = this.route.data.pipe( map((data) => data.dso), getFirstSucceededRemoteDataWithNotEmptyPayload(), map((item: Item) => this.linkService.resolveLink( item, - followLink('bundles', {}, followLink('bitstreams')) + followLink('bundles', {findListOptions: {currentPage : page, elementsPerPage: this.bundlesPerPage}}, followLink('bitstreams')) )) ) as Observable; - const bundles$: Observable> = this.item$.pipe( + const bundles$: Observable> = this.item$.pipe( filter((item: Item) => isNotEmpty(item.bundles)), mergeMap((item: Item) => item.bundles), getFirstSucceededRemoteDataWithNotEmptyPayload(), @@ -96,37 +170,36 @@ export class ItemAuthorizationsComponent implements OnInit, OnDestroy { take(1), map((list: PaginatedList) => list.page) ).subscribe((bundles: Bundle[]) => { - this.bundles$.next(bundles); + if (isEqual(bundles.length,0) || bundles.length < this.bundlesPerPage) { + this.allBundlesLoaded = true; + } + if (isEqual(page, 1)) { + this.bundles$.next(bundles); + } else { + this.bundles$.next(this.bundles$.getValue().concat(bundles)); + } }), bundles$.pipe( take(1), mergeMap((list: PaginatedList) => list.page), map((bundle: Bundle) => ({ id: bundle.id, bitstreams: this.getBundleBitstreams(bundle) })) ).subscribe((entry: BundleBitstreamsMapEntry) => { - this.bundleBitstreamsMap.set(entry.id, entry.bitstreams); + let bitstreamMapValues: BitstreamMapValue = { + isCollapsed: true, + allBitstreamsLoaded: false, + bitstreams: null + }; + bitstreamMapValues.bitstreams = entry.bitstreams.pipe( + map((b: PaginatedList) => { + bitstreamMapValues.allBitstreamsLoaded = b?.page.length < this.bitstreamSize; + return [...b.page.slice(0, this.bitstreamSize)]; + }) + ); + this.bundleBitstreamsMap.set(entry.id, bitstreamMapValues); }) ); } - /** - * Return the item's UUID - */ - getItemUUID(): Observable { - return this.item$.pipe( - map((item: Item) => item.id), - first((UUID: string) => isNotEmpty(UUID)) - ); - } - - /** - * Return all item's bundles - * - * @return an observable that emits all item's bundles - */ - getItemBundles(): Observable { - return this.bundles$.asObservable(); - } - /** * Return all bundle's bitstreams * @@ -142,6 +215,46 @@ export class ItemAuthorizationsComponent implements OnInit, OnDestroy { ); } + /** + * Changes the collapsible state of the area that contains the bitstream list + * @param bundleId Id of bundle responsible for the requested bitstreams + */ + collapseArea(bundleId: string) { + this.bundleBitstreamsMap.get(bundleId).isCollapsed = !this.bundleBitstreamsMap.get(bundleId).isCollapsed; + } + + /** + * Loads as much bundles as initial value of bundleSize to be displayed + */ + onBundleLoad(){ + this.bundlesPageSize ++; + this.getBundlesPerItem(this.bundlesPageSize); + } + + /** + * Calculates the bitstreams that are going to be loaded on demand, + * based on the number configured on this.bitstreamSize. + * @param bundle parent of bitstreams that are requested to be shown + * @returns Subscription + */ + onBitstreamsLoad(bundle: Bundle) { + return this.getBundleBitstreams(bundle).subscribe((res: PaginatedList) => { + let nextBitstreams = res?.page.slice(this.bitstreamPageSize, this.bitstreamPageSize + this.bitstreamSize); + let bitstreamsToShow = this.bundleBitstreamsMap.get(bundle.id).bitstreams.pipe( + map((existingBits: Bitstream[])=> { + return [... existingBits, ...nextBitstreams]; + }) + ); + this.bitstreamPageSize = this.bitstreamPageSize + this.bitstreamSize; + let bitstreamMapValues: BitstreamMapValue = { + bitstreams: bitstreamsToShow , + isCollapsed: this.bundleBitstreamsMap.get(bundle.id).isCollapsed, + allBitstreamsLoaded: res?.page.length <= this.bitstreamPageSize + }; + this.bundleBitstreamsMap.set(bundle.id, bitstreamMapValues); + }); + } + /** * Unsubscribe from all subscriptions */ @@ -151,3 +264,9 @@ export class ItemAuthorizationsComponent implements OnInit, OnDestroy { .forEach((subscription) => subscription.unsubscribe()); } } + +export interface BitstreamMapValue { + bitstreams: Observable; + isCollapsed: boolean; + allBitstreamsLoaded: boolean; +} diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss index 46d172dadc..7de575b785 100644 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss +++ b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss @@ -50,3 +50,7 @@ cursor: grabbing; } } + +:host ::ng-deep .larger-tooltip .tooltip-inner { + max-width: 500px; +} diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.html b/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.html index 5d1edb847f..0f0fad2199 100644 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.html +++ b/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.html @@ -9,9 +9,10 @@
- - {{ bitstream?.firstMetadataValue('dc.description') }} - +
+ {{ bitstream?.firstMetadataValue('dc.description') }} +
diff --git a/src/app/item-page/item-page-routing-paths.ts b/src/app/item-page/item-page-routing-paths.ts index 74ad0aae07..90a4a54b1e 100644 --- a/src/app/item-page/item-page-routing-paths.ts +++ b/src/app/item-page/item-page-routing-paths.ts @@ -50,3 +50,4 @@ export const ITEM_EDIT_PATH = 'edit'; export const ITEM_EDIT_VERSIONHISTORY_PATH = 'versionhistory'; export const ITEM_VERSION_PATH = 'version'; export const UPLOAD_BITSTREAM_PATH = 'bitstreams/new'; +export const ORCID_PATH = 'orcid'; diff --git a/src/app/item-page/item-page-routing.module.ts b/src/app/item-page/item-page-routing.module.ts index 59dafd4d99..add2c3d768 100644 --- a/src/app/item-page/item-page-routing.module.ts +++ b/src/app/item-page/item-page-routing.module.ts @@ -7,15 +7,19 @@ import { VersionResolver } from './version-page/version.resolver'; import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service'; import { LinkService } from '../core/cache/builders/link.service'; import { UploadBitstreamComponent } from './bitstreams/upload/upload-bitstream.component'; -import { ITEM_EDIT_PATH, UPLOAD_BITSTREAM_PATH } from './item-page-routing-paths'; +import { ITEM_EDIT_PATH, ORCID_PATH, UPLOAD_BITSTREAM_PATH } from './item-page-routing-paths'; import { ItemPageAdministratorGuard } from './item-page-administrator.guard'; import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model'; import { ThemedItemPageComponent } from './simple/themed-item-page.component'; import { ThemedFullItemPageComponent } from './full/themed-full-item-page.component'; import { MenuItemType } from '../shared/menu/menu-item-type.model'; import { VersionPageComponent } from './version-page/version-page/version-page.component'; -import { BitstreamRequestACopyPageComponent } from '../shared/bitstream-request-a-copy-page/bitstream-request-a-copy-page.component'; +import { + BitstreamRequestACopyPageComponent +} from '../shared/bitstream-request-a-copy-page/bitstream-request-a-copy-page.component'; import { REQUEST_COPY_MODULE_PATH } from '../app-routing-paths'; +import { OrcidPageComponent } from './orcid-page/orcid-page.component'; +import { OrcidPageGuard } from './orcid-page/orcid-page.guard'; @NgModule({ imports: [ @@ -50,6 +54,11 @@ import { REQUEST_COPY_MODULE_PATH } from '../app-routing-paths'; { path: REQUEST_COPY_MODULE_PATH, component: BitstreamRequestACopyPageComponent, + }, + { + path: ORCID_PATH, + component: OrcidPageComponent, + canActivate: [AuthenticatedGuard, OrcidPageGuard] } ], data: { @@ -88,6 +97,7 @@ import { REQUEST_COPY_MODULE_PATH } from '../app-routing-paths'; LinkService, ItemPageAdministratorGuard, VersionResolver, + OrcidPageGuard ] }) diff --git a/src/app/item-page/item-page.module.ts b/src/app/item-page/item-page.module.ts index 80cb1f61a2..cbb9f3299e 100644 --- a/src/app/item-page/item-page.module.ts +++ b/src/app/item-page/item-page.module.ts @@ -6,11 +6,19 @@ import { SharedModule } from '../shared/shared.module'; import { ItemPageComponent } from './simple/item-page.component'; import { ItemPageRoutingModule } from './item-page-routing.module'; import { MetadataUriValuesComponent } from './field-components/metadata-uri-values/metadata-uri-values.component'; -import { ItemPageAuthorFieldComponent } from './simple/field-components/specific-field/author/item-page-author-field.component'; -import { ItemPageDateFieldComponent } from './simple/field-components/specific-field/date/item-page-date-field.component'; -import { ItemPageAbstractFieldComponent } from './simple/field-components/specific-field/abstract/item-page-abstract-field.component'; +import { + ItemPageAuthorFieldComponent +} from './simple/field-components/specific-field/author/item-page-author-field.component'; +import { + ItemPageDateFieldComponent +} from './simple/field-components/specific-field/date/item-page-date-field.component'; +import { + ItemPageAbstractFieldComponent +} from './simple/field-components/specific-field/abstract/item-page-abstract-field.component'; import { ItemPageUriFieldComponent } from './simple/field-components/specific-field/uri/item-page-uri-field.component'; -import { ItemPageTitleFieldComponent } from './simple/field-components/specific-field/title/item-page-title-field.component'; +import { + ItemPageTitleFieldComponent +} from './simple/field-components/specific-field/title/item-page-title-field.component'; import { ItemPageFieldComponent } from './simple/field-components/specific-field/item-page-field.component'; import { CollectionsComponent } from './field-components/collections/collections.component'; import { FullItemPageComponent } from './full/full-item-page.component'; @@ -20,7 +28,9 @@ import { ItemComponent } from './simple/item-types/shared/item.component'; import { EditItemPageModule } from './edit-item-page/edit-item-page.module'; import { UploadBitstreamComponent } from './bitstreams/upload/upload-bitstream.component'; import { StatisticsModule } from '../statistics/statistics.module'; -import { AbstractIncrementalListComponent } from './simple/abstract-incremental-list/abstract-incremental-list.component'; +import { + AbstractIncrementalListComponent +} from './simple/abstract-incremental-list/abstract-incremental-list.component'; import { UntypedItemComponent } from './simple/item-types/untyped-item/untyped-item.component'; import { JournalEntitiesModule } from '../entity-groups/journal-entities/journal-entities.module'; import { ResearchEntitiesModule } from '../entity-groups/research-entities/research-entities.module'; @@ -34,6 +44,11 @@ import { MiradorViewerComponent } from './mirador-viewer/mirador-viewer.componen import { VersionPageComponent } from './version-page/version-page/version-page.component'; import { VersionedItemComponent } from './simple/item-types/versioned-item/versioned-item.component'; import { ThemedFileSectionComponent } from './simple/field-components/file-section/themed-file-section.component'; +import { OrcidAuthComponent } from './orcid-page/orcid-auth/orcid-auth.component'; +import { OrcidPageComponent } from './orcid-page/orcid-page.component'; +import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'; +import { OrcidSyncSettingsComponent } from './orcid-page/orcid-sync-settings/orcid-sync-settings.component'; +import { OrcidQueueComponent } from './orcid-page/orcid-queue/orcid-queue.component'; const ENTRY_COMPONENTS = [ @@ -67,6 +82,10 @@ const DECLARATIONS = [ MediaViewerImageComponent, MiradorViewerComponent, VersionPageComponent, + OrcidPageComponent, + OrcidAuthComponent, + OrcidSyncSettingsComponent, + OrcidQueueComponent ]; @NgModule({ @@ -79,6 +98,7 @@ const DECLARATIONS = [ JournalEntitiesModule.withEntryComponents(), ResearchEntitiesModule.withEntryComponents(), NgxGalleryModule, + NgbAccordionModule ], declarations: [ ...DECLARATIONS, diff --git a/src/app/item-page/orcid-page/orcid-auth/orcid-auth.component.html b/src/app/item-page/orcid-page/orcid-auth/orcid-auth.component.html new file mode 100644 index 0000000000..e57ce33008 --- /dev/null +++ b/src/app/item-page/orcid-page/orcid-auth/orcid-auth.component.html @@ -0,0 +1,84 @@ +
+

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

+ +
+ + +
+
+
+
+
{{ 'person.page.orcid.granted-authorizations'| translate }}
+
+
+
    +
  • + {{getAuthorizationDescription(auth) | translate}} +
  • +
+
+
+
+
+
+
+
{{ 'person.page.orcid.missing-authorizations'| translate }}
+
+
+ + {{'person.page.orcid.no-missing-authorizations-message' | translate}} + + + {{'person.page.orcid.missing-authorizations-message' | translate}} +
    +
  • + {{getAuthorizationDescription(auth) | translate }} +
  • +
+
+
+
+
+
+
+ + {{ 'person.page.orcid.remove-orcid-message' | translate}} + +
+
+ + +
+
+
+
+ + +
+
+
orcid-logo
+
+ {{ getOrcidNotLinkedMessage() | async }} +
+
+
+
+ +
+
+
+
+ diff --git a/src/app/item-page/orcid-page/orcid-auth/orcid-auth.component.scss b/src/app/item-page/orcid-page/orcid-auth/orcid-auth.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/item-page/orcid-page/orcid-auth/orcid-auth.component.spec.ts b/src/app/item-page/orcid-page/orcid-auth/orcid-auth.component.spec.ts new file mode 100644 index 0000000000..e96e5996fb --- /dev/null +++ b/src/app/item-page/orcid-page/orcid-auth/orcid-auth.component.spec.ts @@ -0,0 +1,336 @@ +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, fakeAsync, TestBed, waitForAsync } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { By } from '@angular/platform-browser'; + +import { getTestScheduler } from 'jasmine-marbles'; +import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'; +import { of } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; + +import { OrcidAuthService } from '../../../core/orcid/orcid-auth.service'; +import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { Item } from '../../../core/shared/item.model'; +import { createPaginatedList } from '../../../shared/testing/utils.test'; +import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock'; +import { OrcidAuthComponent } from './orcid-auth.component'; +import { NativeWindowService } from '../../../core/services/window.service'; +import { NativeWindowMockFactory } from '../../../shared/mocks/mock-native-window-ref'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; +import { ResearcherProfile } from '../../../core/profile/model/researcher-profile.model'; + +describe('OrcidAuthComponent test suite', () => { + let comp: OrcidAuthComponent; + let fixture: ComponentFixture; + let scheduler: TestScheduler; + let orcidAuthService: jasmine.SpyObj; + let nativeWindowRef; + let notificationsService; + + const orcidScopes = [ + '/authenticate', + '/read-limited', + '/activities/update', + '/person/update' + ]; + + const partialOrcidScopes = [ + '/authenticate', + '/read-limited', + ]; + + const mockItemUnlinkedToOrcid: Item = Object.assign(new Item(), { + bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), + metadata: { + 'dc.title': [{ + value: 'test person' + }], + 'dspace.entity.type': [{ + 'value': 'Person' + }] + } + }); + + const mockItemLinkedToOrcid: Item = Object.assign(new Item(), { + bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), + metadata: { + 'dc.title': [{ + value: 'test person' + }], + 'dspace.entity.type': [{ + 'value': 'Person' + }], + 'dspace.object.owner': [{ + 'value': 'test person', + 'language': null, + 'authority': 'deced3e7-68e2-495d-bf98-7c44fc33b8ff', + 'confidence': 600, + 'place': 0 + }], + 'dspace.orcid.authenticated': [{ + 'value': '2022-06-10T15:15:12.952872', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + }], + 'dspace.orcid.scope': [{ + 'value': '/authenticate', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + }, { + 'value': '/read-limited', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 1 + }, { + 'value': '/activities/update', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 2 + }, { + 'value': '/person/update', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 3 + }], + 'person.identifier.orcid': [{ + 'value': 'orcid-id', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + }] + } + }); + + beforeEach(waitForAsync(() => { + orcidAuthService = jasmine.createSpyObj('researcherProfileService', { + getOrcidAuthorizationScopes: jasmine.createSpy('getOrcidAuthorizationScopes'), + getOrcidAuthorizationScopesByItem: jasmine.createSpy('getOrcidAuthorizationScopesByItem'), + getOrcidAuthorizeUrl: jasmine.createSpy('getOrcidAuthorizeUrl'), + isLinkedToOrcid: jasmine.createSpy('isLinkedToOrcid'), + onlyAdminCanDisconnectProfileFromOrcid: jasmine.createSpy('onlyAdminCanDisconnectProfileFromOrcid'), + ownerCanDisconnectProfileFromOrcid: jasmine.createSpy('ownerCanDisconnectProfileFromOrcid'), + unlinkOrcidByItem: jasmine.createSpy('unlinkOrcidByItem') + }); + + void TestBed.configureTestingModule({ + imports: [ + NgbAccordionModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), + RouterTestingModule.withRoutes([]) + ], + declarations: [OrcidAuthComponent], + providers: [ + { provide: NativeWindowService, useFactory: NativeWindowMockFactory }, + { provide: NotificationsService, useClass: NotificationsServiceStub }, + { provide: OrcidAuthService, useValue: orcidAuthService } + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(OrcidAuthComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(waitForAsync(() => { + scheduler = getTestScheduler(); + fixture = TestBed.createComponent(OrcidAuthComponent); + comp = fixture.componentInstance; + orcidAuthService.getOrcidAuthorizationScopes.and.returnValue(of(orcidScopes)); + })); + + describe('when orcid profile is not linked', () => { + beforeEach(waitForAsync(() => { + comp.item = mockItemUnlinkedToOrcid; + orcidAuthService.getOrcidAuthorizationScopesByItem.and.returnValue([]); + orcidAuthService.isLinkedToOrcid.and.returnValue(false); + orcidAuthService.onlyAdminCanDisconnectProfileFromOrcid.and.returnValue(of(false)); + orcidAuthService.ownerCanDisconnectProfileFromOrcid.and.returnValue(of(true)); + orcidAuthService.getOrcidAuthorizeUrl.and.returnValue(of('oarcidUrl')); + fixture.detectChanges(); + })); + + it('should create', fakeAsync(() => { + const orcidLinked = fixture.debugElement.query(By.css('[data-test="orcidLinked"]')); + const orcidNotLinked = fixture.debugElement.query(By.css('[data-test="orcidNotLinked"]')); + expect(orcidLinked).toBeFalsy(); + expect(orcidNotLinked).toBeTruthy(); + })); + + it('should change location on link', () => { + nativeWindowRef = (comp as any)._window; + scheduler.schedule(() => comp.linkOrcid()); + scheduler.flush(); + + expect(nativeWindowRef.nativeWindow.location.href).toBe('oarcidUrl'); + }); + + }); + + describe('when orcid profile is linked', () => { + beforeEach(waitForAsync(() => { + comp.item = mockItemLinkedToOrcid; + orcidAuthService.isLinkedToOrcid.and.returnValue(true); + })); + + describe('', () => { + + beforeEach(waitForAsync(() => { + comp.item = mockItemLinkedToOrcid; + notificationsService = (comp as any).notificationsService; + orcidAuthService.getOrcidAuthorizationScopesByItem.and.returnValue([...orcidScopes]); + orcidAuthService.isLinkedToOrcid.and.returnValue(true); + orcidAuthService.onlyAdminCanDisconnectProfileFromOrcid.and.returnValue(of(false)); + orcidAuthService.ownerCanDisconnectProfileFromOrcid.and.returnValue(of(true)); + })); + + describe('and unlink is successfully', () => { + beforeEach(waitForAsync(() => { + comp.item = mockItemLinkedToOrcid; + orcidAuthService.unlinkOrcidByItem.and.returnValue(createSuccessfulRemoteDataObject$(new ResearcherProfile())); + spyOn(comp.unlink, 'emit'); + fixture.detectChanges(); + })); + + it('should show success notification', () => { + scheduler.schedule(() => comp.unlinkOrcid()); + scheduler.flush(); + + expect(notificationsService.success).toHaveBeenCalled(); + expect(comp.unlink.emit).toHaveBeenCalled(); + }); + }); + + describe('and unlink is failed', () => { + beforeEach(waitForAsync(() => { + comp.item = mockItemLinkedToOrcid; + orcidAuthService.unlinkOrcidByItem.and.returnValue(createFailedRemoteDataObject$()); + fixture.detectChanges(); + })); + + it('should show success notification', () => { + scheduler.schedule(() => comp.unlinkOrcid()); + scheduler.flush(); + + expect(notificationsService.error).toHaveBeenCalled(); + }); + }); + }); + + describe('and has orcid authorization scopes', () => { + + beforeEach(waitForAsync(() => { + comp.item = mockItemLinkedToOrcid; + orcidAuthService.getOrcidAuthorizationScopesByItem.and.returnValue([...orcidScopes]); + orcidAuthService.isLinkedToOrcid.and.returnValue(true); + orcidAuthService.onlyAdminCanDisconnectProfileFromOrcid.and.returnValue(of(false)); + orcidAuthService.ownerCanDisconnectProfileFromOrcid.and.returnValue(of(true)); + fixture.detectChanges(); + })); + + it('should create', fakeAsync(() => { + const orcidLinked = fixture.debugElement.query(By.css('[data-test="orcidLinked"]')); + const orcidNotLinked = fixture.debugElement.query(By.css('[data-test="orcidNotLinked"]')); + expect(orcidLinked).toBeTruthy(); + expect(orcidNotLinked).toBeFalsy(); + })); + + it('should display orcid authorizations', fakeAsync(() => { + const orcidAuthorizations = fixture.debugElement.query(By.css('[data-test="hasOrcidAuthorizations"]')); + const noMissingOrcidAuthorizations = fixture.debugElement.query(By.css('[data-test="noMissingOrcidAuthorizations"]')); + const orcidAuthorizationsList = fixture.debugElement.queryAll(By.css('[data-test="orcidAuthorization"]')); + + expect(orcidAuthorizations).toBeTruthy(); + expect(noMissingOrcidAuthorizations).toBeTruthy(); + expect(orcidAuthorizationsList.length).toBe(4); + })); + }); + + describe('and has missing orcid authorization scopes', () => { + + beforeEach(waitForAsync(() => { + comp.item = mockItemLinkedToOrcid; + orcidAuthService.getOrcidAuthorizationScopesByItem.and.returnValue([...partialOrcidScopes]); + orcidAuthService.isLinkedToOrcid.and.returnValue(true); + orcidAuthService.onlyAdminCanDisconnectProfileFromOrcid.and.returnValue(of(false)); + orcidAuthService.ownerCanDisconnectProfileFromOrcid.and.returnValue(of(true)); + fixture.detectChanges(); + })); + + it('should create', fakeAsync(() => { + const orcidLinked = fixture.debugElement.query(By.css('[data-test="orcidLinked"]')); + const orcidNotLinked = fixture.debugElement.query(By.css('[data-test="orcidNotLinked"]')); + expect(orcidLinked).toBeTruthy(); + expect(orcidNotLinked).toBeFalsy(); + })); + + it('should display orcid authorizations', fakeAsync(() => { + const orcidAuthorizations = fixture.debugElement.query(By.css('[data-test="hasOrcidAuthorizations"]')); + const missingOrcidAuthorizations = fixture.debugElement.query(By.css('[data-test="missingOrcidAuthorizations"]')); + const orcidAuthorizationsList = fixture.debugElement.queryAll(By.css('[data-test="orcidAuthorization"]')); + const missingOrcidAuthorizationsList = fixture.debugElement.queryAll(By.css('[data-test="missingOrcidAuthorization"]')); + + expect(orcidAuthorizations).toBeTruthy(); + expect(missingOrcidAuthorizations).toBeTruthy(); + expect(orcidAuthorizationsList.length).toBe(2); + expect(missingOrcidAuthorizationsList.length).toBe(2); + })); + }); + + describe('and only admin can unlink scopes', () => { + + beforeEach(waitForAsync(() => { + comp.item = mockItemLinkedToOrcid; + orcidAuthService.getOrcidAuthorizationScopesByItem.and.returnValue([...orcidScopes]); + orcidAuthService.isLinkedToOrcid.and.returnValue(true); + orcidAuthService.onlyAdminCanDisconnectProfileFromOrcid.and.returnValue(of(true)); + orcidAuthService.ownerCanDisconnectProfileFromOrcid.and.returnValue(of(false)); + fixture.detectChanges(); + })); + + it('should display warning panel', fakeAsync(() => { + const unlinkOnlyAdmin = fixture.debugElement.query(By.css('[data-test="unlinkOnlyAdmin"]')); + const unlinkOwner = fixture.debugElement.query(By.css('[data-test="unlinkOwner"]')); + expect(unlinkOnlyAdmin).toBeTruthy(); + expect(unlinkOwner).toBeFalsy(); + })); + + }); + + describe('and owner can unlink scopes', () => { + + beforeEach(waitForAsync(() => { + comp.item = mockItemLinkedToOrcid; + orcidAuthService.getOrcidAuthorizationScopesByItem.and.returnValue([...orcidScopes]); + orcidAuthService.isLinkedToOrcid.and.returnValue(true); + orcidAuthService.onlyAdminCanDisconnectProfileFromOrcid.and.returnValue(of(true)); + orcidAuthService.ownerCanDisconnectProfileFromOrcid.and.returnValue(of(true)); + fixture.detectChanges(); + })); + + it('should display warning panel', fakeAsync(() => { + const unlinkOnlyAdmin = fixture.debugElement.query(By.css('[data-test="unlinkOnlyAdmin"]')); + const unlinkOwner = fixture.debugElement.query(By.css('[data-test="unlinkOwner"]')); + expect(unlinkOnlyAdmin).toBeFalsy(); + expect(unlinkOwner).toBeTruthy(); + })); + + }); + + }); + + +}); diff --git a/src/app/item-page/orcid-page/orcid-auth/orcid-auth.component.ts b/src/app/item-page/orcid-page/orcid-auth/orcid-auth.component.ts new file mode 100644 index 0000000000..ea970e7d31 --- /dev/null +++ b/src/app/item-page/orcid-page/orcid-auth/orcid-auth.component.ts @@ -0,0 +1,218 @@ +import { Component, EventEmitter, Inject, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core'; + +import { TranslateService } from '@ngx-translate/core'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { NativeWindowRef, NativeWindowService } from '../../../core/services/window.service'; +import { Item } from '../../../core/shared/item.model'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { RemoteData } from '../../../core/data/remote-data'; +import { ResearcherProfile } from '../../../core/profile/model/researcher-profile.model'; +import { getFirstCompletedRemoteData } from '../../../core/shared/operators'; +import { OrcidAuthService } from '../../../core/orcid/orcid-auth.service'; + +@Component({ + selector: 'ds-orcid-auth', + templateUrl: './orcid-auth.component.html', + styleUrls: ['./orcid-auth.component.scss'] +}) +export class OrcidAuthComponent implements OnInit, OnChanges { + + /** + * The item for which showing the orcid settings + */ + @Input() item: Item; + + /** + * The list of exposed orcid authorization scopes for the orcid profile + */ + profileAuthorizationScopes: BehaviorSubject = new BehaviorSubject([]); + + /** + * The list of all orcid authorization scopes missing in the orcid profile + */ + missingAuthorizationScopes: BehaviorSubject = new BehaviorSubject([]); + + /** + * The list of all orcid authorization scopes available + */ + orcidAuthorizationScopes: BehaviorSubject = new BehaviorSubject([]); + + /** + * A boolean representing if unlink operation is processing + */ + unlinkProcessing: BehaviorSubject = new BehaviorSubject(false); + + /** + * A boolean representing if orcid profile is linked + */ + private isOrcidLinked$: BehaviorSubject = new BehaviorSubject(false); + + /** + * A boolean representing if only admin can disconnect orcid profile + */ + private onlyAdminCanDisconnectProfileFromOrcid$: BehaviorSubject = new BehaviorSubject(false); + + /** + * A boolean representing if owner can disconnect orcid profile + */ + private ownerCanDisconnectProfileFromOrcid$: BehaviorSubject = new BehaviorSubject(false); + + /** + * An event emitted when orcid profile is unliked successfully + */ + @Output() unlink: EventEmitter = new EventEmitter(); + + constructor( + private orcidAuthService: OrcidAuthService, + private translateService: TranslateService, + private notificationsService: NotificationsService, + @Inject(NativeWindowService) private _window: NativeWindowRef, + ) { + } + + ngOnInit() { + this.orcidAuthService.getOrcidAuthorizationScopes().subscribe((scopes: string[]) => { + this.orcidAuthorizationScopes.next(scopes); + this.initOrcidAuthSettings(); + }); + } + + ngOnChanges(changes: SimpleChanges): void { + if (!changes.item.isFirstChange() && changes.item.currentValue !== changes.item.previousValue) { + this.initOrcidAuthSettings(); + } + } + + /** + * Check if the list of exposed orcid authorization scopes for the orcid profile has values + */ + hasOrcidAuthorizations(): Observable { + return this.profileAuthorizationScopes.asObservable().pipe( + map((scopes: string[]) => scopes.length > 0) + ); + } + + /** + * Return the list of exposed orcid authorization scopes for the orcid profile + */ + getOrcidAuthorizations(): Observable { + return this.profileAuthorizationScopes.asObservable(); + } + + /** + * Check if the list of exposed orcid authorization scopes for the orcid profile has values + */ + hasMissingOrcidAuthorizations(): Observable { + return this.missingAuthorizationScopes.asObservable().pipe( + map((scopes: string[]) => scopes.length > 0) + ); + } + + /** + * Return the list of exposed orcid authorization scopes for the orcid profile + */ + getMissingOrcidAuthorizations(): Observable { + return this.profileAuthorizationScopes.asObservable(); + } + + /** + * Return a boolean representing if orcid profile is linked + */ + isLinkedToOrcid(): Observable { + return this.isOrcidLinked$.asObservable(); + } + + getOrcidNotLinkedMessage(): Observable { + const orcid = this.item.firstMetadataValue('person.identifier.orcid'); + if (orcid) { + return this.translateService.get('person.page.orcid.orcid-not-linked-message', { 'orcid': orcid }); + } else { + return this.translateService.get('person.page.orcid.no-orcid-message'); + } + } + + /** + * Get label for a given orcid authorization scope + * + * @param scope + */ + getAuthorizationDescription(scope: string) { + return 'person.page.orcid.scope.' + scope.substring(1).replace('/', '-'); + } + + /** + * Return a boolean representing if only admin can disconnect orcid profile + */ + onlyAdminCanDisconnectProfileFromOrcid(): Observable { + return this.onlyAdminCanDisconnectProfileFromOrcid$.asObservable(); + } + + /** + * Return a boolean representing if owner can disconnect orcid profile + */ + ownerCanDisconnectProfileFromOrcid(): Observable { + return this.ownerCanDisconnectProfileFromOrcid$.asObservable(); + } + + /** + * Link existing person profile with orcid + */ + linkOrcid(): void { + this.orcidAuthService.getOrcidAuthorizeUrl(this.item).subscribe((authorizeUrl) => { + this._window.nativeWindow.location.href = authorizeUrl; + }); + } + + /** + * Unlink existing person profile from orcid + */ + unlinkOrcid(): void { + this.unlinkProcessing.next(true); + this.orcidAuthService.unlinkOrcidByItem(this.item).pipe( + getFirstCompletedRemoteData() + ).subscribe((remoteData: RemoteData) => { + this.unlinkProcessing.next(false); + if (remoteData.isSuccess) { + this.notificationsService.success(this.translateService.get('person.page.orcid.unlink.success')); + this.unlink.emit(); + } else { + this.notificationsService.error(this.translateService.get('person.page.orcid.unlink.error')); + } + }); + } + + /** + * initialize all Orcid authentication settings + * @private + */ + private initOrcidAuthSettings(): void { + + this.setOrcidAuthorizationsFromItem(); + + this.setMissingOrcidAuthorizations(); + + this.orcidAuthService.onlyAdminCanDisconnectProfileFromOrcid().subscribe((result) => { + this.onlyAdminCanDisconnectProfileFromOrcid$.next(result); + }); + + this.orcidAuthService.ownerCanDisconnectProfileFromOrcid().subscribe((result) => { + this.ownerCanDisconnectProfileFromOrcid$.next(result); + }); + + this.isOrcidLinked$.next(this.orcidAuthService.isLinkedToOrcid(this.item)); + } + + private setMissingOrcidAuthorizations(): void { + const profileScopes = this.orcidAuthService.getOrcidAuthorizationScopesByItem(this.item); + const orcidScopes = this.orcidAuthorizationScopes.value; + const missingScopes = orcidScopes.filter((scope) => !profileScopes.includes(scope)); + + this.missingAuthorizationScopes.next(missingScopes); + } + + private setOrcidAuthorizationsFromItem(): void { + this.profileAuthorizationScopes.next(this.orcidAuthService.getOrcidAuthorizationScopesByItem(this.item)); + } + +} diff --git a/src/app/item-page/orcid-page/orcid-page.component.html b/src/app/item-page/orcid-page/orcid-page.component.html new file mode 100644 index 0000000000..33c3125d67 --- /dev/null +++ b/src/app/item-page/orcid-page/orcid-page.component.html @@ -0,0 +1,19 @@ + + + +
+ {{'person.page.orcid.link.error.message' | translate}} +
+ + + + + diff --git a/src/app/item-page/orcid-page/orcid-page.component.scss b/src/app/item-page/orcid-page/orcid-page.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/item-page/orcid-page/orcid-page.component.spec.ts b/src/app/item-page/orcid-page/orcid-page.component.spec.ts new file mode 100644 index 0000000000..1ed237943e --- /dev/null +++ b/src/app/item-page/orcid-page/orcid-page.component.spec.ts @@ -0,0 +1,220 @@ +import { NO_ERRORS_SCHEMA, PLATFORM_ID } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { ComponentFixture, fakeAsync, TestBed, waitForAsync } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { By } from '@angular/platform-browser'; + +import { of as observableOf } from 'rxjs'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { TestScheduler } from 'rxjs/testing'; +import { getTestScheduler } from 'jasmine-marbles'; + +import { AuthService } from '../../core/auth/auth.service'; +import { ActivatedRouteStub } from '../../shared/testing/active-router.stub'; +import { OrcidPageComponent } from './orcid-page.component'; +import { + createFailedRemoteDataObject$, + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$ +} from '../../shared/remote-data.utils'; +import { Item } from '../../core/shared/item.model'; +import { createPaginatedList } from '../../shared/testing/utils.test'; +import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; +import { ItemDataService } from '../../core/data/item-data.service'; +import { ResearcherProfile } from '../../core/profile/model/researcher-profile.model'; +import { OrcidAuthService } from '../../core/orcid/orcid-auth.service'; + +describe('OrcidPageComponent test suite', () => { + let comp: OrcidPageComponent; + let fixture: ComponentFixture; + let scheduler: TestScheduler; + let authService: jasmine.SpyObj; + let routeStub: jasmine.SpyObj; + let routeData: any; + let itemDataService: jasmine.SpyObj; + let orcidAuthService: jasmine.SpyObj; + + const mockResearcherProfile: ResearcherProfile = Object.assign(new ResearcherProfile(), { + id: 'test-id', + visible: true, + type: 'profile', + _links: { + item: { + href: 'https://rest.api/rest/api/profiles/test-id/item' + }, + self: { + href: 'https://rest.api/rest/api/profiles/test-id' + }, + } + }); + const mockItem: Item = Object.assign(new Item(), { + id: 'test-id', + bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'test item' + } + ] + } + }); + const mockItemLinkedToOrcid: Item = Object.assign(new Item(), { + id: 'test-id', + bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), + metadata: { + 'dc.title': [ + { + value: 'test item' + } + ], + 'dspace.orcid.authenticated': [ + { + value: 'true' + } + ] + } + }); + + beforeEach(waitForAsync(() => { + authService = jasmine.createSpyObj('authService', { + isAuthenticated: jasmine.createSpy('isAuthenticated'), + navigateByUrl: jasmine.createSpy('navigateByUrl') + }); + + routeData = { + dso: createSuccessfulRemoteDataObject(mockItem), + }; + + routeStub = new ActivatedRouteStub({}, routeData); + + orcidAuthService = jasmine.createSpyObj('OrcidAuthService', { + isLinkedToOrcid: jasmine.createSpy('isLinkedToOrcid'), + linkOrcidByItem: jasmine.createSpy('linkOrcidByItem'), + }); + + itemDataService = jasmine.createSpyObj('ItemDataService', { + findById: jasmine.createSpy('findById') + }); + + void TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), + RouterTestingModule.withRoutes([]) + ], + declarations: [OrcidPageComponent], + providers: [ + { provide: ActivatedRoute, useValue: routeStub }, + { provide: OrcidAuthService, useValue: orcidAuthService }, + { provide: AuthService, useValue: authService }, + { provide: ItemDataService, useValue: itemDataService }, + { provide: PLATFORM_ID, useValue: 'browser' }, + ], + + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(waitForAsync(() => { + scheduler = getTestScheduler(); + fixture = TestBed.createComponent(OrcidPageComponent); + comp = fixture.componentInstance; + authService.isAuthenticated.and.returnValue(observableOf(true)); + })); + + describe('whn has no query param', () => { + beforeEach(waitForAsync(() => { + fixture.detectChanges(); + })); + + it('should create', () => { + const btn = fixture.debugElement.queryAll(By.css('[data-test="back-button"]')); + const auth = fixture.debugElement.query(By.css('[data-test="orcid-auth"]')); + const settings = fixture.debugElement.query(By.css('[data-test="orcid-sync-setting"]')); + expect(comp).toBeTruthy(); + expect(btn.length).toBe(1); + expect(auth).toBeTruthy(); + expect(settings).toBeTruthy(); + expect(comp.itemId).toBe('test-id'); + }); + + it('should call isLinkedToOrcid', () => { + comp.isLinkedToOrcid(); + + expect(orcidAuthService.isLinkedToOrcid).toHaveBeenCalledWith(comp.item.value); + }); + + it('should update item', fakeAsync(() => { + itemDataService.findById.and.returnValue(createSuccessfulRemoteDataObject$(mockItemLinkedToOrcid)); + scheduler.schedule(() => comp.updateItem()); + scheduler.flush(); + + expect(comp.item.value).toEqual(mockItemLinkedToOrcid); + })); + }); + + describe('when query param contains orcid code', () => { + beforeEach(waitForAsync(() => { + spyOn(comp, 'updateItem').and.callThrough(); + routeStub.testParams = { + code: 'orcid-code' + }; + })); + + describe('and linking to orcid profile is successfully', () => { + beforeEach(waitForAsync(() => { + orcidAuthService.linkOrcidByItem.and.returnValue(createSuccessfulRemoteDataObject$(mockResearcherProfile)); + itemDataService.findById.and.returnValue(createSuccessfulRemoteDataObject$(mockItemLinkedToOrcid)); + fixture.detectChanges(); + })); + + it('should call linkOrcidByItem', () => { + expect(orcidAuthService.linkOrcidByItem).toHaveBeenCalledWith(mockItem, 'orcid-code'); + expect(comp.updateItem).toHaveBeenCalled(); + }); + + it('should create', () => { + const btn = fixture.debugElement.queryAll(By.css('[data-test="back-button"]')); + const auth = fixture.debugElement.query(By.css('[data-test="orcid-auth"]')); + const settings = fixture.debugElement.query(By.css('[data-test="orcid-sync-setting"]')); + expect(comp).toBeTruthy(); + expect(btn.length).toBe(1); + expect(auth).toBeTruthy(); + expect(settings).toBeTruthy(); + expect(comp.itemId).toBe('test-id'); + }); + + }); + + describe('and linking to orcid profile is failed', () => { + beforeEach(waitForAsync(() => { + orcidAuthService.linkOrcidByItem.and.returnValue(createFailedRemoteDataObject$()); + itemDataService.findById.and.returnValue(createSuccessfulRemoteDataObject$(mockItemLinkedToOrcid)); + fixture.detectChanges(); + })); + + it('should call linkOrcidByItem', () => { + expect(orcidAuthService.linkOrcidByItem).toHaveBeenCalledWith(mockItem, 'orcid-code'); + expect(comp.updateItem).not.toHaveBeenCalled(); + }); + + it('should create', () => { + const btn = fixture.debugElement.queryAll(By.css('[data-test="back-button"]')); + const auth = fixture.debugElement.query(By.css('[data-test="orcid-auth"]')); + const settings = fixture.debugElement.query(By.css('[data-test="orcid-sync-setting"]')); + const error = fixture.debugElement.query(By.css('[data-test="error-box"]')); + expect(comp).toBeTruthy(); + expect(btn.length).toBe(1); + expect(error).toBeTruthy(); + expect(auth).toBeFalsy(); + expect(settings).toBeFalsy(); + }); + + }); + }); +}); diff --git a/src/app/item-page/orcid-page/orcid-page.component.ts b/src/app/item-page/orcid-page/orcid-page.component.ts new file mode 100644 index 0000000000..f3dbb569d9 --- /dev/null +++ b/src/app/item-page/orcid-page/orcid-page.component.ts @@ -0,0 +1,153 @@ +import { Component, Inject, OnInit, PLATFORM_ID } from '@angular/core'; +import { ActivatedRoute, ParamMap, Router } from '@angular/router'; +import { isPlatformBrowser } from '@angular/common'; + +import { BehaviorSubject, combineLatest } from 'rxjs'; +import { map, take } from 'rxjs/operators'; + +import { OrcidAuthService } from '../../core/orcid/orcid-auth.service'; +import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../../core/shared/operators'; +import { RemoteData } from '../../core/data/remote-data'; +import { Item } from '../../core/shared/item.model'; +import { getItemPageRoute } from '../item-page-routing-paths'; +import { AuthService } from '../../core/auth/auth.service'; +import { redirectOn4xx } from '../../core/shared/authorized.operators'; +import { ItemDataService } from '../../core/data/item-data.service'; +import { isNotEmpty } from '../../shared/empty.util'; +import { ResearcherProfile } from '../../core/profile/model/researcher-profile.model'; + +/** + * A component that represents the orcid settings page + */ +@Component({ + selector: 'ds-orcid-page', + templateUrl: './orcid-page.component.html', + styleUrls: ['./orcid-page.component.scss'] +}) +export class OrcidPageComponent implements OnInit { + + /** + * A boolean representing if the connection operation with orcid profile is in progress + */ + connectionStatus: BehaviorSubject = new BehaviorSubject(false); + + /** + * The item for which showing the orcid settings + */ + item: BehaviorSubject = new BehaviorSubject(null); + + /** + * The item id for which showing the orcid settings + */ + itemId: string; + + /** + * A boolean representing if the connection operation with orcid profile is in progress + */ + processingConnection: BehaviorSubject = new BehaviorSubject(true); + + constructor( + @Inject(PLATFORM_ID) private platformId: any, + private authService: AuthService, + private itemService: ItemDataService, + private orcidAuthService: OrcidAuthService, + private route: ActivatedRoute, + private router: Router + ) { + } + + /** + * Retrieve the item for which showing the orcid settings + */ + ngOnInit(): void { + if (isPlatformBrowser(this.platformId)) { + const codeParam$ = this.route.queryParamMap.pipe( + take(1), + map((paramMap: ParamMap) => paramMap.get('code')), + ); + + const item$ = this.route.data.pipe( + map((data) => data.dso as RemoteData), + redirectOn4xx(this.router, this.authService), + getFirstSucceededRemoteDataPayload() + ); + + combineLatest([codeParam$, item$]).subscribe(([codeParam, item]) => { + this.itemId = item.id; + /** + * Check if code is present in the query param. If so it means this page is loaded after attempting to + * link the person to the ORCID profile, otherwise the person is already linked to ORCID profile + */ + if (isNotEmpty(codeParam)) { + this.linkProfileToOrcid(item, codeParam); + } else { + this.item.next(item); + this.processingConnection.next(false); + this.connectionStatus.next(true); + } + }); + } + } + + /** + * Check if the current item is linked to an ORCID profile. + * + * @returns the check result + */ + isLinkedToOrcid(): boolean { + return this.orcidAuthService.isLinkedToOrcid(this.item.value); + } + + /** + * Get the route to an item's page + */ + getItemPage(): string { + return getItemPageRoute(this.item.value); + } + + /** + * Retrieve the updated profile item + */ + updateItem(): void { + this.clearRouteParams(); + this.itemService.findById(this.itemId, false).pipe( + getFirstCompletedRemoteData() + ).subscribe((itemRD: RemoteData) => { + if (itemRD.hasSucceeded) { + this.item.next(itemRD.payload); + } + }); + } + + /** + * Link person item to ORCID profile by using the code received after redirect from ORCID. + * + * @param person The person item to link to ORCID profile + * @param code The auth-code received from ORCID + */ + private linkProfileToOrcid(person: Item, code: string) { + this.orcidAuthService.linkOrcidByItem(person, code).pipe( + getFirstCompletedRemoteData() + ).subscribe((profileRD: RemoteData) => { + this.processingConnection.next(false); + if (profileRD.hasSucceeded) { + this.connectionStatus.next(true); + this.updateItem(); + } else { + this.item.next(person); + this.connectionStatus.next(false); + this.clearRouteParams(); + } + }); + } + + /** + * Update route removing the code from query params + * @private + */ + private clearRouteParams(): void { + // update route removing the code from query params + const redirectUrl = this.router.url.split('?')[0]; + this.router.navigate([redirectUrl]); + } +} diff --git a/src/app/item-page/orcid-page/orcid-page.guard.ts b/src/app/item-page/orcid-page/orcid-page.guard.ts new file mode 100644 index 0000000000..97c528e9ae --- /dev/null +++ b/src/app/item-page/orcid-page/orcid-page.guard.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; +import { Observable, of as observableOf } from 'rxjs'; +import { AuthService } from '../../core/auth/auth.service'; +import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; +import { DsoPageSingleFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard'; +import { FeatureID } from '../../core/data/feature-authorization/feature-id'; +import { Item } from '../../core/shared/item.model'; +import { ItemPageResolver } from '../item-page.resolver'; + +@Injectable({ + providedIn: 'root' +}) +/** + * Guard for preventing unauthorized access to certain {@link Item} pages requiring administrator rights + */ +export class OrcidPageGuard extends DsoPageSingleFeatureGuard { + constructor(protected resolver: ItemPageResolver, + protected authorizationService: AuthorizationDataService, + protected router: Router, + protected authService: AuthService) { + super(resolver, authorizationService, router, authService); + } + + /** + * Check administrator authorization rights + */ + getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return observableOf(FeatureID.CanSynchronizeWithORCID); + } +} diff --git a/src/app/item-page/orcid-page/orcid-queue/orcid-queue.component.html b/src/app/item-page/orcid-page/orcid-queue/orcid-queue.component.html new file mode 100644 index 0000000000..9358bcf835 --- /dev/null +++ b/src/app/item-page/orcid-page/orcid-queue/orcid-queue.component.html @@ -0,0 +1,51 @@ +
+ +
+

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

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

{{'person.orcid.sync.setting' | translate}}

+
+
+
+
+
{{ 'person.page.orcid.synchronization-mode'| translate }}
+
+
+
+ + {{ 'person.page.orcid.synchronization-mode-message' | translate}} + +
+
+ + +
+
+
+
+
+
+
+
+
+
{{ 'person.page.orcid.publications-preferences'| translate }}
+
+
+
+ + {{ 'person.page.orcid.synchronization-mode-publication-message' | translate}} + +
+
+
+ + +
+
+
+
+
+
+
+
+
{{ 'person.page.orcid.funding-preferences'| translate }}
+
+
+
+ + {{ 'person.page.orcid.synchronization-mode-funding-message' | translate}} + +
+
+
+ + +
+
+
+
+
+
+
+
+
{{ 'person.page.orcid.profile-preferences'| translate }}
+
+
+
+ + {{ 'person.page.orcid.synchronization-mode-profile-message' | translate}} + +
+
+
+ + +
+
+
+
+
+
+
+
+
+ +
+
+
+
diff --git a/src/app/item-page/orcid-page/orcid-sync-settings/orcid-sync-settings.component.scss b/src/app/item-page/orcid-page/orcid-sync-settings/orcid-sync-settings.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/item-page/orcid-page/orcid-sync-settings/orcid-sync-settings.component.spec.ts b/src/app/item-page/orcid-page/orcid-sync-settings/orcid-sync-settings.component.spec.ts new file mode 100644 index 0000000000..4312d35be9 --- /dev/null +++ b/src/app/item-page/orcid-page/orcid-sync-settings/orcid-sync-settings.component.spec.ts @@ -0,0 +1,261 @@ +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { RouterTestingModule } from '@angular/router/testing'; +import { By } from '@angular/platform-browser'; + +import { getTestScheduler } from 'jasmine-marbles'; +import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'; +import { TestScheduler } from 'rxjs/testing'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { Operation } from 'fast-json-patch'; + +import { ResearcherProfileService } from '../../../core/profile/researcher-profile.service'; +import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { Item } from '../../../core/shared/item.model'; +import { createPaginatedList } from '../../../shared/testing/utils.test'; +import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; +import { OrcidSyncSettingsComponent } from './orcid-sync-settings.component'; +import { ResearcherProfile } from '../../../core/profile/model/researcher-profile.model'; + +describe('OrcidAuthComponent test suite', () => { + let comp: OrcidSyncSettingsComponent; + let fixture: ComponentFixture; + let scheduler: TestScheduler; + let researcherProfileService: jasmine.SpyObj; + let notificationsService; + let formGroup: FormGroup; + + 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 mockItemLinkedToOrcid: Item = Object.assign(new Item(), { + bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), + metadata: { + 'dc.title': [{ + value: 'test person' + }], + 'dspace.entity.type': [{ + 'value': 'Person' + }], + 'dspace.object.owner': [{ + 'value': 'test person', + 'language': null, + 'authority': 'deced3e7-68e2-495d-bf98-7c44fc33b8ff', + 'confidence': 600, + 'place': 0 + }], + 'dspace.orcid.authenticated': [{ + 'value': '2022-06-10T15:15:12.952872', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + }], + 'dspace.orcid.scope': [{ + 'value': '/authenticate', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + }, { + 'value': '/read-limited', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 1 + }, { + 'value': '/activities/update', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 2 + }, { + 'value': '/person/update', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 3 + }], + 'dspace.orcid.sync-mode': [{ + 'value': 'MANUAL', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + }], + 'dspace.orcid.sync-profile': [{ + 'value': 'BIOGRAPHICAL', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + }, { + 'value': 'IDENTIFIERS', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 1 + }], + 'dspace.orcid.sync-publications': [{ + 'value': 'ALL', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + }], + 'person.identifier.orcid': [{ + 'value': 'orcid-id', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + }] + } + }); + + beforeEach(waitForAsync(() => { + researcherProfileService = jasmine.createSpyObj('researcherProfileService', { + findByRelatedItem: jasmine.createSpy('findByRelatedItem'), + updateByOrcidOperations: jasmine.createSpy('updateByOrcidOperations') + }); + + void TestBed.configureTestingModule({ + imports: [ + FormsModule, + NgbAccordionModule, + ReactiveFormsModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), + RouterTestingModule.withRoutes([]) + ], + declarations: [OrcidSyncSettingsComponent], + providers: [ + { provide: NotificationsService, useClass: NotificationsServiceStub }, + { provide: ResearcherProfileService, useValue: researcherProfileService } + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(OrcidSyncSettingsComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(waitForAsync(() => { + scheduler = getTestScheduler(); + fixture = TestBed.createComponent(OrcidSyncSettingsComponent); + comp = fixture.componentInstance; + comp.item = mockItemLinkedToOrcid; + fixture.detectChanges(); + })); + + it('should create cards properly', () => { + const modes = fixture.debugElement.query(By.css('[data-test="sync-mode"]')); + const publication = fixture.debugElement.query(By.css('[data-test="sync-mode-publication"]')); + const funding = fixture.debugElement.query(By.css('[data-test="sync-mode-funding"]')); + const preferences = fixture.debugElement.query(By.css('[data-test="profile-preferences"]')); + expect(modes).toBeTruthy(); + expect(publication).toBeTruthy(); + expect(funding).toBeTruthy(); + expect(preferences).toBeTruthy(); + }); + + it('should init sync modes properly', () => { + expect(comp.currentSyncMode).toBe('MANUAL'); + expect(comp.currentSyncPublications).toBe('ALL'); + expect(comp.currentSyncFunding).toBe('DISABLED'); + }); + + describe('form submit', () => { + beforeEach(() => { + scheduler = getTestScheduler(); + notificationsService = (comp as any).notificationsService; + formGroup = new FormGroup({ + syncMode: new FormControl('MANUAL'), + syncFundings: new FormControl('ALL'), + syncPublications: new FormControl('ALL'), + syncProfile_BIOGRAPHICAL: new FormControl(true), + syncProfile_IDENTIFIERS: new FormControl(true), + }); + spyOn(comp.settingsUpdated, 'emit'); + }); + + it('should call updateByOrcidOperations properly', () => { + researcherProfileService.findByRelatedItem.and.returnValue(createSuccessfulRemoteDataObject$(mockResearcherProfile)); + researcherProfileService.updateByOrcidOperations.and.returnValue(createSuccessfulRemoteDataObject$(mockResearcherProfile)); + const expectedOps: Operation[] = [ + { + path: '/orcid/mode', + op: 'replace', + value: 'MANUAL' + }, { + path: '/orcid/publications', + op: 'replace', + value: 'ALL' + }, { + path: '/orcid/fundings', + op: 'replace', + value: 'ALL' + }, { + path: '/orcid/profile', + op: 'replace', + value: 'BIOGRAPHICAL,IDENTIFIERS' + } + ]; + + scheduler.schedule(() => comp.onSubmit(formGroup)); + scheduler.flush(); + + expect(researcherProfileService.updateByOrcidOperations).toHaveBeenCalledWith(mockResearcherProfile, expectedOps); + }); + + it('should show notification on success', () => { + researcherProfileService.findByRelatedItem.and.returnValue(createSuccessfulRemoteDataObject$(mockResearcherProfile)); + researcherProfileService.updateByOrcidOperations.and.returnValue(createSuccessfulRemoteDataObject$(mockResearcherProfile)); + + scheduler.schedule(() => comp.onSubmit(formGroup)); + scheduler.flush(); + + expect(notificationsService.success).toHaveBeenCalled(); + expect(comp.settingsUpdated.emit).toHaveBeenCalled(); + }); + + it('should show notification on error', () => { + researcherProfileService.findByRelatedItem.and.returnValue(createFailedRemoteDataObject$()); + + scheduler.schedule(() => comp.onSubmit(formGroup)); + scheduler.flush(); + + expect(notificationsService.error).toHaveBeenCalled(); + expect(comp.settingsUpdated.emit).not.toHaveBeenCalled(); + }); + + it('should show notification on error', () => { + researcherProfileService.findByRelatedItem.and.returnValue(createSuccessfulRemoteDataObject$(mockResearcherProfile)); + researcherProfileService.updateByOrcidOperations.and.returnValue(createFailedRemoteDataObject$()); + + scheduler.schedule(() => comp.onSubmit(formGroup)); + scheduler.flush(); + + expect(notificationsService.error).toHaveBeenCalled(); + expect(comp.settingsUpdated.emit).not.toHaveBeenCalled(); + }); + }); + +}); diff --git a/src/app/item-page/orcid-page/orcid-sync-settings/orcid-sync-settings.component.ts b/src/app/item-page/orcid-page/orcid-sync-settings/orcid-sync-settings.component.ts new file mode 100644 index 0000000000..6e8b0c8216 --- /dev/null +++ b/src/app/item-page/orcid-page/orcid-sync-settings/orcid-sync-settings.component.ts @@ -0,0 +1,196 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { FormGroup } from '@angular/forms'; + +import { TranslateService } from '@ngx-translate/core'; +import { Operation } from 'fast-json-patch'; +import { of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { RemoteData } from '../../../core/data/remote-data'; +import { ResearcherProfileService } from '../../../core/profile/researcher-profile.service'; +import { Item } from '../../../core/shared/item.model'; +import { getFirstCompletedRemoteData } from '../../../core/shared/operators'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { ResearcherProfile } from '../../../core/profile/model/researcher-profile.model'; + +@Component({ + selector: 'ds-orcid-sync-setting', + templateUrl: './orcid-sync-settings.component.html', + styleUrls: ['./orcid-sync-settings.component.scss'] +}) +export class OrcidSyncSettingsComponent implements OnInit { + + /** + * The item for which showing the orcid settings + */ + @Input() item: Item; + + /** + * The prefix used for i18n keys + */ + messagePrefix = 'person.page.orcid'; + + /** + * The current synchronization mode + */ + currentSyncMode: string; + + /** + * The current synchronization mode for publications + */ + currentSyncPublications: string; + + /** + * The current synchronization mode for funding + */ + currentSyncFunding: string; + + /** + * The synchronization options + */ + syncModes: { value: string, label: string }[]; + + /** + * The synchronization options for publications + */ + syncPublicationOptions: { value: string, label: string }[]; + + /** + * The synchronization options for funding + */ + syncFundingOptions: { value: string, label: string }[]; + + /** + * The profile synchronization options + */ + syncProfileOptions: { value: string, label: string, checked: boolean }[]; + + /** + * An event emitted when settings are updated + */ + @Output() settingsUpdated: EventEmitter = new EventEmitter(); + + constructor(private researcherProfileService: ResearcherProfileService, + private notificationsService: NotificationsService, + private translateService: TranslateService) { + } + + /** + * Init orcid settings form + */ + ngOnInit() { + this.syncModes = [ + { + label: this.messagePrefix + '.synchronization-mode.batch', + value: 'BATCH' + }, + { + label: this.messagePrefix + '.synchronization-mode.manual', + value: 'MANUAL' + } + ]; + + this.syncPublicationOptions = ['DISABLED', 'ALL'] + .map((value) => { + return { + label: this.messagePrefix + '.sync-publications.' + value.toLowerCase(), + value: value, + }; + }); + + this.syncFundingOptions = ['DISABLED', 'ALL'] + .map((value) => { + return { + label: this.messagePrefix + '.sync-fundings.' + value.toLowerCase(), + value: value, + }; + }); + + const syncProfilePreferences = this.item.allMetadataValues('dspace.orcid.sync-profile'); + + this.syncProfileOptions = ['BIOGRAPHICAL', 'IDENTIFIERS'] + .map((value) => { + return { + label: this.messagePrefix + '.sync-profile.' + value.toLowerCase(), + value: value, + checked: syncProfilePreferences.includes(value) + }; + }); + + this.currentSyncMode = this.getCurrentPreference('dspace.orcid.sync-mode', ['BATCH', 'MANUAL'], 'MANUAL'); + this.currentSyncPublications = this.getCurrentPreference('dspace.orcid.sync-publications', ['DISABLED', 'ALL'], 'DISABLED'); + this.currentSyncFunding = this.getCurrentPreference('dspace.orcid.sync-fundings', ['DISABLED', 'ALL'], 'DISABLED'); + } + + /** + * Generate path operations to save orcid synchronization preferences + * + * @param form The form group + */ + onSubmit(form: FormGroup): void { + const operations: Operation[] = []; + this.fillOperationsFor(operations, '/orcid/mode', form.value.syncMode); + this.fillOperationsFor(operations, '/orcid/publications', form.value.syncPublications); + this.fillOperationsFor(operations, '/orcid/fundings', form.value.syncFundings); + + const syncProfileValue = this.syncProfileOptions + .map((syncProfileOption => syncProfileOption.value)) + .filter((value) => form.value['syncProfile_' + value]) + .join(','); + + this.fillOperationsFor(operations, '/orcid/profile', syncProfileValue); + + if (operations.length === 0) { + return; + } + + this.researcherProfileService.findByRelatedItem(this.item).pipe( + getFirstCompletedRemoteData(), + switchMap((profileRD: RemoteData) => { + if (profileRD.hasSucceeded) { + return this.researcherProfileService.updateByOrcidOperations(profileRD.payload, operations).pipe( + getFirstCompletedRemoteData() + ); + } else { + return of(profileRD); + } + }), + ).subscribe((remoteData: RemoteData) => { + if (remoteData.isSuccess) { + this.notificationsService.success(this.translateService.get(this.messagePrefix + '.synchronization-settings-update.success')); + this.settingsUpdated.emit(); + } else { + this.notificationsService.error(this.translateService.get(this.messagePrefix + '.synchronization-settings-update.error')); + } + }); + } + + /** + * Retrieve setting saved in the item's metadata + * + * @param metadataField The metadata name that contains setting + * @param allowedValues The allowed values + * @param defaultValue The default value + * @private + */ + private getCurrentPreference(metadataField: string, allowedValues: string[], defaultValue: string): string { + const currentPreference = this.item.firstMetadataValue(metadataField); + return (currentPreference && allowedValues.includes(currentPreference)) ? currentPreference : defaultValue; + } + + /** + * Generate a replace patch operation + * + * @param operations + * @param path + * @param currentValue + */ + private fillOperationsFor(operations: Operation[], path: string, currentValue: string): void { + operations.push({ + path: path, + op: 'replace', + value: currentValue + }); + } + +} diff --git a/src/app/item-page/simple/item-types/publication/publication.component.html b/src/app/item-page/simple/item-types/publication/publication.component.html index 96454914cd..d83202ce12 100644 --- a/src/app/item-page/simple/item-types/publication/publication.component.html +++ b/src/app/item-page/simple/item-types/publication/publication.component.html @@ -12,6 +12,9 @@ {{'publication.page.titleprefix' | translate}}
+
diff --git a/src/app/item-page/simple/item-types/publication/publication.component.spec.ts b/src/app/item-page/simple/item-types/publication/publication.component.spec.ts index 761849f232..a623a34b15 100644 --- a/src/app/item-page/simple/item-types/publication/publication.component.spec.ts +++ b/src/app/item-page/simple/item-types/publication/publication.component.spec.ts @@ -1,6 +1,6 @@ import { HttpClient } from '@angular/common/http'; import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { Store } from '@ngrx/store'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; @@ -17,7 +17,7 @@ import { RemoteData } from '../../../../core/data/remote-data'; import { Bitstream } from '../../../../core/shared/bitstream.model'; import { HALEndpointService } from '../../../../core/shared/hal-endpoint.service'; import { Item } from '../../../../core/shared/item.model'; -import { MetadataMap } from '../../../../core/shared/metadata.models'; +import { MetadataMap } from '../../../../core/shared/metadata.models'; import { UUIDService } from '../../../../core/shared/uuid.service'; import { TranslateLoaderMock } from '../../../../shared/mocks/translate-loader.mock'; import { NotificationsService } from '../../../../shared/notifications/notifications.service'; @@ -26,22 +26,16 @@ import { TruncatableService } from '../../../../shared/truncatable/truncatable.s import { TruncatePipe } from '../../../../shared/utils/truncate.pipe'; import { GenericItemPageFieldComponent } from '../../field-components/specific-field/generic/generic-item-page-field.component'; import { - createRelationshipsObservable, - iiifEnabled, - iiifSearchEnabled, mockRouteService + createRelationshipsObservable, getIIIFEnabled, getIIIFSearchEnabled, mockRouteService } from '../shared/item.component.spec'; import { PublicationComponent } from './publication.component'; import { createPaginatedList } from '../../../../shared/testing/utils.test'; import { RouteService } from '../../../../core/services/route.service'; - -const iiifEnabledMap: MetadataMap = { - 'dspace.iiif.enabled': [iiifEnabled], -}; - -const iiifEnabledWithSearchMap: MetadataMap = { - 'dspace.iiif.enabled': [iiifEnabled], - 'iiif.search.enabled': [iiifSearchEnabled], -}; +import { VersionHistoryDataService } from '../../../../core/data/version-history-data.service'; +import { VersionDataService } from '../../../../core/data/version-data.service'; +import { RouterTestingModule } from '@angular/router/testing'; +import { WorkspaceitemDataService } from '../../../../core/submission/workspaceitem-data.service'; +import { SearchService } from '../../../../core/shared/search/search.service'; const noMetadata = new MetadataMap(); @@ -64,12 +58,15 @@ describe('PublicationComponent', () => { } }; TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot({ - loader: { - provide: TranslateLoader, - useClass: TranslateLoaderMock - } - })], + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), + RouterTestingModule, + ], declarations: [PublicationComponent, GenericItemPageFieldComponent, TruncatePipe], providers: [ { provide: ItemDataService, useValue: {} }, @@ -85,18 +82,23 @@ describe('PublicationComponent', () => { { provide: HttpClient, useValue: {} }, { provide: DSOChangeAnalyzer, useValue: {} }, { provide: DefaultChangeAnalyzer, useValue: {} }, + { provide: VersionHistoryDataService, useValue: {} }, + { provide: VersionDataService, useValue: {} }, { provide: BitstreamDataService, useValue: mockBitstreamDataService }, + { provide: WorkspaceitemDataService, useValue: {} }, + { provide: SearchService, useValue: {} }, { provide: RouteService, useValue: mockRouteService } ], schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(PublicationComponent, { - set: { changeDetection: ChangeDetectionStrategy.Default } - }).compileComponents(); + set: {changeDetection: ChangeDetectionStrategy.Default} + }); })); describe('default view', () => { beforeEach(waitForAsync(() => { + TestBed.compileComponents(); fixture = TestBed.createComponent(PublicationComponent); comp = fixture.componentInstance; comp.object = getItem(noMetadata); @@ -137,6 +139,42 @@ describe('PublicationComponent', () => { describe('with IIIF viewer', () => { beforeEach(waitForAsync(() => { + const iiifEnabledMap: MetadataMap = { + 'dspace.iiif.enabled': [getIIIFEnabled(true)], + 'iiif.search.enabled': [getIIIFSearchEnabled(false)], + }; + TestBed.compileComponents(); + fixture = TestBed.createComponent(PublicationComponent); + comp = fixture.componentInstance; + comp.object = getItem(iiifEnabledMap); + fixture.detectChanges(); + })); + + it('should contain an iiif viewer component', () => { + const fields = fixture.debugElement.queryAll(By.css('ds-mirador-viewer')); + expect(fields.length).toBeGreaterThanOrEqual(1); + }); + it('should not retrieve the query term for previous route', fakeAsync((): void => { + //tick(10) + expect(comp.iiifQuery$).toBeFalsy(); + })); + + }); + + describe('with IIIF viewer and search', () => { + + beforeEach(waitForAsync(() => { + const localMockRouteService = { + getPreviousUrl(): Observable { + return of('/search?query=test%20query&fakeParam=true'); + } + }; + const iiifEnabledMap: MetadataMap = { + 'dspace.iiif.enabled': [getIIIFEnabled(true)], + 'iiif.search.enabled': [getIIIFSearchEnabled(true)], + }; + TestBed.overrideProvider(RouteService, {useValue: localMockRouteService}); + TestBed.compileComponents(); fixture = TestBed.createComponent(PublicationComponent); comp = fixture.componentInstance; comp.object = getItem(iiifEnabledMap); @@ -148,15 +186,29 @@ describe('PublicationComponent', () => { expect(fields.length).toBeGreaterThanOrEqual(1); }); + it('should retrieve the query term for previous route', fakeAsync((): void => { + expect(comp.iiifQuery$.subscribe(result => expect(result).toEqual('test query'))); + })); + }); - describe('with IIIF viewer and search', () => { + describe('with IIIF viewer and search but no previous search query', () => { beforeEach(waitForAsync(() => { - mockRouteService.getPreviousUrl.and.returnValue(of(['/search?q=bird&motivation=painting','/item'])); + const localMockRouteService = { + getPreviousUrl(): Observable { + return of('/item'); + } + }; + const iiifEnabledMap: MetadataMap = { + 'dspace.iiif.enabled': [getIIIFEnabled(true)], + 'iiif.search.enabled': [getIIIFSearchEnabled(true)], + }; + TestBed.overrideProvider(RouteService, {useValue: localMockRouteService}); + TestBed.compileComponents(); fixture = TestBed.createComponent(PublicationComponent); comp = fixture.componentInstance; - comp.object = getItem(iiifEnabledWithSearchMap); + comp.object = getItem(iiifEnabledMap); fixture.detectChanges(); })); @@ -165,11 +217,12 @@ describe('PublicationComponent', () => { expect(fields.length).toBeGreaterThanOrEqual(1); }); - it('should call the RouteService getHistory method', () => { - expect(mockRouteService.getPreviousUrl).toHaveBeenCalled(); - }); + it('should not retrieve the query term for previous route', fakeAsync( () => { + let emitted; + comp.iiifQuery$.subscribe(result => emitted = result); + tick(10); + expect(emitted).toBeUndefined(); + })); }); - }); - diff --git a/src/app/item-page/simple/item-types/publication/publication.component.ts b/src/app/item-page/simple/item-types/publication/publication.component.ts index 5ace8d0473..ba5037a104 100644 --- a/src/app/item-page/simple/item-types/publication/publication.component.ts +++ b/src/app/item-page/simple/item-types/publication/publication.component.ts @@ -1,7 +1,7 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { ItemComponent } from '../shared/item.component'; import { ViewMode } from '../../../../core/shared/view-mode.model'; import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; +import { VersionedItemComponent } from '../versioned-item/versioned-item.component'; /** * Component that represents a publication Item page @@ -14,6 +14,6 @@ import { listableObjectComponent } from '../../../../shared/object-collection/sh templateUrl: './publication.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class PublicationComponent extends ItemComponent { +export class PublicationComponent extends VersionedItemComponent { } diff --git a/src/app/item-page/simple/item-types/shared/item-iiif-utils.ts b/src/app/item-page/simple/item-types/shared/item-iiif-utils.ts index eb7b30eb56..fe1070400e 100644 --- a/src/app/item-page/simple/item-types/shared/item-iiif-utils.ts +++ b/src/app/item-page/simple/item-types/shared/item-iiif-utils.ts @@ -1,7 +1,8 @@ import { Item } from '../../../../core/shared/item.model'; import { Observable } from 'rxjs'; -import { filter, map } from 'rxjs/operators'; +import { filter, map, take } from 'rxjs/operators'; import { RouteService } from '../../../../core/services/route.service'; +import { DefaultUrlSerializer, UrlTree } from '@angular/router'; export const isIiifEnabled = (item: Item) => { return !!item.firstMetadataValue('dspace.iiif.enabled'); @@ -21,15 +22,15 @@ export const isIiifSearchEnabled = (item: Item) => { * @param routeService */ export const getDSpaceQuery = (item: Item, routeService: RouteService): Observable => { + return routeService.getPreviousUrl().pipe( filter(r => { return r.includes('/search'); }), - map(r => { - const arr = r.split('&'); - const q = arr[1]; - const v = q.split('='); - return v[1]; - }) + map((r: string) => { + const url: UrlTree = new DefaultUrlSerializer().parse(r); + return url.queryParamMap.get('query'); + }), + take(1) ); }; diff --git a/src/app/item-page/simple/item-types/shared/item.component.spec.ts b/src/app/item-page/simple/item-types/shared/item.component.spec.ts index 6f7684b896..29c3b79719 100644 --- a/src/app/item-page/simple/item-types/shared/item.component.spec.ts +++ b/src/app/item-page/simple/item-types/shared/item.component.spec.ts @@ -4,7 +4,7 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { Store } from '@ngrx/store'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; -import {Observable, of as observableOf} from 'rxjs'; +import { Observable, of as observableOf } from 'rxjs'; import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../../../../core/cache/object-cache.service'; import { BitstreamDataService } from '../../../../core/data/bitstream-data.service'; @@ -32,24 +32,33 @@ import { ItemComponent } from './item.component'; import { createPaginatedList } from '../../../../shared/testing/utils.test'; import { RouteService } from '../../../../core/services/route.service'; import { MetadataValue } from '../../../../core/shared/metadata.models'; -import {AuthorizationDataService} from '../../../../core/data/feature-authorization/authorization-data.service'; -import {ResearcherProfileService} from '../../../../core/profile/researcher-profile.service'; +import { WorkspaceitemDataService } from '../../../../core/submission/workspaceitem-data.service'; +import { SearchService } from '../../../../core/shared/search/search.service'; +import { VersionDataService } from '../../../../core/data/version-data.service'; +import { VersionHistoryDataService } from '../../../../core/data/version-history-data.service'; +import { RouterTestingModule } from '@angular/router/testing'; +import { AuthorizationDataService } from '../../../../core/data/feature-authorization/authorization-data.service'; +import { ResearcherProfileService } from '../../../../core/profile/researcher-profile.service'; -export const iiifEnabled = Object.assign(new MetadataValue(),{ - 'value': 'true', - 'language': null, - 'authority': null, - 'confidence': -1, - 'place': 0 -}); +export function getIIIFSearchEnabled(enabled: boolean): MetadataValue { + return Object.assign(new MetadataValue(), { + 'value': enabled, + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + }); +} -export const iiifSearchEnabled = Object.assign(new MetadataValue(), { - 'value': 'true', - 'language': null, - 'authority': null, - 'confidence': -1, - 'place': 0 -}); +export function getIIIFEnabled(enabled: boolean): MetadataValue { + return Object.assign(new MetadataValue(), { + 'value': enabled, + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + }); +} export const mockRouteService = jasmine.createSpyObj('RouteService', ['getPreviousUrl']); @@ -77,12 +86,15 @@ export function getItemPageFieldsTest(mockItem: Item, component) { }); TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot({ - loader: { - provide: TranslateLoader, - useClass: TranslateLoaderMock - } - })], + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), + RouterTestingModule, + ], declarations: [component, GenericItemPageFieldComponent, TruncatePipe], providers: [ { provide: ItemDataService, useValue: {} }, @@ -96,9 +108,13 @@ export function getItemPageFieldsTest(mockItem: Item, component) { { provide: HALEndpointService, useValue: {} }, { provide: HttpClient, useValue: {} }, { provide: DSOChangeAnalyzer, useValue: {} }, + { provide: VersionHistoryDataService, useValue: {} }, + { provide: VersionDataService, useValue: {} }, { provide: NotificationsService, useValue: {} }, { provide: DefaultChangeAnalyzer, useValue: {} }, { provide: BitstreamDataService, useValue: mockBitstreamDataService }, + { provide: WorkspaceitemDataService, useValue: {} }, + { provide: SearchService, useValue: {} }, { provide: RouteService, useValue: {} }, { provide: AuthorizationDataService, useValue: authorizationService }, { provide: ResearcherProfileService, useValue: {} } diff --git a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.spec.ts b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.spec.ts index efbb4672a5..736916c940 100644 --- a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.spec.ts +++ b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.spec.ts @@ -1,10 +1,10 @@ import { HttpClient } from '@angular/common/http'; import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { Store } from '@ngrx/store'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; -import { Observable } from 'rxjs'; +import { Observable, of } from 'rxjs'; import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../../../../core/cache/object-cache.service'; import { BitstreamDataService } from '../../../../core/data/bitstream-data.service'; @@ -26,13 +26,10 @@ import { TruncatableService } from '../../../../shared/truncatable/truncatable.s import { TruncatePipe } from '../../../../shared/utils/truncate.pipe'; import { GenericItemPageFieldComponent } from '../../field-components/specific-field/generic/generic-item-page-field.component'; import { - createRelationshipsObservable, - iiifEnabled, - iiifSearchEnabled, mockRouteService + createRelationshipsObservable, getIIIFEnabled, getIIIFSearchEnabled, mockRouteService } from '../shared/item.component.spec'; import { UntypedItemComponent } from './untyped-item.component'; import { RouteService } from '../../../../core/services/route.service'; -import { of } from 'rxjs'; import { createPaginatedList } from '../../../../shared/testing/utils.test'; import { VersionHistoryDataService } from '../../../../core/data/version-history-data.service'; import { VersionDataService } from '../../../../core/data/version-data.service'; @@ -41,16 +38,6 @@ import { WorkspaceitemDataService } from '../../../../core/submission/workspacei import { SearchService } from '../../../../core/shared/search/search.service'; import { ItemVersionsSharedService } from '../../../../shared/item/item-versions/item-versions-shared.service'; - -const iiifEnabledMap: MetadataMap = { - 'dspace.iiif.enabled': [iiifEnabled], -}; - -const iiifEnabledWithSearchMap: MetadataMap = { - 'dspace.iiif.enabled': [iiifEnabled], - 'iiif.search.enabled': [iiifSearchEnabled], -}; - const noMetadata = new MetadataMap(); function getItem(metadata: MetadataMap) { @@ -108,11 +95,12 @@ describe('UntypedItemComponent', () => { schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(UntypedItemComponent, { set: {changeDetection: ChangeDetectionStrategy.Default} - }).compileComponents(); + }); })); describe('default view', () => { beforeEach(waitForAsync(() => { + TestBed.compileComponents(); fixture = TestBed.createComponent(UntypedItemComponent); comp = fixture.componentInstance; comp.object = getItem(noMetadata); @@ -159,6 +147,41 @@ describe('UntypedItemComponent', () => { describe('with IIIF viewer', () => { beforeEach(waitForAsync(() => { + const iiifEnabledMap: MetadataMap = { + 'dspace.iiif.enabled': [getIIIFEnabled(true)], + 'iiif.search.enabled': [getIIIFSearchEnabled(false)], + }; + TestBed.compileComponents(); + fixture = TestBed.createComponent(UntypedItemComponent); + comp = fixture.componentInstance; + comp.object = getItem(iiifEnabledMap); + fixture.detectChanges(); + })); + + it('should contain an iiif viewer component', () => { + const fields = fixture.debugElement.queryAll(By.css('ds-mirador-viewer')); + expect(fields.length).toBeGreaterThanOrEqual(1); + }); + it('should not retrieve the query term for previous route', (): void => { + expect(comp.iiifQuery$).toBeFalsy(); + }); + + }); + + describe('with IIIF viewer and search', () => { + + beforeEach(waitForAsync(() => { + const localMockRouteService = { + getPreviousUrl(): Observable { + return of('/search?query=test%20query&fakeParam=true'); + } + }; + const iiifEnabledMap: MetadataMap = { + 'dspace.iiif.enabled': [getIIIFEnabled(true)], + 'iiif.search.enabled': [getIIIFSearchEnabled(true)], + }; + TestBed.overrideProvider(RouteService, {useValue: localMockRouteService}); + TestBed.compileComponents(); fixture = TestBed.createComponent(UntypedItemComponent); comp = fixture.componentInstance; comp.object = getItem(iiifEnabledMap); @@ -170,15 +193,29 @@ describe('UntypedItemComponent', () => { expect(fields.length).toBeGreaterThanOrEqual(1); }); + it('should retrieve the query term for previous route', (): void => { + expect(comp.iiifQuery$.subscribe(result => expect(result).toEqual('test query'))); + }); + }); - describe('with IIIF viewer and search', () => { + describe('with IIIF viewer and search but no previous search query', () => { beforeEach(waitForAsync(() => { - mockRouteService.getPreviousUrl.and.returnValue(of(['/search?q=bird&motivation=painting','/item'])); + const localMockRouteService = { + getPreviousUrl(): Observable { + return of('/item'); + } + }; + const iiifEnabledMap: MetadataMap = { + 'dspace.iiif.enabled': [getIIIFEnabled(true)], + 'iiif.search.enabled': [getIIIFSearchEnabled(true)], + }; + TestBed.overrideProvider(RouteService, {useValue: localMockRouteService}); + TestBed.compileComponents(); fixture = TestBed.createComponent(UntypedItemComponent); comp = fixture.componentInstance; - comp.object = getItem(iiifEnabledWithSearchMap); + comp.object = getItem(iiifEnabledMap); fixture.detectChanges(); })); @@ -187,9 +224,12 @@ describe('UntypedItemComponent', () => { expect(fields.length).toBeGreaterThanOrEqual(1); }); - it('should call the RouteService getHistory method', () => { - expect(mockRouteService.getPreviousUrl).toHaveBeenCalled(); - }); + it('should not retrieve the query term for previous route', fakeAsync( () => { + let emitted; + comp.iiifQuery$.subscribe(result => emitted = result); + tick(10); + expect(emitted).toBeUndefined(); + })); }); diff --git a/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.ts b/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.ts index cd2eb3a19b..7f61cee10b 100644 --- a/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.ts +++ b/src/app/item-page/simple/item-types/versioned-item/versioned-item.component.ts @@ -62,6 +62,8 @@ export class VersionedItemComponent extends ItemComponent { activeModal.componentInstance.createVersionEvent.pipe( switchMap((summary: string) => this.versionHistoryService.createVersion(item._links.self.href, summary)), getFirstCompletedRemoteData(), + // close model (should be displaying loading/waiting indicator) when version creation failed/succeeded + tap(() => activeModal.close()), // show success/failure notification tap((res: RemoteData) => { this.itemVersionShared.notifyCreateNewVersion(res); }), // get workspace item diff --git a/src/app/page-error/page-error.component.html b/src/app/page-error/page-error.component.html new file mode 100644 index 0000000000..9a5f02600a --- /dev/null +++ b/src/app/page-error/page-error.component.html @@ -0,0 +1,10 @@ +
+

{{status}}

+

{{"error-page.description." + status | translate}}

+
+

{{"error-page." + code | translate}}

+
+

+ {{ status + ".link.home-page" | translate}} +

+
diff --git a/src/app/page-error/page-error.component.scss b/src/app/page-error/page-error.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/page-error/page-error.component.spec.ts b/src/app/page-error/page-error.component.spec.ts new file mode 100644 index 0000000000..0f876f3196 --- /dev/null +++ b/src/app/page-error/page-error.component.spec.ts @@ -0,0 +1,48 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { PageErrorComponent } from './page-error.component'; +import { ActivatedRoute } from '@angular/router'; +import { ActivatedRouteStub } from '../shared/testing/active-router.stub'; +import { of as observableOf } from 'rxjs'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { By } from '@angular/platform-browser'; +import { TranslateLoaderMock } from '../shared/testing/translate-loader.mock'; + +describe('PageErrorComponent', () => { + let component: PageErrorComponent; + let fixture: ComponentFixture; + const activatedRouteStub = Object.assign(new ActivatedRouteStub(), { + queryParams: observableOf({ + status: 401, + code: 'orcid.generic-error' + }) + }); + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ PageErrorComponent ], + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }) + ], + providers: [ + { provide: ActivatedRoute, useValue: activatedRouteStub }, + ] + }).compileComponents(); + + fixture = TestBed.createComponent(PageErrorComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + })); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should show error for 401 unauthorized', () => { + const statusElement = fixture.debugElement.query(By.css('[data-test="status"]')).nativeElement; + expect(statusElement.innerHTML).toEqual('401'); + }); +}); diff --git a/src/app/page-error/page-error.component.ts b/src/app/page-error/page-error.component.ts new file mode 100644 index 0000000000..dea1b68407 --- /dev/null +++ b/src/app/page-error/page-error.component.ts @@ -0,0 +1,27 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; + +/** + * This component representing the `PageError` DSpace page. + */ +@Component({ + selector: 'ds-page-error', + styleUrls: ['./page-error.component.scss'], + templateUrl: './page-error.component.html', + changeDetection: ChangeDetectionStrategy.Default +}) +export class PageErrorComponent { + status: number; + code: string; + /** + * Initialize instance variables + * + * @param {ActivatedRoute} activatedRoute + */ + constructor(private activatedRoute: ActivatedRoute) { + this.activatedRoute.queryParams.subscribe((params) => { + this.status = params.status; + this.code = params.code; + }); + } +} diff --git a/src/app/page-error/themed-page-error.component.ts b/src/app/page-error/themed-page-error.component.ts new file mode 100644 index 0000000000..34d29fb2a9 --- /dev/null +++ b/src/app/page-error/themed-page-error.component.ts @@ -0,0 +1,26 @@ +import { Component } from '@angular/core'; +import { ThemedComponent } from '../shared/theme-support/themed.component'; +import { PageErrorComponent } from './page-error.component'; + +/** + * Themed wrapper for PageErrorComponent + */ +@Component({ + selector: 'ds-themed-search-page', + styleUrls: [], + templateUrl: '../shared/theme-support/themed.component.html', +}) +export class ThemedPageErrorComponent extends ThemedComponent { + + protected getComponentName(): string { + return 'PageErrorComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../themes/${themeName}/app/page-error/page-error.component`); + } + + protected importUnthemedComponent(): Promise { + return import(`src/app/page-error/page-error.component`); + } +} 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/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 index 12d5d2b47d..c4bba286bf 100644 --- 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 @@ -1 +1,7 @@ - + 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 @@