Merge branch 'main' into w2p-92282_Fix-missing-auth-tokens-when-retrieving-Bitstreams

This commit is contained in:
Yura Bondarenko
2022-09-07 09:48:19 +02:00
295 changed files with 19310 additions and 4345 deletions

View File

@@ -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

View File

@@ -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.
@@ -179,7 +179,7 @@ If needing to update default configurations values for production, update local
- Update `environment.production.ts` file in `src/environment/` for a `production` environment;
The environment object is provided for use as import in code and is extended with he runtime configuration on bootstrap of the application.
The environment object is provided for use as import in code and is extended with the runtime configuration on bootstrap of the application.
> Take caution moving runtime configs into the buildtime configuration. They will be overwritten by what is defined in the runtime config on bootstrap.

View File

@@ -2,7 +2,8 @@
debug: false
# Angular Universal server settings
# NOTE: these must be 'synced' with the 'dspace.ui.url' setting in your backend's local.cfg.
# NOTE: these settings define where Node.js will start your UI application. Therefore, these
# "ui" settings usually specify a localhost port/URL which is later proxied to a public URL (using Apache or similar)
ui:
ssl: false
host: localhost
@@ -15,7 +16,8 @@ ui:
max: 500 # limit each IP to 500 requests per windowMs
# The REST API server settings
# NOTE: these must be 'synced' with the 'dspace.server.url' setting in your backend's local.cfg.
# NOTE: these settings define which (publicly available) REST API to use. They are usually
# 'synced' with the 'dspace.server.url' setting in your backend's local.cfg.
rest:
ssl: true
host: api7.dspace.org
@@ -148,6 +150,9 @@ languages:
- code: fi
label: Suomi
active: true
- code: sv
label: Svenska
active: true
- code: tr
label: Türkçe
active: true
@@ -246,3 +251,10 @@ bundle:
mediaViewer:
image: false
video: false
# Whether the end user agreement is required before users use the repository.
# If enabled, the user will be required to accept the agreement before they can use the repository.
# And whether the privacy statement should exist or not.
info:
enableEndUserAgreement: true
enablePrivacyStatement: true

View File

@@ -1,7 +1,9 @@
# Docker Compose files
***
:warning: **NOT PRODUCTION READY** The below Docker Compose resources are not guaranteed "production ready" at this time. They have been built for development/testing only. Therefore, DSpace Docker images may not be fully secured or up-to-date. While you are welcome to base your own images on these DSpace images/resources, these should not be used "as is" in any production scenario.
:warning: **THESE IMAGES ARE NOT PRODUCTION READY** The below Docker Compose images/resources were built for development/testing only. Therefore, they may not be fully secured or up-to-date, and should not be used in production.
If you wish to run DSpace on Docker in production, we recommend building your own Docker images. You are welcome to borrow ideas/concepts from the below images in doing so. But, the below images should not be used "as is" in any production scenario.
***
## 'Dockerfile' in root directory

View File

@@ -107,7 +107,7 @@
"mirador": "^3.3.0",
"mirador-dl-plugin": "^0.13.0",
"mirador-share-plugin": "^0.11.0",
"moment": "^2.29.2",
"moment": "^2.29.4",
"morgan": "^1.10.0",
"ng-mocks": "^13.1.1",
"ng2-file-upload": "1.4.0",

View File

@@ -45,7 +45,7 @@
</div>
</form>
<ds-loading *ngIf="searching$ | async"></ds-loading>
<ds-themed-loading *ngIf="searching$ | async"></ds-themed-loading>
<ds-pagination
*ngIf="(pageInfoState$ | async)?.totalElements > 0 && !(searching$ | async)"
[paginationOptions]="config"

View File

@@ -36,12 +36,12 @@
</button>
</ds-form>
<ds-loading [showMessage]="false" *ngIf="!formGroup"></ds-loading>
<ds-themed-loading [showMessage]="false" *ngIf="!formGroup"></ds-themed-loading>
<div *ngIf="epersonService.getActiveEPerson() | async">
<h5>{{messagePrefix + '.groupsEPersonIsMemberOf' | translate}}</h5>
<ds-loading [showMessage]="false" *ngIf="!(groups | async)"></ds-loading>
<ds-themed-loading [showMessage]="false" *ngIf="!(groups | async)"></ds-themed-loading>
<ds-pagination
*ngIf="(groups | async)?.payload?.totalElements > 0"

View File

@@ -33,7 +33,7 @@
</div>
</form>
<ds-loading *ngIf="loading$ | async"></ds-loading>
<ds-themed-loading *ngIf="loading$ | async"></ds-themed-loading>
<ds-pagination
*ngIf="(pageInfoState$ | async)?.totalElements > 0 && !(loading$ | async)"
[paginationOptions]="config"

View File

@@ -20,6 +20,8 @@ import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import createSpy = jasmine.createSpy;
import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils';
import { Item } from '../../core/shared/item.model';
import { ThemeService } from '../../shared/theme-support/theme.service';
import { getMockThemeService } from '../../shared/mocks/theme-service.mock';
describe('AdminSidebarComponent', () => {
let comp: AdminSidebarComponent;
@@ -60,6 +62,7 @@ describe('AdminSidebarComponent', () => {
declarations: [AdminSidebarComponent],
providers: [
Injector,
{ provide: ThemeService, useValue: getMockThemeService() },
{ provide: MenuService, useValue: menuService },
{ provide: CSSVariableService, useClass: CSSVariableServiceStub },
{ provide: AuthService, useClass: AuthServiceStub },

View File

@@ -9,6 +9,7 @@ import { CSSVariableService } from '../../shared/sass-helper/sass-helper.service
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { MenuID } from '../../shared/menu/menu-id.model';
import { ActivatedRoute } from '@angular/router';
import { ThemeService } from '../../shared/theme-support/theme.service';
/**
* Component representing the admin sidebar
@@ -56,9 +57,10 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
private variableService: CSSVariableService,
private authService: AuthService,
public authorizationService: AuthorizationDataService,
public route: ActivatedRoute
public route: ActivatedRoute,
protected themeService: ThemeService
) {
super(menuService, injector, authorizationService, route);
super(menuService, injector, authorizationService, route, themeService);
this.inFocus$ = new BehaviorSubject(false);
}

View File

@@ -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}`;

View File

@@ -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],

View File

@@ -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()
);

View File

@@ -27,7 +27,7 @@
</div>
</div>
<ds-error *ngIf="bitstreamRD?.hasFailed" message="{{'error.bitstream' | translate}}"></ds-error>
<ds-loading *ngIf="!bitstreamRD || !formatsRD || bitstreamRD?.isLoading || formatsRD?.isLoading"
message="{{'loading.bitstream' | translate}}"></ds-loading>
<ds-themed-loading *ngIf="!bitstreamRD || !formatsRD || bitstreamRD?.isLoading || formatsRD?.isLoading"
message="{{'loading.bitstream' | translate}}"></ds-themed-loading>
</div>
</ng-container>

View File

@@ -40,7 +40,7 @@
(prev)="goPrev()"
(next)="goNext()">
</ds-browse-by>
<ds-loading *ngIf="!startsWithOptions" message="{{'loading.browse-by-page' | translate}}"></ds-loading>
<ds-themed-loading *ngIf="!startsWithOptions" message="{{'loading.browse-by-page' | translate}}"></ds-themed-loading>
</div>
</section>
<ng-container *ngVar="(parent$ | async) as parent">

View File

@@ -18,6 +18,8 @@ import { BrowseByDataType, rendersBrowseBy } from '../browse-by-switcher/browse-
import { PaginationService } from '../../core/pagination/pagination.service';
import { map } from 'rxjs/operators';
export const BBM_PAGINATION_ID = 'bbm';
@Component({
selector: 'ds-browse-by-metadata-page',
styleUrls: ['./browse-by-metadata-page.component.scss'],
@@ -50,7 +52,7 @@ export class BrowseByMetadataPageComponent implements OnInit {
* The pagination config used to display the values
*/
paginationConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
id: 'bbm',
id: BBM_PAGINATION_ID,
currentPage: 1,
pageSize: 20
});

View File

@@ -1,6 +1,10 @@
import { hasNoValue } from '../../shared/empty.util';
import { InjectionToken } from '@angular/core';
import { GenericConstructor } from '../../core/shared/generic-constructor';
import {
DEFAULT_THEME,
resolveTheme
} from '../../shared/object-collection/shared/listable-object/listable-object.decorator';
export enum BrowseByDataType {
Title = 'title',
@@ -10,7 +14,7 @@ export enum BrowseByDataType {
export const DEFAULT_BROWSE_BY_TYPE = BrowseByDataType.Metadata;
export const BROWSE_BY_COMPONENT_FACTORY = new InjectionToken<(browseByType) => GenericConstructor<any>>('getComponentByBrowseByType', {
export const BROWSE_BY_COMPONENT_FACTORY = new InjectionToken<(browseByType, theme) => GenericConstructor<any>>('getComponentByBrowseByType', {
providedIn: 'root',
factory: () => getComponentByBrowseByType
});
@@ -20,13 +24,17 @@ const map = new Map();
/**
* Decorator used for rendering Browse-By pages by type
* @param browseByType The type of page
* @param theme The optional theme for the component
*/
export function rendersBrowseBy(browseByType: BrowseByDataType) {
export function rendersBrowseBy(browseByType: BrowseByDataType, theme = DEFAULT_THEME) {
return function decorator(component: any) {
if (hasNoValue(map.get(browseByType))) {
map.set(browseByType, component);
map.set(browseByType, new Map());
}
if (hasNoValue(map.get(browseByType).get(theme))) {
map.get(browseByType).set(theme, component);
} else {
throw new Error(`There can't be more than one component to render Browse-By of type "${browseByType}"`);
throw new Error(`There can't be more than one component to render Browse-By of type "${browseByType}" and theme "${theme}"`);
}
};
}
@@ -34,11 +42,16 @@ export function rendersBrowseBy(browseByType: BrowseByDataType) {
/**
* Get the component used for rendering a Browse-By page by type
* @param browseByType The type of page
* @param theme the theme to match
*/
export function getComponentByBrowseByType(browseByType) {
const comp = map.get(browseByType);
export function getComponentByBrowseByType(browseByType, theme) {
let themeMap = map.get(browseByType);
if (hasNoValue(themeMap)) {
themeMap = map.get(DEFAULT_BROWSE_BY_TYPE);
}
const comp = resolveTheme(themeMap, theme);
if (hasNoValue(comp)) {
map.get(DEFAULT_BROWSE_BY_TYPE);
return themeMap.get(DEFAULT_THEME);
}
return comp;
}

View File

@@ -4,7 +4,8 @@ import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { BROWSE_BY_COMPONENT_FACTORY, BrowseByDataType } from './browse-by-decorator';
import { BrowseDefinition } from '../../core/shared/browse-definition.model';
import { BehaviorSubject, of as observableOf } from 'rxjs';
import { BehaviorSubject } from 'rxjs';
import { ThemeService } from '../../shared/theme-support/theme.service';
describe('BrowseBySwitcherComponent', () => {
let comp: BrowseBySwitcherComponent;
@@ -44,11 +45,20 @@ describe('BrowseBySwitcherComponent', () => {
data
};
let themeService: ThemeService;
let themeName: string;
beforeEach(waitForAsync(() => {
themeName = 'dspace';
themeService = jasmine.createSpyObj('themeService', {
getThemeName: themeName,
});
TestBed.configureTestingModule({
declarations: [BrowseBySwitcherComponent],
providers: [
{ provide: ActivatedRoute, useValue: activatedRouteStub },
{ provide: ThemeService, useValue: themeService },
{ provide: BROWSE_BY_COMPONENT_FACTORY, useValue: jasmine.createSpy('getComponentByBrowseByType').and.returnValue(null) }
],
schemas: [NO_ERRORS_SCHEMA]
@@ -68,7 +78,7 @@ describe('BrowseBySwitcherComponent', () => {
});
it(`should call getComponentByBrowseByType with type "${type.dataType}"`, () => {
expect((comp as any).getComponentByBrowseByType).toHaveBeenCalledWith(type.dataType);
expect((comp as any).getComponentByBrowseByType).toHaveBeenCalledWith(type.dataType, themeName);
});
});
});

View File

@@ -5,6 +5,7 @@ import { map } from 'rxjs/operators';
import { BROWSE_BY_COMPONENT_FACTORY } from './browse-by-decorator';
import { GenericConstructor } from '../../core/shared/generic-constructor';
import { BrowseDefinition } from '../../core/shared/browse-definition.model';
import { ThemeService } from '../../shared/theme-support/theme.service';
@Component({
selector: 'ds-browse-by-switcher',
@@ -21,7 +22,8 @@ export class BrowseBySwitcherComponent implements OnInit {
browseByComponent: Observable<any>;
public constructor(protected route: ActivatedRoute,
@Inject(BROWSE_BY_COMPONENT_FACTORY) private getComponentByBrowseByType: (browseByType) => GenericConstructor<any>) {
protected themeService: ThemeService,
@Inject(BROWSE_BY_COMPONENT_FACTORY) private getComponentByBrowseByType: (browseByType, theme) => GenericConstructor<any>) {
}
/**
@@ -29,7 +31,7 @@ export class BrowseBySwitcherComponent implements OnInit {
*/
ngOnInit(): void {
this.browseByComponent = this.route.data.pipe(
map((data: { browseDefinition: BrowseDefinition }) => this.getComponentByBrowseByType(data.browseDefinition.dataType))
map((data: { browseDefinition: BrowseDefinition }) => this.getComponentByBrowseByType(data.browseDefinition.dataType, this.themeService.getThemeName()))
);
}

View File

@@ -6,7 +6,7 @@ import { CreateCollectionPageComponent } from './create-collection-page/create-c
import { AuthenticatedGuard } from '../core/auth/authenticated.guard';
import { CreateCollectionPageGuard } from './create-collection-page/create-collection-page.guard';
import { DeleteCollectionPageComponent } from './delete-collection-page/delete-collection-page.component';
import { EditItemTemplatePageComponent } from './edit-item-template-page/edit-item-template-page.component';
import { ThemedEditItemTemplatePageComponent } from './edit-item-template-page/themed-edit-item-template-page.component';
import { ItemTemplatePageResolver } from './edit-item-template-page/item-template-page.resolver';
import { CollectionBreadcrumbResolver } from '../core/breadcrumbs/collection-breadcrumb.resolver';
import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service';
@@ -52,7 +52,7 @@ import { MenuItemType } from '../shared/menu/menu-item-type.model';
},
{
path: ITEMTEMPLATE_PATH,
component: EditItemTemplatePageComponent,
component: ThemedEditItemTemplatePageComponent,
canActivate: [AuthenticatedGuard],
resolve: {
item: ItemTemplatePageResolver,

View File

@@ -56,8 +56,8 @@
</div>
<ds-error *ngIf="itemRD?.hasFailed"
message="{{'error.recent-submissions' | translate}}"></ds-error>
<ds-loading *ngIf="!itemRD || itemRD.isLoading"
message="{{'loading.recent-submissions' | translate}}"></ds-loading>
<ds-themed-loading *ngIf="!itemRD || itemRD.isLoading"
message="{{'loading.recent-submissions' | translate}}"></ds-themed-loading>
<div *ngIf="!itemRD?.isLoading && itemRD?.payload?.page.length === 0" class="alert alert-info w-100" role="alert">
{{'collection.page.browse.recent.empty' | translate}}
</div>
@@ -74,7 +74,7 @@
</div>
<ds-error *ngIf="collectionRD?.hasFailed"
message="{{'error.collection' | translate}}"></ds-error>
<ds-loading *ngIf="collectionRD?.isLoading"
message="{{'loading.collection' | translate}}"></ds-loading>
<ds-themed-loading *ngIf="collectionRD?.isLoading"
message="{{'loading.collection' | translate}}"></ds-themed-loading>
</div>
</div>

View File

@@ -8,6 +8,7 @@ import { CollectionPageRoutingModule } from './collection-page-routing.module';
import { CreateCollectionPageComponent } from './create-collection-page/create-collection-page.component';
import { DeleteCollectionPageComponent } from './delete-collection-page/delete-collection-page.component';
import { EditItemTemplatePageComponent } from './edit-item-template-page/edit-item-template-page.component';
import { ThemedEditItemTemplatePageComponent } from './edit-item-template-page/themed-edit-item-template-page.component';
import { EditItemPageModule } from '../item-page/edit-item-page/edit-item-page.module';
import { CollectionItemMapperComponent } from './collection-item-mapper/collection-item-mapper.component';
import { SearchService } from '../core/shared/search/search.service';
@@ -32,6 +33,7 @@ import { ComcolModule } from '../shared/comcol/comcol.module';
CreateCollectionPageComponent,
DeleteCollectionPageComponent,
EditItemTemplatePageComponent,
ThemedEditItemTemplatePageComponent,
CollectionItemMapperComponent
],
providers: [

View File

@@ -25,7 +25,7 @@
<label class="form-check-label"
for="externalSourceCheck">{{ 'collection.edit.tabs.source.external' | translate }}</label>
</div>
<ds-loading *ngIf="!contentSource" [message]="'loading.content-source' | translate"></ds-loading>
<ds-themed-loading *ngIf="!contentSource" [message]="'loading.content-source' | translate"></ds-themed-loading>
<h4 *ngIf="contentSource && (contentSource?.harvestType !== harvestTypeNone)">{{ 'collection.edit.tabs.source.form.head' | translate }}</h4>
</div>
<div class="row">

View File

@@ -3,10 +3,10 @@
<div class="col-12" *ngVar="(itemRD$ | async) as itemRD">
<ng-container *ngIf="itemRD?.hasSucceeded">
<h2 class="border-bottom">{{ 'collection.edit.template.head' | translate:{ collection: collection?.name } }}</h2>
<ds-item-metadata [updateService]="itemTemplateService" [item]="itemRD?.payload"></ds-item-metadata>
<ds-themed-item-metadata [updateService]="itemTemplateService" [item]="itemRD?.payload"></ds-themed-item-metadata>
<button [routerLink]="getCollectionEditUrl(collection)" class="btn btn-outline-secondary">{{ 'collection.edit.template.cancel' | translate }}</button>
</ng-container>
<ds-loading *ngIf="itemRD?.isLoading" [message]="'collection.edit.template.loading' | translate"></ds-loading>
<ds-themed-loading *ngIf="itemRD?.isLoading" [message]="'collection.edit.template.loading' | translate"></ds-themed-loading>
<ds-alert *ngIf="itemRD?.hasFailed" [type]="AlertTypeEnum.Error" [content]="'collection.edit.template.error' | translate"></ds-alert>
</div>
</div>

View File

@@ -0,0 +1,25 @@
import { Component } from '@angular/core';
import { ThemedComponent } from '../../shared/theme-support/themed.component';
import { EditItemTemplatePageComponent } from './edit-item-template-page.component';
@Component({
selector: 'ds-themed-edit-item-template-page',
styleUrls: [],
templateUrl: '../../shared/theme-support/themed.component.html',
})
/**
* Component for editing the item template of a collection
*/
export class ThemedEditItemTemplatePageComponent extends ThemedComponent<EditItemTemplatePageComponent> {
protected getComponentName(): string {
return 'EditItemTemplatePageComponent';
}
protected importThemedComponent(themeName: string): Promise<any> {
return import(`../../../themes/${themeName}/app/collection-page/edit-item-template-page/edit-item-template-page.component`);
}
protected importUnthemedComponent(): Promise<any> {
return import('./edit-item-template-page.component');
}
}

View File

@@ -1,4 +1,4 @@
<ds-loading *ngIf="(dataSource.loading$ | async) && !loadingNode" class="ds-loading"></ds-loading>
<ds-themed-loading *ngIf="(dataSource.loading$ | async) && !loadingNode" class="ds-themed-loading"></ds-themed-loading>
<cdk-tree [dataSource]="dataSource" [treeControl]="treeControl">
<!-- This is the tree node template for show more node -->
<cdk-tree-node *cdkTreeNodeDef="let node; when: isShowMore" cdkTreeNodePadding
@@ -12,7 +12,7 @@
class="btn btn-outline-primary btn-sm" role="button">
<i class="fas fa-angle-down"></i> {{ 'communityList.showMore' | translate }}
</a>
<ds-loading *ngIf="node===loadingNode && dataSource.loading$ | async" class="ds-loading"></ds-loading>
<ds-themed-loading *ngIf="node===loadingNode && dataSource.loading$ | async" class="ds-themed-loading"></ds-themed-loading>
</div>
</div>
<div class="text-muted" cdkTreeNodePadding>
@@ -57,7 +57,7 @@
<span class="{{node.isExpanded ? 'fa fa-chevron-down' : 'fa fa-chevron-right'}}"
aria-hidden="true"></span>
</button>
<ds-loading class="ds-loading"></ds-loading>
<ds-themed-loading class="ds-themed-loading"></ds-themed-loading>
</div>
</cdk-tree-node>
<!-- This is the tree node template for leaf nodes (collections and (sub)coms without children) -->

View File

@@ -41,5 +41,5 @@
</div>
<ds-error *ngIf="communityRD?.hasFailed" message="{{'error.community' | translate}}"></ds-error>
<ds-loading *ngIf="communityRD?.isLoading" message="{{'loading.community' | translate}}"></ds-loading>
<ds-themed-loading *ngIf="communityRD?.isLoading" message="{{'loading.community' | translate}}"></ds-themed-loading>
</div>

View File

@@ -9,5 +9,5 @@
</ds-viewable-collection>
</div>
<ds-error *ngIf="subCollectionsRD?.hasFailed" message="{{'error.sub-collections' | translate}}"></ds-error>
<ds-loading *ngIf="subCollectionsRD?.isLoading" message="{{'loading.sub-collections' | translate}}"></ds-loading>
<ds-themed-loading *ngIf="subCollectionsRD?.isLoading" message="{{'loading.sub-collections' | translate}}"></ds-themed-loading>
</ng-container>

View File

@@ -9,5 +9,5 @@
</ds-viewable-collection>
</div>
<ds-error *ngIf="subCommunitiesRD?.hasFailed" message="{{'error.sub-communities' | translate}}"></ds-error>
<ds-loading *ngIf="subCommunitiesRD?.isLoading" message="{{'loading.sub-communities' | translate}}"></ds-loading>
<ds-themed-loading *ngIf="subCommunitiesRD?.isLoading" message="{{'loading.sub-communities' | translate}}"></ds-themed-loading>
</ng-container>

View File

@@ -4,5 +4,6 @@ export enum AuthMethodType {
Ldap = 'ldap',
Ip = 'ip',
X509 = 'x509',
Oidc = 'oidc'
Oidc = 'oidc',
Orcid = 'orcid'
}

View File

@@ -34,6 +34,11 @@ export class AuthMethod {
this.location = location;
break;
}
case 'orcid': {
this.authMethodType = AuthMethodType.Orcid;
this.location = location;
break;
}
default: {
break;

View File

@@ -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({

View File

@@ -894,6 +894,26 @@ describe('DataService', () => {
expectObservable(done$).toBe('------(t|)', BOOLEAN);
});
});
it('should only fire for the current state of the object (instead of tracking it)', () => {
testScheduler.run(({ cold, flush }) => {
getByHrefSpy.and.returnValue(cold('a---b---c---', {
a: { requestUUIDs: ['request1'] }, // this is the state at the moment we're invalidating the cache
b: { requestUUIDs: ['request2'] }, // we shouldn't keep tracking the state
c: { requestUUIDs: ['request3'] }, // because we may invalidate when we shouldn't
}));
service.invalidateByHref('some-href');
flush();
// requests from the first state are marked as stale
expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request1');
// request from subsequent states are ignored
expect(requestService.setStaleByUUID).not.toHaveBeenCalledWith('request2');
expect(requestService.setStaleByUUID).not.toHaveBeenCalledWith('request3');
});
});
});
describe('delete', () => {

View File

@@ -595,6 +595,7 @@ export abstract class DataService<T extends CacheableObject> implements UpdateDa
const done$ = new AsyncSubject<boolean>();
this.objectCache.getByHref(href).pipe(
take(1),
switchMap((oce: ObjectCacheEntry) => observableFrom(oce.requestUUIDs).pipe(
mergeMap((requestUUID: string) => this.requestService.setStaleByUUID(requestUUID)),
toArray(),

View File

@@ -19,7 +19,7 @@ export abstract class SomeFeatureAuthorizationGuard implements CanActivate {
/**
* True when user has authorization rights for the feature and object provided
* Redirect the user to the unauthorized page when he/she's not authorized for the given feature
* Redirect the user to the unauthorized page when they are not authorized for the given feature
*/
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> {
return observableCombineLatest(this.getFeatureIDs(route, state), this.getObjectUrl(route, state), this.getEPersonUuid(route, state)).pipe(

View File

@@ -29,4 +29,5 @@ export enum FeatureID {
CanViewUsageStatistics = 'canViewUsageStatistics',
CanSendFeedback = 'canSendFeedback',
CanClaimItem = 'canClaimItem',
CanSynchronizeWithORCID = 'canSynchronizeWithORCID'
}

View File

@@ -38,7 +38,7 @@ export class ProcessDataService extends DataService<Process> {
}
/**
* Get the endpoint for a process his files
* Get the endpoint for the files of the process
* @param processId The ID of the process
*/
getFilesEndpoint(processId: string): Observable<string> {

View File

@@ -1,6 +1,7 @@
import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree } from '@angular/router';
import { Observable } from 'rxjs';
import { Observable, of as observableOf } from 'rxjs';
import { returnEndUserAgreementUrlTreeOnFalse } from '../shared/authorized.operators';
import { environment } from '../../../environments/environment';
/**
* An abstract guard for redirecting users to the user agreement page if a certain condition is met
@@ -18,6 +19,9 @@ export abstract class AbstractEndUserAgreementGuard implements CanActivate {
* when they're finished accepting the agreement
*/
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> {
if (!environment.info.enableEndUserAgreement) {
return observableOf(true);
}
return this.hasAccepted().pipe(
returnEndUserAgreementUrlTreeOnFalse(this.router, state.url)
);

View File

@@ -2,6 +2,7 @@ import { EndUserAgreementCurrentUserGuard } from './end-user-agreement-current-u
import { EndUserAgreementService } from './end-user-agreement.service';
import { Router, UrlTree } from '@angular/router';
import { of as observableOf } from 'rxjs';
import { environment } from '../../../environments/environment.test';
describe('EndUserAgreementGuard', () => {
let guard: EndUserAgreementCurrentUserGuard;
@@ -44,5 +45,24 @@ describe('EndUserAgreementGuard', () => {
});
});
});
describe('when the end user agreement is disabled', () => {
it('should return true', (done) => {
environment.info.enableEndUserAgreement = false;
guard.canActivate(undefined, Object.assign({ url: 'redirect' })).subscribe((result) => {
console.log(result);
expect(result).toEqual(true);
done();
});
});
it('should not resolve to the end user agreement page', (done) => {
environment.info.enableEndUserAgreement = false;
guard.canActivate(undefined, Object.assign({ url: 'redirect' })).subscribe((result) => {
expect(router.navigateByUrl).not.toHaveBeenCalled();
done();
});
});
});
});
});

View File

@@ -1,8 +1,9 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { Observable, of as observableOf } from 'rxjs';
import { AbstractEndUserAgreementGuard } from './abstract-end-user-agreement.guard';
import { EndUserAgreementService } from './end-user-agreement.service';
import { Router } from '@angular/router';
import { environment } from '../../../environments/environment';
/**
* A guard redirecting logged in users to the end agreement page when they haven't accepted the latest user agreement
@@ -19,6 +20,10 @@ export class EndUserAgreementCurrentUserGuard extends AbstractEndUserAgreementGu
* True when the currently logged in user has accepted the agreements or when the user is not currently authenticated
*/
hasAccepted(): Observable<boolean> {
if (!environment.info.enableEndUserAgreement) {
return observableOf(true);
}
return this.endUserAgreementService.hasCurrentUserAcceptedAgreement(true);
}

View File

@@ -55,7 +55,7 @@ export class EndUserAgreementService {
/**
* Set the current user's accepted agreement status
* When a user is authenticated, set his/her metadata to the provided value
* When a user is authenticated, set their metadata to the provided value
* When no user is authenticated, set the cookie to the provided value
* @param accepted
*/

View File

@@ -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,
};
}

View File

@@ -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');

View File

@@ -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,
};
}

View File

@@ -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');

View File

@@ -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<ResearcherProfileService>;
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<string>[] = [{
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);
});
});
});

View File

@@ -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<boolean> {
return this.getOrcidDisconnectionAllowedUsersConfiguration().pipe(
map((propertyRD: RemoteData<ConfigurationProperty>) => {
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<boolean> {
return this.getOrcidDisconnectionAllowedUsersConfiguration().pipe(
map((propertyRD: RemoteData<ConfigurationProperty>) => {
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<RemoteData<ResearcherProfile>> {
const operations: AddOperation<string>[] = [{
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<RemoteData<ResearcherProfile>> {
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<string> {
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<string[]> {
return this.configurationService.findByPropertyName('orcid.scope').pipe(
getFirstCompletedRemoteData(),
map((propertyRD: RemoteData<ConfigurationProperty>) => propertyRD.hasSucceeded ? propertyRD.payload.values : [])
);
}
private getOrcidDisconnectionAllowedUsersConfiguration(): Observable<RemoteData<ConfigurationProperty>> {
return this.configurationService.findByPropertyName('orcid.disconnection.allowed-users').pipe(
getFirstCompletedRemoteData()
);
}
}

View File

@@ -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<OrcidHistory> {
public linkPath = 'orcidhistories';
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
protected http: HttpClient,
protected comparator: DefaultChangeAnalyzer<OrcidHistory>) {
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<CoreState>,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
protected http: HttpClient,
protected comparator: DefaultChangeAnalyzer<OrcidHistory>,
protected itemService: ItemDataService ) {
this.dataService = new OrcidHistoryServiceImpl(requestService, rdbService, store, objectCache, halService,
notificationsService, http, comparator);
}
sendToORCID(orcidQueue: OrcidQueue): Observable<RemoteData<OrcidHistory>> {
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<RemoteData<OrcidHistory>>)
);
}
getEndpoint(): Observable<string> {
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<OrcidHistory>[]): Observable<RemoteData<OrcidHistory>> {
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<OrcidHistory>[]): Observable<RemoteData<PaginatedList<OrcidHistory>>> {
return this.dataService.findAllByHref(href, findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
}

View File

@@ -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<OrcidQueue> {
public linkPath = 'orcidqueues';
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
protected http: HttpClient,
protected comparator: DefaultChangeAnalyzer<OrcidQueue>) {
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<CoreState>,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
protected http: HttpClient,
protected comparator: DefaultChangeAnalyzer<OrcidQueue>,
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<RemoteData<PaginatedList<OrcidQueue>>> {
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<RemoteData<NoContent>> {
return this.dataService.delete(orcidQueueId.toString());
}
/**
* This method will set linkPath to stale
*/
clearFindByProfileItemRequests() {
this.requestService.setStaleByHrefSubstring(this.dataService.linkPath + '/search/findByProfileItem');
}
}

View File

@@ -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', () => {

View File

@@ -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 = {};

View File

@@ -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, []);
});
});
});

View File

@@ -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<ResearcherProfile> {
@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<RemoteData<ResearcherProfile>> {
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<RemoteData<ResearcherProfile>> {
return this.dataService.patch(researcherProfile, operations);
}
}

View File

@@ -39,7 +39,7 @@ export class MetadataValue implements MetadataValueInterface {
value: string;
/**
* The place of this MetadataValue within his list of metadata
* The place of this MetadataValue within its list of metadata
* This is used to render metadata in a specific custom order
*/
@autoserialize
@@ -105,7 +105,7 @@ export class MetadatumViewModel {
value: string;
/**
* The place of this MetadataValue within his list of metadata
* The place of this MetadataValue within its list of metadata
* This is used to render metadata in a specific custom order
*/
place: number;

View File

@@ -15,6 +15,7 @@ import { By } from '@angular/platform-browser';
import { ConfigurationDataService } from '../core/data/configuration-data.service';
import { ConfigurationProperty } from '../core/shared/configuration-property.model';
import { getProcessDetailRoute } from '../process-page/process-page-routing.paths';
import { HandleService } from '../shared/handle.service';
describe('CurationFormComponent', () => {
let comp: CurationFormComponent;
@@ -23,6 +24,7 @@ describe('CurationFormComponent', () => {
let scriptDataService: ScriptDataService;
let processDataService: ProcessDataService;
let configurationDataService: ConfigurationDataService;
let handleService: HandleService;
let notificationsService;
let router;
@@ -51,6 +53,10 @@ describe('CurationFormComponent', () => {
}))
});
handleService = {
normalizeHandle: (a) => a
} as any;
notificationsService = new NotificationsServiceStub();
router = new RouterStub();
@@ -58,11 +64,12 @@ describe('CurationFormComponent', () => {
imports: [TranslateModule.forRoot(), FormsModule, ReactiveFormsModule],
declarations: [CurationFormComponent],
providers: [
{provide: ScriptDataService, useValue: scriptDataService},
{provide: ProcessDataService, useValue: processDataService},
{provide: NotificationsService, useValue: notificationsService},
{provide: Router, useValue: router},
{provide: ConfigurationDataService, useValue: configurationDataService},
{ provide: ScriptDataService, useValue: scriptDataService },
{ provide: ProcessDataService, useValue: processDataService },
{ provide: NotificationsService, useValue: notificationsService },
{ provide: HandleService, useValue: handleService },
{ provide: Router, useValue: router},
{ provide: ConfigurationDataService, useValue: configurationDataService },
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
}).compileComponents();
@@ -143,4 +150,13 @@ describe('CurationFormComponent', () => {
{name: '-i', value: 'all'},
], []);
});
it(`should show an error notification and return when an invalid dsoHandle is provided`, () => {
comp.dsoHandle = 'test-handle';
spyOn(handleService, 'normalizeHandle').and.returnValue(null);
comp.submit();
expect(notificationsService.error).toHaveBeenCalled();
expect(scriptDataService.invoke).not.toHaveBeenCalled();
});
});

View File

@@ -5,7 +5,7 @@ import { getFirstCompletedRemoteData } from '../core/shared/operators';
import { find, map } from 'rxjs/operators';
import { NotificationsService } from '../shared/notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core';
import { hasValue, isEmpty, isNotEmpty } from '../shared/empty.util';
import { hasValue, isEmpty, isNotEmpty, hasNoValue } from '../shared/empty.util';
import { RemoteData } from '../core/data/remote-data';
import { Router } from '@angular/router';
import { ProcessDataService } from '../core/data/processes/process-data.service';
@@ -14,9 +14,9 @@ import { ConfigurationDataService } from '../core/data/configuration-data.servic
import { ConfigurationProperty } from '../core/shared/configuration-property.model';
import { Observable } from 'rxjs';
import { getProcessDetailRoute } from '../process-page/process-page-routing.paths';
import { HandleService } from '../shared/handle.service';
export const CURATION_CFG = 'plugin.named.org.dspace.curate.CurationTask';
/**
* Component responsible for rendering the Curation Task form
*/
@@ -39,6 +39,7 @@ export class CurationFormComponent implements OnInit {
private processDataService: ProcessDataService,
private notificationsService: NotificationsService,
private translateService: TranslateService,
private handleService: HandleService,
private router: Router
) {
}
@@ -76,13 +77,19 @@ export class CurationFormComponent implements OnInit {
const taskName = this.form.get('task').value;
let handle;
if (this.hasHandleValue()) {
handle = this.dsoHandle;
handle = this.handleService.normalizeHandle(this.dsoHandle);
if (isEmpty(handle)) {
this.notificationsService.error(this.translateService.get('curation.form.submit.error.head'),
this.translateService.get('curation.form.submit.error.invalid-handle'));
return;
}
} else {
handle = this.form.get('handle').value;
handle = this.handleService.normalizeHandle(this.form.get('handle').value);
if (isEmpty(handle)) {
handle = 'all';
}
}
this.scriptDataService.invoke('curate', [
{ name: '-t', value: taskName },
{ name: '-i', value: handle },

View File

@@ -3,6 +3,9 @@
{{'journalissue.page.titleprefix' | translate}}<ds-metadata-values [mdValues]="object?.allMetadata(['dc.title'])"></ds-metadata-values>
</h2>
<div class="pl-2 space-children-mr">
<ds-dso-page-version-button (newVersionEvent)="onCreateNewVersion()" [dso]="object"
[tooltipMsgCreate]="'item.page.version.create'"
[tooltipMsgHasDraft]="'item.page.version.hasDraft'"></ds-dso-page-version-button>
<ds-dso-page-edit-button [pageRoute]="itemPageRoute" [dso]="object" [tooltipMsg]="'journalissue.page.edit'"></ds-dso-page-edit-button>
</div>
</div>

View File

@@ -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 {
}

View File

@@ -3,6 +3,9 @@
{{'journalvolume.page.titleprefix' | translate}}<ds-metadata-values [mdValues]="object?.allMetadata(['dc.title'])"></ds-metadata-values>
</h2>
<div class="pl-2 space-children-mr">
<ds-dso-page-version-button (newVersionEvent)="onCreateNewVersion()" [dso]="object"
[tooltipMsgCreate]="'item.page.version.create'"
[tooltipMsgHasDraft]="'item.page.version.hasDraft'"></ds-dso-page-version-button>
<ds-dso-page-edit-button [pageRoute]="itemPageRoute" [dso]="object" [tooltipMsg]="'journalvolume.page.edit'"></ds-dso-page-edit-button>
</div>
</div>

View File

@@ -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 {
}

View File

@@ -3,6 +3,9 @@
{{'journal.page.titleprefix' | translate}}<ds-metadata-values [mdValues]="object?.allMetadata(['dc.title'])"></ds-metadata-values>
</h2>
<div class="pl-2 space-children-mr">
<ds-dso-page-version-button (newVersionEvent)="onCreateNewVersion()" [dso]="object"
[tooltipMsgCreate]="'item.page.version.create'"
[tooltipMsgHasDraft]="'item.page.version.hasDraft'"></ds-dso-page-version-button>
<ds-dso-page-edit-button [pageRoute]="itemPageRoute" [dso]="object" [tooltipMsg]="'journal.page.edit'"></ds-dso-page-edit-button>
</div>
</div>
@@ -44,7 +47,8 @@
<ds-tabbed-related-entities-search [item]="object"
[relationTypes]="[{
label: 'isJournalOfPublication',
filter: 'isJournalOfPublication'
filter: 'isJournalOfPublication',
configuration: 'default-relationships'
}]">
</ds-tabbed-related-entities-search>
</div>

View File

@@ -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<JournalComponent>;
@@ -65,12 +70,15 @@ describe('JournalComponent', () => {
};
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot({
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: {} }
],

View File

@@ -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 {
}

View File

@@ -3,6 +3,9 @@
{{'orgunit.page.titleprefix' | translate}}<ds-metadata-values [mdValues]="object?.allMetadata(['organization.legalName'])"></ds-metadata-values>
</h2>
<div class="pl-2 space-children-mr">
<ds-dso-page-version-button (newVersionEvent)="onCreateNewVersion()" [dso]="object"
[tooltipMsgCreate]="'item.page.version.create'"
[tooltipMsgHasDraft]="'item.page.version.hasDraft'"></ds-dso-page-version-button>
<ds-dso-page-edit-button [pageRoute]="itemPageRoute" [dso]="object" [tooltipMsg]="'orgunit.page.edit'"></ds-dso-page-edit-button>
</div>
</div>
@@ -54,12 +57,12 @@
[relationTypes]="[{
label: 'isOrgUnitOfPerson',
filter: 'isOrgUnitOfPerson',
configuration: 'person'
configuration: 'person-relationships'
},
{
label: 'isOrgUnitOfProject',
filter: 'isOrgUnitOfProject',
configuration: 'project'
configuration: 'project-relationships'
}]">
</ds-tabbed-related-entities-search>
</div>

View File

@@ -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 {
}

View File

@@ -3,6 +3,10 @@
{{'person.page.titleprefix' | translate}}<ds-metadata-values [mdValues]="getTitleMetadataValues()" [separator]="', '"></ds-metadata-values>
</h2>
<div class="pl-2 space-children-mr">
<ds-dso-page-orcid-button [pageRoute]="itemPageRoute" [dso]="object" class="mr-2"></ds-dso-page-orcid-button>
<ds-dso-page-version-button (newVersionEvent)="onCreateNewVersion()" [dso]="object"
[tooltipMsgCreate]="'item.page.version.create'"
[tooltipMsgHasDraft]="'item.page.version.hasDraft'"></ds-dso-page-version-button>
<ds-dso-page-edit-button [pageRoute]="itemPageRoute" [dso]="object" [tooltipMsg]="'person.page.edit'"></ds-dso-page-edit-button>
<ds-person-page-claim-button [object]="object"></ds-person-page-claim-button>
</div>
@@ -62,7 +66,8 @@
<ds-tabbed-related-entities-search [item]="object"
[relationTypes]="[{
label: 'isAuthorOfPublication',
filter: 'isAuthorOfPublication'
filter: 'isAuthorOfPublication',
configuration: 'default-relationships'
}]">
</ds-tabbed-related-entities-search>
</div>

View File

@@ -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;
}
}

View File

@@ -3,6 +3,9 @@
{{'project.page.titleprefix' | translate}}<ds-metadata-values [mdValues]="object?.allMetadata(['dc.title'])"></ds-metadata-values>
</h2>
<div class="pl-2 space-children-mr">
<ds-dso-page-version-button (newVersionEvent)="onCreateNewVersion()" [dso]="object"
[tooltipMsgCreate]="'item.page.version.create'"
[tooltipMsgHasDraft]="'item.page.version.hasDraft'"></ds-dso-page-version-button>
<ds-dso-page-edit-button [pageRoute]="itemPageRoute" [dso]="object" [tooltipMsg]="'project.page.edit'"></ds-dso-page-edit-button>
</div>
</div>

View File

@@ -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 {
}

View File

@@ -67,11 +67,11 @@
<a class="text-white" href="javascript:void(0);"
(click)="showCookieSettings()">{{ 'footer.link.cookies' | translate}}</a>
</li>
<li>
<li *ngIf="showPrivacyPolicy">
<a class="text-white"
routerLink="info/privacy">{{ 'footer.link.privacy-policy' | translate}}</a>
</li>
<li>
<li *ngIf="showEndUserAgreement">
<a class="text-white"
routerLink="info/end-user-agreement">{{ 'footer.link.end-user-agreement' | translate}}</a>
</li>

View File

@@ -1,6 +1,7 @@
import { Component, Optional } from '@angular/core';
import { hasValue } from '../shared/empty.util';
import { KlaroService } from '../shared/cookies/klaro.service';
import { environment } from '../../environments/environment';
@Component({
selector: 'ds-footer',
@@ -14,6 +15,8 @@ export class FooterComponent {
* A boolean representing if to show or not the top footer container
*/
showTopFooter = false;
showPrivacyPolicy = environment.info.enablePrivacyStatement;
showEndUserAgreement = environment.info.enableEndUserAgreement;
constructor(@Optional() private cookies: KlaroService) {
}

View File

@@ -8,7 +8,7 @@
<nav role="navigation" [attr.aria-label]="'nav.user.description' | translate" class="navbar navbar-light navbar-expand-md flex-shrink-0 px-0">
<ds-search-navbar></ds-search-navbar>
<ds-lang-switch></ds-lang-switch>
<ds-auth-nav-menu></ds-auth-nav-menu>
<ds-themed-auth-nav-menu></ds-themed-auth-nav-menu>
<ds-impersonate-navbar></ds-impersonate-navbar>
<div class="pl-2">
<button class="navbar-toggler" type="button" (click)="toggleNavbar()"

View File

@@ -16,7 +16,7 @@ export class HealthStatusComponent {
@Input() status: HealthStatus;
/**
* He
* Health Status
*/
HealthStatus = HealthStatus;

View File

@@ -10,4 +10,4 @@
</ds-viewable-collection>
</div>
<ds-error *ngIf="communitiesRD?.hasFailed " message="{{'error.top-level-communites' | translate}}"></ds-error>
<ds-loading *ngIf="communitiesRD?.isLoading " message="{{'loading.top-level-communities' | translate}}"></ds-loading></ng-container>
<ds-themed-loading *ngIf="communitiesRD?.isLoading " message="{{'loading.top-level-communities' | translate}}"></ds-themed-loading></ng-container>

View File

@@ -1,6 +1,8 @@
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing';
import { ImportExternalPageComponent } from './import-external-page.component';
import { ThemeService } from '../shared/theme-support/theme.service';
import { getMockThemeService } from '../shared/mocks/theme-service.mock';
describe('ImportExternalPageComponent', () => {
let component: ImportExternalPageComponent;
@@ -9,6 +11,9 @@ describe('ImportExternalPageComponent', () => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ ImportExternalPageComponent ],
providers:[
{ provide: ThemeService, useValue: getMockThemeService() },
],
schemas: [NO_ERRORS_SCHEMA]
})
.compileComponents();

View File

@@ -76,7 +76,7 @@ export class EndUserAgreementComponent implements OnInit {
/**
* Cancel the agreement
* If the user is logged in, this will log him/her out
* If the user is logged in, this will log them out
* If the user is not logged in, they will be redirected to the homepage
*/
cancel() {

View File

@@ -6,26 +6,10 @@ import { ThemedEndUserAgreementComponent } from './end-user-agreement/themed-end
import { ThemedPrivacyComponent } from './privacy/themed-privacy.component';
import { ThemedFeedbackComponent } from './feedback/themed-feedback.component';
import { FeedbackGuard } from '../core/feedback/feedback.guard';
import { environment } from '../../environments/environment';
@NgModule({
imports: [
RouterModule.forChild([
{
path: END_USER_AGREEMENT_PATH,
component: ThemedEndUserAgreementComponent,
resolve: { breadcrumb: I18nBreadcrumbResolver },
data: { title: 'info.end-user-agreement.title', breadcrumbKey: 'info.end-user-agreement' }
}
]),
RouterModule.forChild([
{
path: PRIVACY_PATH,
component: ThemedPrivacyComponent,
resolve: { breadcrumb: I18nBreadcrumbResolver },
data: { title: 'info.privacy.title', breadcrumbKey: 'info.privacy' }
}
]),
const imports = [
RouterModule.forChild([
{
path: FEEDBACK_PATH,
@@ -35,6 +19,34 @@ import { FeedbackGuard } from '../core/feedback/feedback.guard';
canActivate: [FeedbackGuard]
}
])
];
if (environment.info.enableEndUserAgreement) {
imports.push(
RouterModule.forChild([
{
path: END_USER_AGREEMENT_PATH,
component: ThemedEndUserAgreementComponent,
resolve: { breadcrumb: I18nBreadcrumbResolver },
data: { title: 'info.end-user-agreement.title', breadcrumbKey: 'info.end-user-agreement' }
}
]));
}
if (environment.info.enablePrivacyStatement) {
imports.push(
RouterModule.forChild([
{
path: PRIVACY_PATH,
component: ThemedPrivacyComponent,
resolve: { breadcrumb: I18nBreadcrumbResolver },
data: { title: 'info.privacy.title', breadcrumbKey: 'info.privacy' }
}
]));
}
@NgModule({
imports: [
...imports
]
})
/**

View File

@@ -15,6 +15,7 @@ import { ItemPrivateComponent } from './item-private/item-private.component';
import { ItemPublicComponent } from './item-public/item-public.component';
import { ItemDeleteComponent } from './item-delete/item-delete.component';
import { ItemMetadataComponent } from './item-metadata/item-metadata.component';
import { ThemedItemMetadataComponent } from './item-metadata/themed-item-metadata.component';
import { EditInPlaceFieldComponent } from './item-metadata/edit-in-place-field/edit-in-place-field.component';
import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component';
import { ItemEditBitstreamComponent } from './item-bitstreams/item-edit-bitstream/item-edit-bitstream.component';
@@ -63,6 +64,7 @@ import { ResourcePoliciesModule } from '../../shared/resource-policies/resource-
ItemDeleteComponent,
ItemStatusComponent,
ItemMetadataComponent,
ThemedItemMetadataComponent,
ItemRelationshipsComponent,
ItemBitstreamsComponent,
ItemVersionHistoryComponent,
@@ -84,7 +86,8 @@ import { ResourcePoliciesModule } from '../../shared/resource-policies/resource-
ObjectValuesPipe
],
exports: [
ItemMetadataComponent
EditInPlaceFieldComponent,
ThemedItemMetadataComponent,
]
})
export class EditItemPageModule {

View File

@@ -44,7 +44,7 @@
class="alert alert-info w-100 d-inline-block mt-4" role="alert">
{{'item.edit.bitstreams.empty' | translate}}
</div>
<ds-loading *ngIf="!bundles" message="{{'loading.bitstreams' | translate}}"></ds-loading>
<ds-themed-loading *ngIf="!bundles" message="{{'loading.bitstreams' | translate}}"></ds-themed-loading>
<div class="button-row bottom">
<div class="mt-4 float-right space-children-mr ml-gap">

View File

@@ -50,3 +50,7 @@
cursor: grabbing;
}
}
:host ::ng-deep .larger-tooltip .tooltip-inner {
max-width: 500px;
}

View File

@@ -29,5 +29,5 @@
</ng-container>
</div>
</ng-container>
<ds-loading *ngIf="(loading$ | async)" [message]="'loading.bitstreams' | translate"></ds-loading>
<ds-themed-loading *ngIf="(loading$ | async)" [message]="'loading.bitstreams' | translate"></ds-themed-loading>
</ds-pagination>

View File

@@ -9,9 +9,10 @@
</div>
<div class="{{columnSizes.columns[1].buildClasses()}} row-element d-flex align-items-center">
<div class="w-100">
<span class="text-truncate">
<div class="text-truncate" [tooltipClass]="'larger-tooltip'" placement="bottom"
[ngbTooltip]="bitstream?.firstMetadataValue('dc.description')">
{{ bitstream?.firstMetadataValue('dc.description') }}
</span>
</div>
</div>
</div>
<div class="{{columnSizes.columns[2].buildClasses()}} row-element d-flex align-items-center">

View File

@@ -0,0 +1,34 @@
import { Component, Input } from '@angular/core';
import { Item } from '../../../core/shared/item.model';
import { UpdateDataService } from '../../../core/data/update-data.service';
import { ItemMetadataComponent } from './item-metadata.component';
import { ThemedComponent } from '../../../shared/theme-support/themed.component';
@Component({
selector: 'ds-themed-item-metadata',
styleUrls: [],
templateUrl: './../../../shared/theme-support/themed.component.html',
})
/**
* Component for displaying an item's metadata edit page
*/
export class ThemedItemMetadataComponent extends ThemedComponent<ItemMetadataComponent> {
@Input() item: Item;
@Input() updateService: UpdateDataService<Item>;
protected inAndOutputNames: (keyof ItemMetadataComponent & keyof this)[] = ['item', 'updateService'];
protected getComponentName(): string {
return 'ItemMetadataComponent';
}
protected importThemedComponent(themeName: string): Promise<any> {
return import(`../../../../themes/${themeName}/app/item-page/edit-item-page/item-metadata/item-metadata.component`);
}
protected importUnthemedComponent(): Promise<any> {
return import(`./item-metadata.component`);
}
}

View File

@@ -31,5 +31,5 @@
<div *ngIf="updateValues.length === 0">{{"item.edit.relationships.no-relationships" | translate}}</div>
</ng-container>
</ng-container>
<ds-loading *ngIf="loading$ | async"></ds-loading>
<ds-themed-loading *ngIf="loading$ | async"></ds-themed-loading>
</ng-container>

View File

@@ -32,7 +32,7 @@
></ds-edit-relationship-list>
</div>
</ng-container>
<ds-loading *ngIf="!relationshipTypes"></ds-loading>
<ds-themed-loading *ngIf="!relationshipTypes"></ds-themed-loading>
</ng-container>
<div class="button-row bottom">
<div class="float-right space-children-mr ml-gap">

View File

@@ -27,6 +27,8 @@ import { createPaginatedList } from '../../../shared/testing/utils.test';
import { FieldChangeType } from '../../../core/data/object-updates/field-change-type.model';
import { RelationshipTypeService } from '../../../core/data/relationship-type.service';
import { relationshipTypes } from '../../../shared/testing/relationship-types.mock';
import { ThemeService } from '../../../shared/theme-support/theme.service';
import { getMockThemeService } from '../../../shared/mocks/theme-service.mock';
let comp: any;
let fixture: ComponentFixture<ItemRelationshipsComponent>;
@@ -211,6 +213,7 @@ describe('ItemRelationshipsComponent', () => {
imports: [SharedModule, TranslateModule.forRoot()],
declarations: [ItemRelationshipsComponent],
providers: [
{ provide: ThemeService, useValue: getMockThemeService() },
{ provide: ItemDataService, useValue: itemService },
{ provide: ObjectUpdatesService, useValue: objectUpdatesService },
{ provide: Router, useValue: router },

View File

@@ -30,7 +30,7 @@ export class CollectionsComponent implements OnInit {
label = 'item.page.collections';
separator = '<br/>';
@Input() separator = '<br/>';
/**
* Amount of mapped collections that should be fetched at once.

View File

@@ -43,5 +43,5 @@
</div>
</div>
<ds-error *ngIf="itemRD?.hasFailed" message="{{'error.item' | translate}}"></ds-error>
<ds-loading *ngIf="itemRD?.isLoading" message="{{'loading.item' | translate}}"></ds-loading>
<ds-themed-loading *ngIf="itemRD?.isLoading" message="{{'loading.item' | translate}}"></ds-themed-loading>
</div>

View File

@@ -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';

View File

@@ -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
]
})

View File

@@ -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,

View File

@@ -1,9 +1,9 @@
<ng-container *ngVar="mediaList$ | async as mediaList">
<ds-loading
<ds-themed-loading
*ngIf="isLoading"
message="{{ 'loading.default' | translate }}"
[showMessage]="false"
></ds-loading>
></ds-themed-loading>
<div class="media-viewer" *ngIf="!isLoading">
<ng-container *ngIf="mediaList.length > 0">
<ng-container *ngIf="videoOptions">

View File

@@ -110,7 +110,7 @@ describe('MediaViewerComponent', () => {
});
it('should display a loading component', () => {
const loading = fixture.debugElement.query(By.css('ds-loading'));
const loading = fixture.debugElement.query(By.css('ds-themed-loading'));
expect(loading.nativeElement).toBeDefined();
});
});

View File

@@ -0,0 +1,84 @@
<div class="container mb-5">
<h2>{{'person.orcid.registry.auth' | translate}}</h2>
<ng-container *ngIf="(isLinkedToOrcid() | async); then orcidLinked; else orcidNotLinked"></ng-container>
</div>
<ng-template #orcidLinked>
<div data-test="orcidLinked">
<div class="row">
<div *ngIf="(hasOrcidAuthorizations() | async)" class="col-sm-6 mb-3" data-test="hasOrcidAuthorizations">
<div class="card h-100">
<div class="card-header">{{ 'person.page.orcid.granted-authorizations'| translate }}</div>
<div class="card-body">
<div class="container p-0">
<ul>
<li *ngFor="let auth of (getOrcidAuthorizations() | async)" data-test="orcidAuthorization">
{{getAuthorizationDescription(auth) | translate}}
</li>
</ul>
</div>
</div>
</div>
</div>
<div class="col-sm-6 mb-3">
<div class="card h-100">
<div class="card-header">{{ 'person.page.orcid.missing-authorizations'| translate }}</div>
<div class="card-body">
<div class="container">
<ds-alert *ngIf="!(hasMissingOrcidAuthorizations() | async)" [type]="'alert-success'" data-test="noMissingOrcidAuthorizations">
{{'person.page.orcid.no-missing-authorizations-message' | translate}}
</ds-alert>
<ds-alert *ngIf="(hasMissingOrcidAuthorizations() | async)" [type]="'alert-warning'" data-test="missingOrcidAuthorizations">
{{'person.page.orcid.missing-authorizations-message' | translate}}
<ul>
<li *ngFor="let auth of (getMissingOrcidAuthorizations() | async)" data-test="missingOrcidAuthorization">
{{getAuthorizationDescription(auth) | translate }}
</li>
</ul>
</ds-alert>
</div>
</div>
</div>
</div>
</div>
<ds-alert *ngIf="(onlyAdminCanDisconnectProfileFromOrcid() | async) && !(ownerCanDisconnectProfileFromOrcid() | async)"
[type]="'alert-warning'" data-test="unlinkOnlyAdmin">
{{ 'person.page.orcid.remove-orcid-message' | translate}}
</ds-alert>
<div class="row" *ngIf="(ownerCanDisconnectProfileFromOrcid() | async)" data-test="unlinkOwner">
<div class="col">
<button type="submit" class="btn btn-danger float-right" (click)="unlinkOrcid()"
[disabled]="(unlinkProcessing | async)">
<span *ngIf="!(unlinkProcessing | async)"><i
class="fas fa-unlink"></i> {{ 'person.page.orcid.unlink' | translate }}</span>
<span *ngIf="(unlinkProcessing | async)"><i
class='fas fa-circle-notch fa-spin'></i> {{'person.page.orcid.unlink.processing' | translate}}</span>
</button>
<button *ngIf="(hasMissingOrcidAuthorizations() | async)" type="submit"
class="btn btn-primary float-right" (click)="linkOrcid()">
<span><i class="fas fa-check"></i> {{ 'person.page.orcid.grant-authorizations' | translate }}</span>
</button>
</div>
</div>
</div>
</ng-template>
<ng-template #orcidNotLinked>
<div data-test="orcidNotLinked">
<div class="row">
<div class="col-2"><img alt="orcid-logo" src="../../../../assets/images/orcid.logo.icon.svg"/></div>
<div class="col">
<ds-alert [type]="'alert-info'">{{ getOrcidNotLinkedMessage() | async }}</ds-alert>
</div>
</div>
<div class="row">
<div class="col">
<button class="btn btn-primary float-right" (click)="linkOrcid()">
<i class="fas fa-link"></i>
{{'person.page.orcid.link' | translate}}
</button>
</div>
</div>
</div>
</ng-template>

View File

@@ -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<OrcidAuthComponent>;
let scheduler: TestScheduler;
let orcidAuthService: jasmine.SpyObj<OrcidAuthService>;
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();
}));
});
});
});

View File

@@ -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<string[]> = new BehaviorSubject<string[]>([]);
/**
* The list of all orcid authorization scopes missing in the orcid profile
*/
missingAuthorizationScopes: BehaviorSubject<string[]> = new BehaviorSubject<string[]>([]);
/**
* The list of all orcid authorization scopes available
*/
orcidAuthorizationScopes: BehaviorSubject<string[]> = new BehaviorSubject<string[]>([]);
/**
* A boolean representing if unlink operation is processing
*/
unlinkProcessing: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
/**
* A boolean representing if orcid profile is linked
*/
private isOrcidLinked$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
/**
* A boolean representing if only admin can disconnect orcid profile
*/
private onlyAdminCanDisconnectProfileFromOrcid$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
/**
* A boolean representing if owner can disconnect orcid profile
*/
private ownerCanDisconnectProfileFromOrcid$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
/**
* An event emitted when orcid profile is unliked successfully
*/
@Output() unlink: EventEmitter<void> = new EventEmitter<void>();
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<boolean> {
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<string[]> {
return this.profileAuthorizationScopes.asObservable();
}
/**
* Check if the list of exposed orcid authorization scopes for the orcid profile has values
*/
hasMissingOrcidAuthorizations(): Observable<boolean> {
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<string[]> {
return this.profileAuthorizationScopes.asObservable();
}
/**
* Return a boolean representing if orcid profile is linked
*/
isLinkedToOrcid(): Observable<boolean> {
return this.isOrcidLinked$.asObservable();
}
getOrcidNotLinkedMessage(): Observable<string> {
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<boolean> {
return this.onlyAdminCanDisconnectProfileFromOrcid$.asObservable();
}
/**
* Return a boolean representing if owner can disconnect orcid profile
*/
ownerCanDisconnectProfileFromOrcid(): Observable<boolean> {
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<ResearcherProfile>) => {
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));
}
}

View File

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

View File

@@ -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<OrcidPageComponent>;
let scheduler: TestScheduler;
let authService: jasmine.SpyObj<AuthService>;
let routeStub: jasmine.SpyObj<ActivatedRouteStub>;
let routeData: any;
let itemDataService: jasmine.SpyObj<ItemDataService>;
let orcidAuthService: jasmine.SpyObj<OrcidAuthService>;
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();
});
});
});
});

Some files were not shown because too many files have changed in this diff Show More