diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index eeddb37441..04d426d091 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,7 +22,7 @@ jobs: strategy: # Create a matrix of Node versions to test against (in parallel) matrix: - node-version: [12.x, 14.x] + node-version: [14.x, 16.x] # Do NOT exit immediately if one matrix job fails fail-fast: false # These are the actual CI steps to perform per job @@ -82,11 +82,11 @@ jobs: run: yarn run test:headless # NOTE: Angular CLI only supports code coverage for specs. See https://github.com/angular/angular-cli/issues/6286 - # Upload coverage reports to Codecov (for Node v12 only) + # Upload coverage reports to Codecov (for one version of Node only) # https://github.com/codecov/codecov-action - name: Upload coverage to Codecov.io uses: codecov/codecov-action@v2 - if: matrix.node-version == '12.x' + if: matrix.node-version == '16.x' # Using docker-compose start backend using CI configuration # and load assetstore from a cached copy diff --git a/README.md b/README.md index 0e26d9e492..837cb48004 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ https://wiki.lyrasis.org/display/DSDOC7x/Installing+DSpace Quick start ----------- -**Ensure you're running [Node](https://nodejs.org) `v12.x`, `v14.x` or `v16.x`, [npm](https://www.npmjs.com/) >= `v5.x` and [yarn](https://yarnpkg.com) == `v1.x`** +**Ensure you're running [Node](https://nodejs.org) `v14.x` or `v16.x`, [npm](https://www.npmjs.com/) >= `v5.x` and [yarn](https://yarnpkg.com) == `v1.x`** ```bash # clone the repo @@ -90,7 +90,7 @@ Requirements ------------ - [Node.js](https://nodejs.org) and [yarn](https://yarnpkg.com) -- Ensure you're running node `v12.x`, `v14.x` or `v16.x` and yarn == `v1.x` +- Ensure you're running node `v14.x` or `v16.x` and yarn == `v1.x` If you have [`nvm`](https://github.com/creationix/nvm#install-script) or [`nvm-windows`](https://github.com/coreybutler/nvm-windows) installed, which is highly recommended, you can run `nvm install --lts && nvm use` to install and start using the latest Node LTS. @@ -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. diff --git a/config/config.example.yml b/config/config.example.yml index 898b47784f..ae733e0be5 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -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 diff --git a/docker/README.md b/docker/README.md index d6fe0e6646..1a9fee0a81 100644 --- a/docker/README.md +++ b/docker/README.md @@ -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 diff --git a/package.json b/package.json index dbb4cca8a5..32832460a2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/access-control/epeople-registry/epeople-registry.component.html b/src/app/access-control/epeople-registry/epeople-registry.component.html index 7ef02a76cf..2d87f21d26 100644 --- a/src/app/access-control/epeople-registry/epeople-registry.component.html +++ b/src/app/access-control/epeople-registry/epeople-registry.component.html @@ -45,7 +45,7 @@ - + - +
{{messagePrefix + '.groupsEPersonIsMemberOf' | translate}}
- + - + { 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 }, diff --git a/src/app/admin/admin-sidebar/admin-sidebar.component.ts b/src/app/admin/admin-sidebar/admin-sidebar.component.ts index b244039a25..6029387bfc 100644 --- a/src/app/admin/admin-sidebar/admin-sidebar.component.ts +++ b/src/app/admin/admin-sidebar/admin-sidebar.component.ts @@ -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); } diff --git a/src/app/app-routing-paths.ts b/src/app/app-routing-paths.ts index b54036cf5a..e9a6376884 100644 --- a/src/app/app-routing-paths.ts +++ b/src/app/app-routing-paths.ts @@ -107,6 +107,8 @@ export function getPageInternalServerErrorRoute() { return `/${INTERNAL_SERVER_ERROR}`; } +export const ERROR_PAGE = 'error'; + export const INFO_MODULE_PATH = 'info'; export function getInfoModulePath() { return `/${INFO_MODULE_PATH}`; diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index d00e1d7b0a..d426b041ce 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -10,6 +10,7 @@ import { ACCESS_CONTROL_MODULE_PATH, ADMIN_MODULE_PATH, BITSTREAM_MODULE_PATH, + ERROR_PAGE, FORBIDDEN_PATH, FORGOT_PASSWORD_PATH, HEALTH_PAGE_PATH, @@ -38,11 +39,13 @@ import { } from './page-internal-server-error/themed-page-internal-server-error.component'; import { ServerCheckGuard } from './core/server-check/server-check.guard'; import { MenuResolver } from './menu.resolver'; +import { ThemedPageErrorComponent } from './page-error/themed-page-error.component'; @NgModule({ imports: [ RouterModule.forRoot([ { path: INTERNAL_SERVER_ERROR, component: ThemedPageInternalServerErrorComponent }, + { path: ERROR_PAGE , component: ThemedPageErrorComponent }, { path: '', canActivate: [AuthBlockingGuard], diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 2c743e219d..ee8c4d685f 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -22,7 +22,7 @@ import { import { isEqual } from 'lodash'; import { BehaviorSubject, Observable, of } from 'rxjs'; import { select, Store } from '@ngrx/store'; -import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { NgbModal, NgbModalConfig } from '@ng-bootstrap/ng-bootstrap'; import { TranslateService } from '@ngx-translate/core'; import { Angulartics2GoogleAnalytics } from 'angulartics2'; @@ -49,6 +49,7 @@ import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service'; import { IdleModalComponent } from './shared/idle-modal/idle-modal.component'; import { getDefaultThemeConfig } from '../config/config.util'; import { AppConfig, APP_CONFIG } from 'src/config/app-config.interface'; +import { ModalBeforeDismiss } from './shared/interfaces/modal-before-dismiss.interface'; @Component({ selector: 'ds-app', @@ -106,6 +107,7 @@ export class AppComponent implements OnInit, AfterViewInit { private localeService: LocaleService, private breadcrumbsService: BreadcrumbsService, private modalService: NgbModal, + private modalConfig: NgbModalConfig, @Optional() private cookiesService: KlaroService, @Optional() private googleAnalyticsService: GoogleAnalyticsService, ) { @@ -166,6 +168,16 @@ export class AppComponent implements OnInit, AfterViewInit { } ngOnInit() { + /** Implement behavior for interface {@link ModalBeforeDismiss} */ + this.modalConfig.beforeDismiss = async function () { + if (typeof this?.componentInstance?.beforeDismiss === 'function') { + return this.componentInstance.beforeDismiss(); + } + + // fall back to default behavior + return true; + }; + this.isAuthBlocking$ = this.store.pipe(select(isAuthenticationBlocking)).pipe( distinctUntilChanged() ); diff --git a/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.html b/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.html index 4d3b948a58..6454198340 100644 --- a/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.html +++ b/src/app/bitstream-page/edit-bitstream-page/edit-bitstream-page.component.html @@ -27,7 +27,7 @@
- + diff --git a/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.html b/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.html index 107ef99b3e..eb15ac9523 100644 --- a/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.html +++ b/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.html @@ -40,7 +40,7 @@ (prev)="goPrev()" (next)="goNext()"> - + diff --git a/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.ts b/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.ts index 8f9eaa16bc..c685fe31a7 100644 --- a/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.ts +++ b/src/app/browse-by/browse-by-metadata-page/browse-by-metadata-page.component.ts @@ -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 }); diff --git a/src/app/browse-by/browse-by-switcher/browse-by-decorator.ts b/src/app/browse-by/browse-by-switcher/browse-by-decorator.ts index 1ebaa7face..ceb4c6a6c6 100644 --- a/src/app/browse-by/browse-by-switcher/browse-by-decorator.ts +++ b/src/app/browse-by/browse-by-switcher/browse-by-decorator.ts @@ -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>('getComponentByBrowseByType', { +export const BROWSE_BY_COMPONENT_FACTORY = new InjectionToken<(browseByType, theme) => GenericConstructor>('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; } diff --git a/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.spec.ts b/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.spec.ts index cb82ddb7c4..c2e1c9cb68 100644 --- a/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.spec.ts +++ b/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.spec.ts @@ -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); }); }); }); diff --git a/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.ts b/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.ts index cf4c1d9856..0d3a35bebf 100644 --- a/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.ts +++ b/src/app/browse-by/browse-by-switcher/browse-by-switcher.component.ts @@ -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; public constructor(protected route: ActivatedRoute, - @Inject(BROWSE_BY_COMPONENT_FACTORY) private getComponentByBrowseByType: (browseByType) => GenericConstructor) { + protected themeService: ThemeService, + @Inject(BROWSE_BY_COMPONENT_FACTORY) private getComponentByBrowseByType: (browseByType, theme) => GenericConstructor) { } /** @@ -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())) ); } diff --git a/src/app/collection-page/collection-page-routing.module.ts b/src/app/collection-page/collection-page-routing.module.ts index 29e342f140..678c745c01 100644 --- a/src/app/collection-page/collection-page-routing.module.ts +++ b/src/app/collection-page/collection-page-routing.module.ts @@ -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, diff --git a/src/app/collection-page/collection-page.component.html b/src/app/collection-page/collection-page.component.html index 72033649b0..c1df38f793 100644 --- a/src/app/collection-page/collection-page.component.html +++ b/src/app/collection-page/collection-page.component.html @@ -56,8 +56,8 @@ - + @@ -74,7 +74,7 @@ - + diff --git a/src/app/collection-page/collection-page.module.ts b/src/app/collection-page/collection-page.module.ts index 3652823200..c35ebf9021 100644 --- a/src/app/collection-page/collection-page.module.ts +++ b/src/app/collection-page/collection-page.module.ts @@ -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: [ diff --git a/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.html b/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.html index b478456049..d7b0d0c475 100644 --- a/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.html +++ b/src/app/collection-page/edit-collection-page/collection-source/collection-source.component.html @@ -25,7 +25,7 @@ - +

{{ 'collection.edit.tabs.source.form.head' | translate }}

diff --git a/src/app/collection-page/edit-item-template-page/edit-item-template-page.component.html b/src/app/collection-page/edit-item-template-page/edit-item-template-page.component.html index f8c5c92e96..0403a7ecad 100644 --- a/src/app/collection-page/edit-item-template-page/edit-item-template-page.component.html +++ b/src/app/collection-page/edit-item-template-page/edit-item-template-page.component.html @@ -3,10 +3,10 @@

{{ 'collection.edit.template.head' | translate:{ collection: collection?.name } }}

- +
- +
diff --git a/src/app/collection-page/edit-item-template-page/themed-edit-item-template-page.component.ts b/src/app/collection-page/edit-item-template-page/themed-edit-item-template-page.component.ts new file mode 100644 index 0000000000..b53f4e6c45 --- /dev/null +++ b/src/app/collection-page/edit-item-template-page/themed-edit-item-template-page.component.ts @@ -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 { + protected getComponentName(): string { + return 'EditItemTemplatePageComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../themes/${themeName}/app/collection-page/edit-item-template-page/edit-item-template-page.component`); + } + + protected importUnthemedComponent(): Promise { + return import('./edit-item-template-page.component'); + } +} diff --git a/src/app/community-list-page/community-list/community-list.component.html b/src/app/community-list-page/community-list/community-list.component.html index 50bbb9a20e..821cb58473 100644 --- a/src/app/community-list-page/community-list/community-list.component.html +++ b/src/app/community-list-page/community-list/community-list.component.html @@ -1,4 +1,4 @@ - + {{ 'communityList.showMore' | translate }} - +
@@ -57,7 +57,7 @@ - +
diff --git a/src/app/community-page/community-page.component.html b/src/app/community-page/community-page.component.html index fa2408d298..6b277bd07f 100644 --- a/src/app/community-page/community-page.component.html +++ b/src/app/community-page/community-page.component.html @@ -41,5 +41,5 @@ - + diff --git a/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.html b/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.html index 9928ebd18a..69f16ee3ac 100644 --- a/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.html +++ b/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.html @@ -9,5 +9,5 @@ - +
diff --git a/src/app/community-page/sub-community-list/community-page-sub-community-list.component.html b/src/app/community-page/sub-community-list/community-page-sub-community-list.component.html index 2d14dce60a..be2788a9f4 100644 --- a/src/app/community-page/sub-community-list/community-page-sub-community-list.component.html +++ b/src/app/community-page/sub-community-list/community-page-sub-community-list.component.html @@ -9,5 +9,5 @@ - + diff --git a/src/app/core/auth/models/auth.method-type.ts b/src/app/core/auth/models/auth.method-type.ts index 9d999c4c3f..594d6d8b39 100644 --- a/src/app/core/auth/models/auth.method-type.ts +++ b/src/app/core/auth/models/auth.method-type.ts @@ -4,5 +4,6 @@ export enum AuthMethodType { Ldap = 'ldap', Ip = 'ip', X509 = 'x509', - Oidc = 'oidc' + Oidc = 'oidc', + Orcid = 'orcid' } diff --git a/src/app/core/auth/models/auth.method.ts b/src/app/core/auth/models/auth.method.ts index 5a362e8606..0579ae0cd1 100644 --- a/src/app/core/auth/models/auth.method.ts +++ b/src/app/core/auth/models/auth.method.ts @@ -34,6 +34,11 @@ export class AuthMethod { this.location = location; break; } + case 'orcid': { + this.authMethodType = AuthMethodType.Orcid; + this.location = location; + break; + } default: { break; diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index fd219537a0..b16930e819 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -38,7 +38,7 @@ import { SubmissionSectionModel } from './config/models/config-submission-sectio import { SubmissionUploadsModel } from './config/models/config-submission-uploads.model'; import { SubmissionFormsConfigService } from './config/submission-forms-config.service'; import { coreEffects } from './core.effects'; -import { coreReducers} from './core.reducers'; +import { coreReducers } from './core.reducers'; import { BitstreamFormatDataService } from './data/bitstream-format-data.service'; import { CollectionDataService } from './data/collection-data.service'; import { CommunityDataService } from './data/community-data.service'; @@ -132,11 +132,15 @@ import { Feature } from './shared/feature.model'; import { Authorization } from './shared/authorization.model'; import { FeatureDataService } from './data/feature-authorization/feature-data.service'; import { AuthorizationDataService } from './data/feature-authorization/authorization-data.service'; -import { SiteAdministratorGuard } from './data/feature-authorization/feature-authorization-guard/site-administrator.guard'; +import { + SiteAdministratorGuard +} from './data/feature-authorization/feature-authorization-guard/site-administrator.guard'; import { Registration } from './shared/registration.model'; import { MetadataSchemaDataService } from './data/metadata-schema-data.service'; import { MetadataFieldDataService } from './data/metadata-field-data.service'; -import { DsDynamicTypeBindRelationService } from '../shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service'; +import { + DsDynamicTypeBindRelationService +} from '../shared/form/builder/ds-dynamic-form-ui/ds-dynamic-type-bind-relation.service'; import { TokenResponseParsingService } from './auth/token-response-parsing.service'; import { SubmissionCcLicenseDataService } from './submission/submission-cc-license-data.service'; import { SubmissionCcLicence } from './submission/models/submission-cc-license.model'; @@ -169,6 +173,11 @@ import { LinkHeadService } from './services/link-head.service'; import { ResearcherProfileService } from './profile/researcher-profile.service'; import { ProfileClaimService } from '../profile-page/profile-claim/profile-claim.service'; import { ResearcherProfile } from './profile/model/researcher-profile.model'; +import { OrcidQueueService } from './orcid/orcid-queue.service'; +import { OrcidHistoryDataService } from './orcid/orcid-history-data.service'; +import { OrcidQueue } from './orcid/model/orcid-queue.model'; +import { OrcidHistory } from './orcid/model/orcid-history.model'; +import { OrcidAuthService } from './orcid/orcid-auth.service'; /** * When not in production, endpoint responses can be mocked for testing purposes @@ -296,7 +305,10 @@ const PROVIDERS = [ GroupDataService, FeedbackDataService, ResearcherProfileService, - ProfileClaimService + ProfileClaimService, + OrcidAuthService, + OrcidQueueService, + OrcidHistoryDataService, ]; /** @@ -358,7 +370,10 @@ export const models = SearchConfig, SubmissionAccessesModel, AccessStatusObject, - ResearcherProfile + ResearcherProfile, + OrcidQueue, + OrcidHistory, + AccessStatusObject ]; @NgModule({ diff --git a/src/app/core/data/data.service.spec.ts b/src/app/core/data/data.service.spec.ts index 64efd58418..dc661e12d7 100644 --- a/src/app/core/data/data.service.spec.ts +++ b/src/app/core/data/data.service.spec.ts @@ -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', () => { diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index 1cd9731a65..6176694d9d 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -595,6 +595,7 @@ export abstract class DataService implements UpdateDa const done$ = new AsyncSubject(); this.objectCache.getByHref(href).pipe( + take(1), switchMap((oce: ObjectCacheEntry) => observableFrom(oce.requestUUIDs).pipe( mergeMap((requestUUID: string) => this.requestService.setStaleByUUID(requestUUID)), toArray(), diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/some-feature-authorization.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/some-feature-authorization.guard.ts index 18c7f8fb43..b909640ea6 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/some-feature-authorization.guard.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/some-feature-authorization.guard.ts @@ -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 { return observableCombineLatest(this.getFeatureIDs(route, state), this.getObjectUrl(route, state), this.getEPersonUuid(route, state)).pipe( diff --git a/src/app/core/data/feature-authorization/feature-id.ts b/src/app/core/data/feature-authorization/feature-id.ts index 59514b65cd..3cb18bf515 100644 --- a/src/app/core/data/feature-authorization/feature-id.ts +++ b/src/app/core/data/feature-authorization/feature-id.ts @@ -29,4 +29,5 @@ export enum FeatureID { CanViewUsageStatistics = 'canViewUsageStatistics', CanSendFeedback = 'canSendFeedback', CanClaimItem = 'canClaimItem', + CanSynchronizeWithORCID = 'canSynchronizeWithORCID' } diff --git a/src/app/core/data/processes/process-data.service.ts b/src/app/core/data/processes/process-data.service.ts index 81b4cbd503..cb00ac529e 100644 --- a/src/app/core/data/processes/process-data.service.ts +++ b/src/app/core/data/processes/process-data.service.ts @@ -38,7 +38,7 @@ export class ProcessDataService extends DataService { } /** - * 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 { diff --git a/src/app/core/end-user-agreement/abstract-end-user-agreement.guard.ts b/src/app/core/end-user-agreement/abstract-end-user-agreement.guard.ts index b9f134f946..076df8ebc9 100644 --- a/src/app/core/end-user-agreement/abstract-end-user-agreement.guard.ts +++ b/src/app/core/end-user-agreement/abstract-end-user-agreement.guard.ts @@ -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 { + if (!environment.info.enableEndUserAgreement) { + return observableOf(true); + } return this.hasAccepted().pipe( returnEndUserAgreementUrlTreeOnFalse(this.router, state.url) ); diff --git a/src/app/core/end-user-agreement/end-user-agreement-current-user.guard.spec.ts b/src/app/core/end-user-agreement/end-user-agreement-current-user.guard.spec.ts index 75b8f6089e..40728ab601 100644 --- a/src/app/core/end-user-agreement/end-user-agreement-current-user.guard.spec.ts +++ b/src/app/core/end-user-agreement/end-user-agreement-current-user.guard.spec.ts @@ -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(); + }); + }); + }); }); }); diff --git a/src/app/core/end-user-agreement/end-user-agreement-current-user.guard.ts b/src/app/core/end-user-agreement/end-user-agreement-current-user.guard.ts index 2c04186f34..a79e12cc32 100644 --- a/src/app/core/end-user-agreement/end-user-agreement-current-user.guard.ts +++ b/src/app/core/end-user-agreement/end-user-agreement-current-user.guard.ts @@ -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 { + if (!environment.info.enableEndUserAgreement) { + return observableOf(true); + } + return this.endUserAgreementService.hasCurrentUserAcceptedAgreement(true); } diff --git a/src/app/core/end-user-agreement/end-user-agreement.service.ts b/src/app/core/end-user-agreement/end-user-agreement.service.ts index 8a18488660..3841abb5d4 100644 --- a/src/app/core/end-user-agreement/end-user-agreement.service.ts +++ b/src/app/core/end-user-agreement/end-user-agreement.service.ts @@ -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 */ diff --git a/src/app/core/orcid/model/orcid-history.model.ts b/src/app/core/orcid/model/orcid-history.model.ts new file mode 100644 index 0000000000..ef8f30e0a3 --- /dev/null +++ b/src/app/core/orcid/model/orcid-history.model.ts @@ -0,0 +1,89 @@ +import { autoserialize, deserialize } from 'cerialize'; +import { typedObject } from '../../cache/builders/build-decorators'; +import { HALLink } from '../../shared/hal-link.model'; +import { ResourceType } from '../../shared/resource-type'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { ORCID_HISTORY } from './orcid-history.resource-type'; +import { CacheableObject } from '../../cache/cacheable-object.model'; + +/** + * Class the represents a Orcid History. + */ +@typedObject +export class OrcidHistory extends CacheableObject { + + static type = ORCID_HISTORY; + + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The identifier of this Orcid History record + */ + @autoserialize + id: number; + + /** + * The name of the related entity + */ + @autoserialize + entityName: string; + + /** + * The identifier of the profileItem of this Orcid History record. + */ + @autoserialize + profileItemId: string; + + /** + * The identifier of the entity related to this Orcid History record. + */ + @autoserialize + entityId: string; + + /** + * The type of the entity related to this Orcid History record. + */ + @autoserialize + entityType: string; + + /** + * The response status coming from ORCID api. + */ + @autoserialize + status: number; + + /** + * The putCode assigned by ORCID to the entity. + */ + @autoserialize + putCode: string; + + /** + * The last send attempt timestamp. + */ + lastAttempt: string; + + /** + * The success send attempt timestamp. + */ + successAttempt: string; + + /** + * The response coming from ORCID. + */ + responseMessage: string; + + /** + * The {@link HALLink}s for this Orcid History record + */ + @deserialize + _links: { + self: HALLink, + }; + +} diff --git a/src/app/core/orcid/model/orcid-history.resource-type.ts b/src/app/core/orcid/model/orcid-history.resource-type.ts new file mode 100644 index 0000000000..45da8cbf68 --- /dev/null +++ b/src/app/core/orcid/model/orcid-history.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from '../../shared/resource-type'; + +/** + * The resource type for OrcidHistory + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const ORCID_HISTORY = new ResourceType('orcidhistory'); diff --git a/src/app/core/orcid/model/orcid-queue.model.ts b/src/app/core/orcid/model/orcid-queue.model.ts new file mode 100644 index 0000000000..2a1c3f1d82 --- /dev/null +++ b/src/app/core/orcid/model/orcid-queue.model.ts @@ -0,0 +1,68 @@ +import { autoserialize, deserialize } from 'cerialize'; +import { typedObject } from '../../cache/builders/build-decorators'; +import { HALLink } from '../../shared/hal-link.model'; +import { ResourceType } from '../../shared/resource-type'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { ORCID_QUEUE } from './orcid-queue.resource-type'; +import { CacheableObject } from '../../cache/cacheable-object.model'; + +/** + * Class the represents a Orcid Queue. + */ +@typedObject +export class OrcidQueue extends CacheableObject { + + static type = ORCID_QUEUE; + + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The identifier of this Orcid Queue record + */ + @autoserialize + id: number; + + /** + * The record description. + */ + @autoserialize + description: string; + + /** + * The identifier of the profileItem of this Orcid Queue record. + */ + @autoserialize + profileItemId: string; + + /** + * The identifier of the entity related to this Orcid Queue record. + */ + @autoserialize + entityId: string; + + /** + * The type of this Orcid Queue record. + */ + @autoserialize + recordType: string; + + /** + * The operation related to this Orcid Queue record. + */ + @autoserialize + operation: string; + + /** + * The {@link HALLink}s for this Orcid Queue record + */ + @deserialize + _links: { + self: HALLink, + }; + +} diff --git a/src/app/core/orcid/model/orcid-queue.resource-type.ts b/src/app/core/orcid/model/orcid-queue.resource-type.ts new file mode 100644 index 0000000000..a7f40d70ec --- /dev/null +++ b/src/app/core/orcid/model/orcid-queue.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from '../../shared/resource-type'; + +/** + * The resource type for OrcidQueue + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const ORCID_QUEUE = new ResourceType('orcidqueue'); diff --git a/src/app/core/orcid/orcid-auth.service.spec.ts b/src/app/core/orcid/orcid-auth.service.spec.ts new file mode 100644 index 0000000000..27a33a85b1 --- /dev/null +++ b/src/app/core/orcid/orcid-auth.service.spec.ts @@ -0,0 +1,329 @@ +import { cold, getTestScheduler } from 'jasmine-marbles'; +import { TestScheduler } from 'rxjs/testing'; +import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { RouterMock } from '../../shared/mocks/router.mock'; +import { ResearcherProfile } from '../profile/model/researcher-profile.model'; +import { Item } from '../shared/item.model'; +import { AddOperation, RemoveOperation } from 'fast-json-patch'; +import { ConfigurationProperty } from '../shared/configuration-property.model'; +import { ConfigurationDataService } from '../data/configuration-data.service'; +import { createPaginatedList } from '../../shared/testing/utils.test'; +import { NativeWindowRefMock } from '../../shared/mocks/mock-native-window-ref'; +import { URLCombiner } from '../url-combiner/url-combiner'; +import { OrcidAuthService } from './orcid-auth.service'; +import { ResearcherProfileService } from '../profile/researcher-profile.service'; + +describe('OrcidAuthService', () => { + let scheduler: TestScheduler; + let service: OrcidAuthService; + let serviceAsAny: any; + + let researcherProfileService: jasmine.SpyObj; + let configurationDataService: ConfigurationDataService; + let nativeWindowService: NativeWindowRefMock; + let routerStub: any; + + const researcherProfileId = 'beef9946-rt56-479e-8f11-b90cbe9f7241'; + const itemId = 'beef9946-rt56-479e-8f11-b90cbe9f7241'; + + const researcherProfile: ResearcherProfile = Object.assign(new ResearcherProfile(), { + id: researcherProfileId, + visible: false, + type: 'profile', + _links: { + item: { + href: `https://rest.api/rest/api/profiles/${researcherProfileId}/item` + }, + self: { + href: `https://rest.api/rest/api/profiles/${researcherProfileId}` + }, + } + }); + + const researcherProfilePatched: ResearcherProfile = Object.assign(new ResearcherProfile(), { + id: researcherProfileId, + visible: true, + type: 'profile', + _links: { + item: { + href: `https://rest.api/rest/api/profiles/${researcherProfileId}/item` + }, + self: { + href: `https://rest.api/rest/api/profiles/${researcherProfileId}` + }, + } + }); + + const mockItemUnlinkedToOrcid: Item = Object.assign(new Item(), { + id: 'mockItemUnlinkedToOrcid', + bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), + metadata: { + 'dc.title': [{ + value: 'test person' + }], + 'dspace.entity.type': [{ + 'value': 'Person' + }], + 'dspace.object.owner': [{ + 'value': 'test person', + 'language': null, + 'authority': 'researcher-profile-id', + 'confidence': 600, + 'place': 0 + }], + } + }); + + const mockItemLinkedToOrcid: Item = Object.assign(new Item(), { + bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), + metadata: { + 'dc.title': [{ + value: 'test person' + }], + 'dspace.entity.type': [{ + 'value': 'Person' + }], + 'dspace.object.owner': [{ + 'value': 'test person', + 'language': null, + 'authority': 'researcher-profile-id', + 'confidence': 600, + 'place': 0 + }], + 'dspace.orcid.authenticated': [{ + 'value': '2022-06-10T15:15:12.952872', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + }], + 'dspace.orcid.scope': [{ + 'value': '/authenticate', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + }, { + 'value': '/read-limited', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 1 + }, { + 'value': '/activities/update', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 2 + }, { + 'value': '/person/update', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 3 + }], + 'person.identifier.orcid': [{ + 'value': 'orcid-id', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + }] + } + }); + + const disconnectionAllowAdmin = { + uuid: 'orcid.disconnection.allowed-users', + name: 'orcid.disconnection.allowed-users', + values: ['only_admin'] + } as ConfigurationProperty; + + const disconnectionAllowAdminOwner = { + uuid: 'orcid.disconnection.allowed-users', + name: 'orcid.disconnection.allowed-users', + values: ['admin_and_owner'] + } as ConfigurationProperty; + + const authorizeUrl = { + uuid: 'orcid.authorize-url', + name: 'orcid.authorize-url', + values: ['orcid.authorize-url'] + } as ConfigurationProperty; + const appClientId = { + uuid: 'orcid.application-client-id', + name: 'orcid.application-client-id', + values: ['orcid.application-client-id'] + } as ConfigurationProperty; + const orcidScope = { + uuid: 'orcid.scope', + name: 'orcid.scope', + values: ['/authenticate', '/read-limited'] + } as ConfigurationProperty; + + beforeEach(() => { + scheduler = getTestScheduler(); + routerStub = new RouterMock(); + researcherProfileService = jasmine.createSpyObj('ResearcherProfileService', { + findById: jasmine.createSpy('findById'), + updateByOrcidOperations: jasmine.createSpy('updateByOrcidOperations') + }); + configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: jasmine.createSpy('findByPropertyName') + }); + nativeWindowService = new NativeWindowRefMock(); + + service = new OrcidAuthService( + nativeWindowService, + configurationDataService, + researcherProfileService, + routerStub); + + serviceAsAny = service; + }); + + + describe('isLinkedToOrcid', () => { + it('should return true when item has metadata', () => { + const result = service.isLinkedToOrcid(mockItemLinkedToOrcid); + expect(result).toBeTrue(); + }); + + it('should return true when item has no metadata', () => { + const result = service.isLinkedToOrcid(mockItemUnlinkedToOrcid); + expect(result).toBeFalse(); + }); + }); + + describe('onlyAdminCanDisconnectProfileFromOrcid', () => { + it('should return true when property is only_admin', () => { + spyOn((service as any), 'getOrcidDisconnectionAllowedUsersConfiguration').and.returnValue(createSuccessfulRemoteDataObject$(disconnectionAllowAdmin)); + const result = service.onlyAdminCanDisconnectProfileFromOrcid(); + const expected = cold('(a|)', { + a: true + }); + expect(result).toBeObservable(expected); + }); + + it('should return false on faild', () => { + spyOn((service as any), 'getOrcidDisconnectionAllowedUsersConfiguration').and.returnValue(createFailedRemoteDataObject$()); + const result = service.onlyAdminCanDisconnectProfileFromOrcid(); + const expected = cold('(a|)', { + a: false + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('ownerCanDisconnectProfileFromOrcid', () => { + it('should return true when property is admin_and_owner', () => { + spyOn((service as any), 'getOrcidDisconnectionAllowedUsersConfiguration').and.returnValue(createSuccessfulRemoteDataObject$(disconnectionAllowAdminOwner)); + const result = service.ownerCanDisconnectProfileFromOrcid(); + const expected = cold('(a|)', { + a: true + }); + expect(result).toBeObservable(expected); + }); + + it('should return false on faild', () => { + spyOn((service as any), 'getOrcidDisconnectionAllowedUsersConfiguration').and.returnValue(createFailedRemoteDataObject$()); + const result = service.ownerCanDisconnectProfileFromOrcid(); + const expected = cold('(a|)', { + a: false + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('linkOrcidByItem', () => { + beforeEach(() => { + scheduler = getTestScheduler(); + researcherProfileService.updateByOrcidOperations.and.returnValue(createSuccessfulRemoteDataObject$(researcherProfilePatched)); + researcherProfileService.findById.and.returnValue(createSuccessfulRemoteDataObject$(researcherProfile)); + }); + + it('should call updateByOrcidOperations method properly', () => { + const operations: AddOperation[] = [{ + path: '/orcid', + op: 'add', + value: 'test-code' + }]; + + scheduler.schedule(() => service.linkOrcidByItem(mockItemUnlinkedToOrcid, 'test-code').subscribe()); + scheduler.flush(); + + expect(researcherProfileService.updateByOrcidOperations).toHaveBeenCalledWith(researcherProfile, operations); + }); + }); + + describe('unlinkOrcidByItem', () => { + beforeEach(() => { + scheduler = getTestScheduler(); + researcherProfileService.updateByOrcidOperations.and.returnValue(createSuccessfulRemoteDataObject$(researcherProfilePatched)); + researcherProfileService.findById.and.returnValue(createSuccessfulRemoteDataObject$(researcherProfile)); + }); + + it('should call updateByOrcidOperations method properly', () => { + const operations: RemoveOperation[] = [{ + path: '/orcid', + op: 'remove' + }]; + + scheduler.schedule(() => service.unlinkOrcidByItem(mockItemLinkedToOrcid).subscribe()); + scheduler.flush(); + + expect(researcherProfileService.updateByOrcidOperations).toHaveBeenCalledWith(researcherProfile, operations); + }); + }); + + describe('getOrcidAuthorizeUrl', () => { + beforeEach(() => { + routerStub.setRoute('/entities/person/uuid/orcid'); + (service as any).configurationService.findByPropertyName.and.returnValues( + createSuccessfulRemoteDataObject$(authorizeUrl), + createSuccessfulRemoteDataObject$(appClientId), + createSuccessfulRemoteDataObject$(orcidScope) + ); + }); + + it('should build the url properly', () => { + const result = service.getOrcidAuthorizeUrl(mockItemUnlinkedToOrcid); + const redirectUri: string = new URLCombiner(nativeWindowService.nativeWindow.origin, encodeURIComponent(routerStub.url.split('?')[0])).toString(); + const url = 'orcid.authorize-url?client_id=orcid.application-client-id&redirect_uri=' + redirectUri + '&response_type=code&scope=/authenticate /read-limited'; + + const expected = cold('(a|)', { + a: url + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('getOrcidAuthorizationScopesByItem', () => { + it('should return list of scopes saved in the item', () => { + const orcidScopes = [ + '/authenticate', + '/read-limited', + '/activities/update', + '/person/update' + ]; + const result = service.getOrcidAuthorizationScopesByItem(mockItemLinkedToOrcid); + expect(result).toEqual(orcidScopes); + }); + }); + + describe('getOrcidAuthorizationScopes', () => { + it('should return list of scopes by configuration', () => { + (service as any).configurationService.findByPropertyName.and.returnValue( + createSuccessfulRemoteDataObject$(orcidScope) + ); + const orcidScopes = [ + '/authenticate', + '/read-limited' + ]; + const expected = cold('(a|)', { + a: orcidScopes + }); + const result = service.getOrcidAuthorizationScopes(); + expect(result).toBeObservable(expected); + }); + }); +}); diff --git a/src/app/core/orcid/orcid-auth.service.ts b/src/app/core/orcid/orcid-auth.service.ts new file mode 100644 index 0000000000..cf7bc2b259 --- /dev/null +++ b/src/app/core/orcid/orcid-auth.service.ts @@ -0,0 +1,145 @@ +import { Inject, Injectable } from '@angular/core'; +import { Router } from '@angular/router'; + +import { combineLatest, Observable } from 'rxjs'; +import { map, switchMap } from 'rxjs/operators'; +import { AddOperation, RemoveOperation } from 'fast-json-patch'; + +import { ResearcherProfileService } from '../profile/researcher-profile.service'; +import { Item } from '../shared/item.model'; +import { isNotEmpty } from '../../shared/empty.util'; +import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../shared/operators'; +import { RemoteData } from '../data/remote-data'; +import { ConfigurationProperty } from '../shared/configuration-property.model'; +import { ConfigurationDataService } from '../data/configuration-data.service'; +import { ResearcherProfile } from '../profile/model/researcher-profile.model'; +import { URLCombiner } from '../url-combiner/url-combiner'; +import { NativeWindowRef, NativeWindowService } from '../services/window.service'; + +@Injectable() +export class OrcidAuthService { + + constructor( + @Inject(NativeWindowService) protected _window: NativeWindowRef, + private configurationService: ConfigurationDataService, + private researcherProfileService: ResearcherProfileService, + private router: Router) { + } + + /** + * Check if the given item is linked to an ORCID profile. + * + * @param item the item to check + * @returns the check result + */ + public isLinkedToOrcid(item: Item): boolean { + return item.hasMetadata('dspace.orcid.authenticated'); + } + + /** + * Returns true if only the admin users can disconnect a researcher profile from ORCID. + * + * @returns the check result + */ + public onlyAdminCanDisconnectProfileFromOrcid(): Observable { + return this.getOrcidDisconnectionAllowedUsersConfiguration().pipe( + map((propertyRD: RemoteData) => { + return propertyRD.hasSucceeded && propertyRD.payload.values.map((value) => value.toLowerCase()).includes('only_admin'); + }) + ); + } + + /** + * Returns true if the profile's owner can disconnect that profile from ORCID. + * + * @returns the check result + */ + public ownerCanDisconnectProfileFromOrcid(): Observable { + return this.getOrcidDisconnectionAllowedUsersConfiguration().pipe( + map((propertyRD: RemoteData) => { + return propertyRD.hasSucceeded && propertyRD.payload.values.map( (value) => value.toLowerCase()).includes('admin_and_owner'); + }) + ); + } + + /** + * Perform a link operation to ORCID profile. + * + * @param person The person item related to the researcher profile + * @param code The auth-code received from orcid + */ + public linkOrcidByItem(person: Item, code: string): Observable> { + const operations: AddOperation[] = [{ + path: '/orcid', + op: 'add', + value: code + }]; + + return this.researcherProfileService.findById(person.firstMetadata('dspace.object.owner').authority).pipe( + getFirstCompletedRemoteData(), + switchMap((profileRD) => this.researcherProfileService.updateByOrcidOperations(profileRD.payload, operations)) + ); + } + + /** + * Perform unlink operation from ORCID profile. + * + * @param person The person item related to the researcher profile + */ + public unlinkOrcidByItem(person: Item): Observable> { + const operations: RemoveOperation[] = [{ + path:'/orcid', + op:'remove' + }]; + + return this.researcherProfileService.findById(person.firstMetadata('dspace.object.owner').authority).pipe( + getFirstCompletedRemoteData(), + switchMap((profileRD) => this.researcherProfileService.updateByOrcidOperations(profileRD.payload, operations)) + ); + } + + /** + * Build and return the url to authenticate with orcid + * + * @param profile + */ + public getOrcidAuthorizeUrl(profile: Item): Observable { + return combineLatest([ + this.configurationService.findByPropertyName('orcid.authorize-url').pipe(getFirstSucceededRemoteDataPayload()), + this.configurationService.findByPropertyName('orcid.application-client-id').pipe(getFirstSucceededRemoteDataPayload()), + this.configurationService.findByPropertyName('orcid.scope').pipe(getFirstSucceededRemoteDataPayload())] + ).pipe( + map(([authorizeUrl, clientId, scopes]) => { + const redirectUri = new URLCombiner(this._window.nativeWindow.origin, encodeURIComponent(this.router.url.split('?')[0])); + console.log(redirectUri.toString()); + return authorizeUrl.values[0] + '?client_id=' + clientId.values[0] + '&redirect_uri=' + redirectUri + '&response_type=code&scope=' + + scopes.values.join(' '); + })); + } + + /** + * Return all orcid authorization scopes saved in the given item + * + * @param item + */ + public getOrcidAuthorizationScopesByItem(item: Item): string[] { + return isNotEmpty(item) ? item.allMetadataValues('dspace.orcid.scope') : []; + } + + /** + * Return all orcid authorization scopes available by configuration + */ + public getOrcidAuthorizationScopes(): Observable { + return this.configurationService.findByPropertyName('orcid.scope').pipe( + getFirstCompletedRemoteData(), + map((propertyRD: RemoteData) => propertyRD.hasSucceeded ? propertyRD.payload.values : []) + ); + } + + private getOrcidDisconnectionAllowedUsersConfiguration(): Observable> { + return this.configurationService.findByPropertyName('orcid.disconnection.allowed-users').pipe( + getFirstCompletedRemoteData() + ); + } + +} diff --git a/src/app/core/orcid/orcid-history-data.service.ts b/src/app/core/orcid/orcid-history-data.service.ts new file mode 100644 index 0000000000..cef3efbe78 --- /dev/null +++ b/src/app/core/orcid/orcid-history-data.service.ts @@ -0,0 +1,126 @@ +// eslint-disable-next-line max-classes-per-file +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { map, switchMap } from 'rxjs/operators'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { dataService } from '../cache/builders/build-decorators'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { DataService } from '../data/data.service'; +import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service'; +import { ItemDataService } from '../data/item-data.service'; +import { RemoteData } from '../data/remote-data'; +import { PostRequest } from '../data/request.models'; +import { RequestService } from '../data/request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { OrcidHistory } from './model/orcid-history.model'; +import { ORCID_HISTORY } from './model/orcid-history.resource-type'; +import { OrcidQueue } from './model/orcid-queue.model'; +import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { CoreState } from '../core-state.model'; +import { RestRequest } from '../data/rest-request.model'; +import { sendRequest } from '../shared/request.operators'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { FindListOptions } from '../data/find-list-options.model'; +import { PaginatedList } from '../data/paginated-list.model'; + +/** + * A private DataService implementation to delegate specific methods to. + */ +class OrcidHistoryServiceImpl extends DataService { + public linkPath = 'orcidhistories'; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer) { + super(); + } + +} + +/** + * A service that provides methods to make REST requests with Orcid History endpoint. + */ +@Injectable() +@dataService(ORCID_HISTORY) +export class OrcidHistoryDataService { + + dataService: OrcidHistoryServiceImpl; + + responseMsToLive: number = 10 * 1000; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer, + protected itemService: ItemDataService ) { + + this.dataService = new OrcidHistoryServiceImpl(requestService, rdbService, store, objectCache, halService, + notificationsService, http, comparator); + + } + + sendToORCID(orcidQueue: OrcidQueue): Observable> { + const requestId = this.requestService.generateRequestId(); + return this.getEndpoint().pipe( + map((endpointURL: string) => { + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'text/uri-list'); + options.headers = headers; + return new PostRequest(requestId, endpointURL, orcidQueue._links.self.href, options); + }), + sendRequest(this.requestService), + switchMap((request: RestRequest) => this.rdbService.buildFromRequestUUID(request.uuid) as Observable>) + ); + } + + getEndpoint(): Observable { + return this.halService.getEndpoint(this.dataService.linkPath); + } + + /** + * Returns an observable of {@link RemoteData} of an object, based on its ID, with a list of + * {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object + * @param id ID of object we want to retrieve + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + */ + findById(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { + return this.dataService.findById(id, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Returns a list of observables of {@link RemoteData} of {@link OrcidHistory}s, based on an href, with a list of {@link FollowLinkConfig}, + * to automatically resolve {@link HALLink}s of the {@link OrcidHistory} + * @param href The url of object we want to retrieve + * @param findListOptions Find list options object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + */ + findAllByHref(href: string, findListOptions: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.dataService.findAllByHref(href, findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + +} diff --git a/src/app/core/orcid/orcid-queue.service.ts b/src/app/core/orcid/orcid-queue.service.ts new file mode 100644 index 0000000000..30b9580b96 --- /dev/null +++ b/src/app/core/orcid/orcid-queue.service.ts @@ -0,0 +1,110 @@ +// eslint-disable-next-line max-classes-per-file +import { DataService } from '../data/data.service'; +import { OrcidQueue } from './model/orcid-queue.model'; +import { RequestService } from '../data/request.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { Store } from '@ngrx/store'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { HttpClient } from '@angular/common/http'; +import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service'; +import { Injectable } from '@angular/core'; +import { dataService } from '../cache/builders/build-decorators'; +import { ORCID_QUEUE } from './model/orcid-queue.resource-type'; +import { ItemDataService } from '../data/item-data.service'; +import { Observable } from 'rxjs'; +import { RemoteData } from '../data/remote-data'; +import { PaginatedList } from '../data/paginated-list.model'; +import { RequestParam } from '../cache/models/request-param.model'; +import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; +import { NoContent } from '../shared/NoContent.model'; +import { ConfigurationDataService } from '../data/configuration-data.service'; +import { Router } from '@angular/router'; +import { CoreState } from '../core-state.model'; + +/** + * A private DataService implementation to delegate specific methods to. + */ +class OrcidQueueServiceImpl extends DataService { + public linkPath = 'orcidqueues'; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer) { + super(); + } + +} + +/** + * A service that provides methods to make REST requests with Orcid Queue endpoint. + */ +@Injectable() +@dataService(ORCID_QUEUE) +export class OrcidQueueService { + + dataService: OrcidQueueServiceImpl; + + responseMsToLive: number = 10 * 1000; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer, + protected configurationService: ConfigurationDataService, + protected router: Router, + protected itemService: ItemDataService ) { + + this.dataService = new OrcidQueueServiceImpl(requestService, rdbService, store, objectCache, halService, + notificationsService, http, comparator); + + } + + /** + * @param itemId It represent an Id of profileItem + * @param paginationOptions The pagination options object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @returns { OrcidQueue } + */ + searchByProfileItemId(itemId: string, paginationOptions: PaginationComponentOptions, useCachedVersionIfAvailable = true, reRequestOnStale = true): Observable>> { + return this.dataService.searchBy('findByProfileItem', { + searchParams: [new RequestParam('profileItemId', itemId)], + elementsPerPage: paginationOptions.pageSize, + currentPage: paginationOptions.currentPage + }, + useCachedVersionIfAvailable, + reRequestOnStale + ); + } + + /** + * @param orcidQueueId represents a id of orcid queue + * @returns { NoContent } + */ + deleteById(orcidQueueId: number): Observable> { + return this.dataService.delete(orcidQueueId.toString()); + } + + /** + * This method will set linkPath to stale + */ + clearFindByProfileItemRequests() { + this.requestService.setStaleByHrefSubstring(this.dataService.linkPath + '/search/findByProfileItem'); + } + +} diff --git a/src/app/core/pagination/pagination.service.spec.ts b/src/app/core/pagination/pagination.service.spec.ts index 94b6b48d59..66349e8a9e 100644 --- a/src/app/core/pagination/pagination.service.spec.ts +++ b/src/app/core/pagination/pagination.service.spec.ts @@ -12,7 +12,7 @@ describe('PaginationService', () => { let routeService; const defaultPagination = new PaginationComponentOptions(); - const defaultSort = new SortOptions('id', SortDirection.DESC); + const defaultSort = new SortOptions('dc.title', SortDirection.ASC); const defaultFindListOptions = new FindListOptions(); beforeEach(() => { @@ -39,7 +39,6 @@ describe('PaginationService', () => { service = new PaginationService(routeService, router); }); - describe('getCurrentPagination', () => { it('should retrieve the current pagination info from the routerService', () => { service.getCurrentPagination('test-id', defaultPagination).subscribe((currentPagination) => { @@ -56,6 +55,26 @@ describe('PaginationService', () => { expect(currentSort).toEqual(Object.assign(new SortOptions('score', SortDirection.ASC ))); }); }); + it('should return default sort when no sort specified', () => { + // This is same as routeService (defined above), but returns no sort field or direction + routeService = { + getQueryParameterValue: (param) => { + let value; + if (param.endsWith('.page')) { + value = 5; + } + if (param.endsWith('.rpp')) { + value = 10; + } + return observableOf(value); + } + }; + service = new PaginationService(routeService, router); + + service.getCurrentSort('test-id', defaultSort).subscribe((currentSort) => { + expect(currentSort).toEqual(defaultSort); + }); + }); }); describe('getFindListOptions', () => { it('should retrieve the current findListOptions info from the routerService', () => { diff --git a/src/app/core/pagination/pagination.service.ts b/src/app/core/pagination/pagination.service.ts index a6f8052c4b..40e13d654f 100644 --- a/src/app/core/pagination/pagination.service.ts +++ b/src/app/core/pagination/pagination.service.ts @@ -24,7 +24,11 @@ import { isNumeric } from '../../shared/numeric.util'; */ export class PaginationService { - private defaultSortOptions = new SortOptions('id', SortDirection.ASC); + /** + * Sort on title ASC by default + * @type {SortOptions} + */ + private defaultSortOptions = new SortOptions('dc.title', SortDirection.ASC); private clearParams = {}; diff --git a/src/app/core/profile/researcher-profile.service.spec.ts b/src/app/core/profile/researcher-profile.service.spec.ts index eb5ff776fe..899867ec8e 100644 --- a/src/app/core/profile/researcher-profile.service.spec.ts +++ b/src/app/core/profile/researcher-profile.service.spec.ts @@ -26,6 +26,8 @@ import { ReplaceOperation } from 'fast-json-patch'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { PostRequest } from '../data/request.models'; import { followLink } from '../../shared/utils/follow-link-config.model'; +import { ConfigurationProperty } from '../shared/configuration-property.model'; +import { createPaginatedList } from '../../shared/testing/utils.test'; describe('ResearcherProfileService', () => { let scheduler: TestScheduler; @@ -36,6 +38,7 @@ describe('ResearcherProfileService', () => { let objectCache: ObjectCacheService; let halService: HALEndpointService; let responseCacheEntry: RequestEntry; + let routerStub: any; const researcherProfileId = 'beef9946-rt56-479e-8f11-b90cbe9f7241'; const itemId = 'beef9946-rt56-479e-8f11-b90cbe9f7241'; @@ -86,6 +89,113 @@ describe('ResearcherProfileService', () => { }, } }); + + const mockItemUnlinkedToOrcid: Item = Object.assign(new Item(), { + id: 'mockItemUnlinkedToOrcid', + bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), + metadata: { + 'dc.title': [{ + value: 'test person' + }], + 'dspace.entity.type': [{ + 'value': 'Person' + }], + 'dspace.object.owner': [{ + 'value': 'test person', + 'language': null, + 'authority': 'researcher-profile-id', + 'confidence': 600, + 'place': 0 + }], + } + }); + + const mockItemLinkedToOrcid: Item = Object.assign(new Item(), { + bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])), + metadata: { + 'dc.title': [{ + value: 'test person' + }], + 'dspace.entity.type': [{ + 'value': 'Person' + }], + 'dspace.object.owner': [{ + 'value': 'test person', + 'language': null, + 'authority': 'researcher-profile-id', + 'confidence': 600, + 'place': 0 + }], + 'dspace.orcid.authenticated': [{ + 'value': '2022-06-10T15:15:12.952872', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + }], + 'dspace.orcid.scope': [{ + 'value': '/authenticate', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + }, { + 'value': '/read-limited', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 1 + }, { + 'value': '/activities/update', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 2 + }, { + 'value': '/person/update', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 3 + }], + 'person.identifier.orcid': [{ + 'value': 'orcid-id', + 'language': null, + 'authority': null, + 'confidence': -1, + 'place': 0 + }] + } + }); + + const disconnectionAllowAdmin = { + uuid: 'orcid.disconnection.allowed-users', + name: 'orcid.disconnection.allowed-users', + values: ['only_admin'] + } as ConfigurationProperty; + + const disconnectionAllowAdminOwner = { + uuid: 'orcid.disconnection.allowed-users', + name: 'orcid.disconnection.allowed-users', + values: ['admin_and_owner'] + } as ConfigurationProperty; + + const authorizeUrl = { + uuid: 'orcid.authorize-url', + name: 'orcid.authorize-url', + values: ['orcid.authorize-url'] + } as ConfigurationProperty; + const appClientId = { + uuid: 'orcid.application-client-id', + name: 'orcid.application-client-id', + values: ['orcid.application-client-id'] + } as ConfigurationProperty; + const orcidScope = { + uuid: 'orcid.scope', + name: 'orcid.scope', + values: ['/authenticate', '/read-limited'] + } as ConfigurationProperty; + const endpointURL = `https://rest.api/rest/api/profiles`; const endpointURLWithEmbed = 'https://rest.api/rest/api/profiles?embed=item'; const sourceUri = `https://rest.api/rest/api/external-source/profile`; @@ -132,7 +242,7 @@ describe('ResearcherProfileService', () => { const notificationsService = {} as NotificationsService; const http = {} as HttpClient; const comparator = {} as any; - const routerStub: any = new RouterMock(); + routerStub = new RouterMock(); const itemService = jasmine.createSpyObj('ItemService', { findByHref: jasmine.createSpy('findByHref') }); @@ -271,7 +381,7 @@ describe('ResearcherProfileService', () => { }); describe('createFromExternalSource', () => { - let patchSpy; + beforeEach(() => { spyOn((service as any).dataService, 'patch').and.returnValue(createSuccessfulRemoteDataObject$(researcherProfilePatched)); spyOn((service as any), 'findById').and.returnValue(createSuccessfulRemoteDataObject$(researcherProfilePatched)); @@ -293,4 +403,17 @@ describe('ResearcherProfileService', () => { }); }); + describe('updateByOrcidOperations', () => { + beforeEach(() => { + scheduler = getTestScheduler(); + spyOn((service as any).dataService, 'patch').and.returnValue(createSuccessfulRemoteDataObject$(researcherProfilePatched)); + }); + + it('should call patch method properly', () => { + scheduler.schedule(() => service.updateByOrcidOperations(researcherProfile, []).subscribe()); + scheduler.flush(); + + expect((service as any).dataService.patch).toHaveBeenCalledWith(researcherProfile, []); + }); + }); }); diff --git a/src/app/core/profile/researcher-profile.service.ts b/src/app/core/profile/researcher-profile.service.ts index 006b0b554b..882845d133 100644 --- a/src/app/core/profile/researcher-profile.service.ts +++ b/src/app/core/profile/researcher-profile.service.ts @@ -4,10 +4,9 @@ import { Injectable } from '@angular/core'; import { Router } from '@angular/router'; import { Store } from '@ngrx/store'; -import { ReplaceOperation } from 'fast-json-patch'; +import { Operation, ReplaceOperation } from 'fast-json-patch'; import { Observable } from 'rxjs'; import { find, map } from 'rxjs/operators'; - import { NotificationsService } from '../../shared/notifications/notifications.service'; import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; @@ -24,10 +23,11 @@ import { ResearcherProfile } from './model/researcher-profile.model'; import { RESEARCHER_PROFILE } from './model/researcher-profile.resource-type'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { PostRequest } from '../data/request.models'; -import { hasValue } from '../../shared/empty.util'; +import { hasValue, isEmpty } from '../../shared/empty.util'; import { CoreState } from '../core-state.model'; import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { Item } from '../shared/item.model'; +import { createFailedRemoteDataObject$ } from '../../shared/remote-data.utils'; /** * A private DataService implementation to delegate specific methods to. @@ -56,9 +56,9 @@ class ResearcherProfileServiceImpl extends DataService { @dataService(RESEARCHER_PROFILE) export class ResearcherProfileService { - dataService: ResearcherProfileServiceImpl; + protected dataService: ResearcherProfileServiceImpl; - responseMsToLive: number = 10 * 1000; + protected responseMsToLive: number = 10 * 1000; constructor( protected requestService: RequestService, @@ -112,6 +112,20 @@ export class ResearcherProfileService { ); } + /** + * Find a researcher profile by its own related item + * + * @param item + */ + public findByRelatedItem(item: Item): Observable> { + const profileId = item.firstMetadata('dspace.object.owner')?.authority; + if (isEmpty(profileId)) { + return createFailedRemoteDataObject$(); + } else { + return this.findById(profileId); + } + } + /** * Find the item id related to the given researcher profile. * @@ -164,4 +178,17 @@ export class ResearcherProfileService { return this.rdbService.buildFromRequestUUID(requestId, followLink('item')); } + + /** + * Update researcher profile by patch orcid operation + * + * @param researcherProfile + * @param operations + */ + public updateByOrcidOperations(researcherProfile: ResearcherProfile, operations: Operation[]): Observable> { + return this.dataService.patch(researcherProfile, operations); + } + + + } diff --git a/src/app/core/shared/metadata.models.ts b/src/app/core/shared/metadata.models.ts index 52f1805e8c..e74e1f9927 100644 --- a/src/app/core/shared/metadata.models.ts +++ b/src/app/core/shared/metadata.models.ts @@ -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; diff --git a/src/app/curation-form/curation-form.component.spec.ts b/src/app/curation-form/curation-form.component.spec.ts index 4ff013f77c..dc70b925e8 100644 --- a/src/app/curation-form/curation-form.component.spec.ts +++ b/src/app/curation-form/curation-form.component.spec.ts @@ -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(); + }); }); diff --git a/src/app/curation-form/curation-form.component.ts b/src/app/curation-form/curation-form.component.ts index 31501e70d7..422c955037 100644 --- a/src/app/curation-form/curation-form.component.ts +++ b/src/app/curation-form/curation-form.component.ts @@ -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 }, diff --git a/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html b/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html index 6e73935672..dbd9d03994 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html +++ b/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html @@ -3,6 +3,9 @@ {{'journalissue.page.titleprefix' | translate}}
+
diff --git a/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.ts b/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.ts index f96379dafd..f5e9dc9b2b 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.ts +++ b/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; -import { ItemComponent } from '../../../../item-page/simple/item-types/shared/item.component'; import { ViewMode } from '../../../../core/shared/view-mode.model'; import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; +import { VersionedItemComponent } from '../../../../item-page/simple/item-types/versioned-item/versioned-item.component'; @listableObjectComponent('JournalIssue', ViewMode.StandalonePage) @Component({ @@ -12,5 +12,5 @@ import { listableObjectComponent } from '../../../../shared/object-collection/sh /** * The component for displaying metadata and relations of an item of the type Journal Issue */ -export class JournalIssueComponent extends ItemComponent { +export class JournalIssueComponent extends VersionedItemComponent { } diff --git a/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html b/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html index 5d4d8d06ce..8b19c37033 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html +++ b/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.html @@ -3,6 +3,9 @@ {{'journalvolume.page.titleprefix' | translate}}
+
diff --git a/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.ts b/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.ts index eeb93e7070..cc09be7959 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.ts +++ b/src/app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; -import { ItemComponent } from '../../../../item-page/simple/item-types/shared/item.component'; import { ViewMode } from '../../../../core/shared/view-mode.model'; import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; +import { VersionedItemComponent } from '../../../../item-page/simple/item-types/versioned-item/versioned-item.component'; @listableObjectComponent('JournalVolume', ViewMode.StandalonePage) @Component({ @@ -12,5 +12,5 @@ import { listableObjectComponent } from '../../../../shared/object-collection/sh /** * The component for displaying metadata and relations of an item of the type Journal Volume */ -export class JournalVolumeComponent extends ItemComponent { +export class JournalVolumeComponent extends VersionedItemComponent { } diff --git a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.html b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.html index d51c55e5d6..45cbc1f839 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.html +++ b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.html @@ -3,6 +3,9 @@ {{'journal.page.titleprefix' | translate}}
+
@@ -44,7 +47,8 @@ diff --git a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.spec.ts b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.spec.ts index 0e756b7dc9..3ed73e7891 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.spec.ts +++ b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.spec.ts @@ -29,6 +29,11 @@ import { TruncatableService } from '../../../../shared/truncatable/truncatable.s import { TruncatePipe } from '../../../../shared/utils/truncate.pipe'; import { JournalComponent } from './journal.component'; import { RouteService } from '../../../../core/services/route.service'; +import { RouterTestingModule } from '@angular/router/testing'; +import { VersionHistoryDataService } from '../../../../core/data/version-history-data.service'; +import { VersionDataService } from '../../../../core/data/version-data.service'; +import { WorkspaceitemDataService } from '../../../../core/submission/workspaceitem-data.service'; +import { SearchService } from '../../../../core/shared/search/search.service'; let comp: JournalComponent; let fixture: ComponentFixture; @@ -65,12 +70,15 @@ describe('JournalComponent', () => { }; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot({ - loader: { - provide: TranslateLoader, - useClass: TranslateLoaderMock - } - })], + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), + RouterTestingModule, + ], declarations: [JournalComponent, GenericItemPageFieldComponent, TruncatePipe], providers: [ { provide: ItemDataService, useValue: {} }, @@ -86,7 +94,11 @@ describe('JournalComponent', () => { { provide: DSOChangeAnalyzer, useValue: {} }, { provide: NotificationsService, useValue: {} }, { provide: DefaultChangeAnalyzer, useValue: {} }, + { provide: VersionHistoryDataService, useValue: {} }, + { provide: VersionDataService, useValue: {} }, { provide: BitstreamDataService, useValue: mockBitstreamDataService }, + { provide: WorkspaceitemDataService, useValue: {} }, + { provide: SearchService, useValue: {} }, { provide: RouteService, useValue: {} } ], diff --git a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.ts b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.ts index 3fe0903145..acfd31d8f6 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.ts +++ b/src/app/entity-groups/journal-entities/item-pages/journal/journal.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; -import { ItemComponent } from '../../../../item-page/simple/item-types/shared/item.component'; import { ViewMode } from '../../../../core/shared/view-mode.model'; import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; +import { VersionedItemComponent } from '../../../../item-page/simple/item-types/versioned-item/versioned-item.component'; @listableObjectComponent('Journal', ViewMode.StandalonePage) @Component({ @@ -12,5 +12,5 @@ import { listableObjectComponent } from '../../../../shared/object-collection/sh /** * The component for displaying metadata and relations of an item of the type Journal */ -export class JournalComponent extends ItemComponent { +export class JournalComponent extends VersionedItemComponent { } diff --git a/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html b/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html index 4315d2a91c..ff4bc4d226 100644 --- a/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html +++ b/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html @@ -3,6 +3,9 @@ {{'orgunit.page.titleprefix' | translate}}
+
@@ -54,12 +57,12 @@ [relationTypes]="[{ label: 'isOrgUnitOfPerson', filter: 'isOrgUnitOfPerson', - configuration: 'person' + configuration: 'person-relationships' }, { label: 'isOrgUnitOfProject', filter: 'isOrgUnitOfProject', - configuration: 'project' + configuration: 'project-relationships' }]"> diff --git a/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.ts b/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.ts index ab756db562..cbf8497f35 100644 --- a/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.ts +++ b/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; -import { ItemComponent } from '../../../../item-page/simple/item-types/shared/item.component'; import { ViewMode } from '../../../../core/shared/view-mode.model'; import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; +import { VersionedItemComponent } from '../../../../item-page/simple/item-types/versioned-item/versioned-item.component'; @listableObjectComponent('OrgUnit', ViewMode.StandalonePage) @Component({ @@ -12,5 +12,5 @@ import { listableObjectComponent } from '../../../../shared/object-collection/sh /** * The component for displaying metadata and relations of an item of the type Organisation Unit */ -export class OrgUnitComponent extends ItemComponent { +export class OrgUnitComponent extends VersionedItemComponent { } diff --git a/src/app/entity-groups/research-entities/item-pages/person/person.component.html b/src/app/entity-groups/research-entities/item-pages/person/person.component.html index 96cfdbfacd..ace42f844e 100644 --- a/src/app/entity-groups/research-entities/item-pages/person/person.component.html +++ b/src/app/entity-groups/research-entities/item-pages/person/person.component.html @@ -3,6 +3,10 @@ {{'person.page.titleprefix' | translate}}
+ +
@@ -62,7 +66,8 @@ diff --git a/src/app/entity-groups/research-entities/item-pages/person/person.component.ts b/src/app/entity-groups/research-entities/item-pages/person/person.component.ts index 27fdd2ab15..8fde5ee69a 100644 --- a/src/app/entity-groups/research-entities/item-pages/person/person.component.ts +++ b/src/app/entity-groups/research-entities/item-pages/person/person.component.ts @@ -1,9 +1,7 @@ import { Component } from '@angular/core'; -import { ItemComponent } from '../../../../item-page/simple/item-types/shared/item.component'; import { ViewMode } from '../../../../core/shared/view-mode.model'; -import { - listableObjectComponent -} from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; +import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; +import { VersionedItemComponent } from '../../../../item-page/simple/item-types/versioned-item/versioned-item.component'; import { MetadataValue } from '../../../../core/shared/metadata.models'; @listableObjectComponent('Person', ViewMode.StandalonePage) @@ -15,7 +13,7 @@ import { MetadataValue } from '../../../../core/shared/metadata.models'; /** * The component for displaying metadata and relations of an item of the type Person */ -export class PersonComponent extends ItemComponent { +export class PersonComponent extends VersionedItemComponent { /** * Returns the metadata values to be used for the page title. @@ -36,4 +34,5 @@ export class PersonComponent extends ItemComponent { } return metadataValues; } + } diff --git a/src/app/entity-groups/research-entities/item-pages/project/project.component.html b/src/app/entity-groups/research-entities/item-pages/project/project.component.html index 7960631f3d..a068878fb4 100644 --- a/src/app/entity-groups/research-entities/item-pages/project/project.component.html +++ b/src/app/entity-groups/research-entities/item-pages/project/project.component.html @@ -3,6 +3,9 @@ {{'project.page.titleprefix' | translate}}
+
diff --git a/src/app/entity-groups/research-entities/item-pages/project/project.component.ts b/src/app/entity-groups/research-entities/item-pages/project/project.component.ts index e53d8afd69..066427fc0d 100644 --- a/src/app/entity-groups/research-entities/item-pages/project/project.component.ts +++ b/src/app/entity-groups/research-entities/item-pages/project/project.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; -import { ItemComponent } from '../../../../item-page/simple/item-types/shared/item.component'; import { ViewMode } from '../../../../core/shared/view-mode.model'; import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; +import { VersionedItemComponent } from '../../../../item-page/simple/item-types/versioned-item/versioned-item.component'; @listableObjectComponent('Project', ViewMode.StandalonePage) @Component({ @@ -12,5 +12,5 @@ import { listableObjectComponent } from '../../../../shared/object-collection/sh /** * The component for displaying metadata and relations of an item of the type Project */ -export class ProjectComponent extends ItemComponent { +export class ProjectComponent extends VersionedItemComponent { } diff --git a/src/app/footer/footer.component.html b/src/app/footer/footer.component.html index 2c1a34ccae..88236d381e 100644 --- a/src/app/footer/footer.component.html +++ b/src/app/footer/footer.component.html @@ -67,11 +67,11 @@ {{ 'footer.link.cookies' | translate}} -
  • +
  • {{ 'footer.link.privacy-policy' | translate}}
  • -
  • +
  • {{ 'footer.link.end-user-agreement' | translate}}
  • diff --git a/src/app/footer/footer.component.ts b/src/app/footer/footer.component.ts index c43a0ae85d..c4195c8eb3 100644 --- a/src/app/footer/footer.component.ts +++ b/src/app/footer/footer.component.ts @@ -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) { } diff --git a/src/app/header/header.component.html b/src/app/header/header.component.html index c7b979d266..e5d5f38971 100644 --- a/src/app/header/header.component.html +++ b/src/app/header/header.component.html @@ -8,7 +8,7 @@ \ No newline at end of file + diff --git a/src/app/navbar/navbar.component.spec.ts b/src/app/navbar/navbar.component.spec.ts index 659107080c..9a9d2210be 100644 --- a/src/app/navbar/navbar.component.spec.ts +++ b/src/app/navbar/navbar.component.spec.ts @@ -20,6 +20,8 @@ import { BrowseDefinition } from '../core/shared/browse-definition.model'; import { BrowseByDataType } from '../browse-by/browse-by-switcher/browse-by-decorator'; import { Item } from '../core/shared/item.model'; import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; +import { ThemeService } from '../shared/theme-support/theme.service'; +import { getMockThemeService } from '../shared/mocks/theme-service.mock'; let comp: NavbarComponent; let fixture: ComponentFixture; @@ -91,6 +93,7 @@ describe('NavbarComponent', () => { declarations: [NavbarComponent], providers: [ Injector, + { provide: ThemeService, useValue: getMockThemeService() }, { provide: MenuService, useValue: menuService }, { provide: HostWindowService, useValue: new HostWindowServiceStub(800) }, { provide: ActivatedRoute, useValue: routeStub }, diff --git a/src/app/navbar/navbar.component.ts b/src/app/navbar/navbar.component.ts index 7c4b2a5ca9..62b9e7d770 100644 --- a/src/app/navbar/navbar.component.ts +++ b/src/app/navbar/navbar.component.ts @@ -7,6 +7,7 @@ import { BrowseService } from '../core/browse/browse.service'; import { ActivatedRoute } from '@angular/router'; import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; import { MenuID } from '../shared/menu/menu-id.model'; +import { ThemeService } from '../shared/theme-support/theme.service'; /** * Component representing the public navbar @@ -29,9 +30,10 @@ export class NavbarComponent extends MenuComponent { public windowService: HostWindowService, public browseService: BrowseService, public authorizationService: AuthorizationDataService, - public route: ActivatedRoute + public route: ActivatedRoute, + protected themeService: ThemeService ) { - super(menuService, injector, authorizationService, route); + super(menuService, injector, authorizationService, route, themeService); } ngOnInit(): void { diff --git a/src/app/navbar/navbar.module.ts b/src/app/navbar/navbar.module.ts index d2742b0bae..af2bf036bd 100644 --- a/src/app/navbar/navbar.module.ts +++ b/src/app/navbar/navbar.module.ts @@ -7,6 +7,7 @@ import { CoreModule } from '../core/core.module'; import { NavbarEffects } from './navbar.effects'; import { NavbarSectionComponent } from './navbar-section/navbar-section.component'; import { ExpandableNavbarSectionComponent } from './expandable-navbar-section/expandable-navbar-section.component'; +import { ThemedExpandableNavbarSectionComponent } from './expandable-navbar-section/themed-expandable-navbar-section.component'; import { NavbarComponent } from './navbar.component'; import { MenuModule } from '../shared/menu/menu.module'; import { SharedModule } from '../shared/shared.module'; @@ -20,30 +21,31 @@ const effects = [ const ENTRY_COMPONENTS = [ // put only entry components that use custom decorator NavbarSectionComponent, - ExpandableNavbarSectionComponent, + ThemedExpandableNavbarSectionComponent, ]; @NgModule({ - imports: [ - CommonModule, - SharedModule, - MenuModule, - FormsModule, - EffectsModule.forFeature(effects), - CoreModule.forRoot() - ], - declarations: [ - NavbarComponent, - ThemedNavbarComponent, - NavbarSectionComponent, - ExpandableNavbarSectionComponent - ], - providers: [], - exports: [ - ThemedNavbarComponent, - NavbarSectionComponent, - ExpandableNavbarSectionComponent - ] + imports: [ + CommonModule, + SharedModule, + MenuModule, + FormsModule, + EffectsModule.forFeature(effects), + CoreModule.forRoot() + ], + declarations: [ + NavbarComponent, + ThemedNavbarComponent, + NavbarSectionComponent, + ExpandableNavbarSectionComponent, + ThemedExpandableNavbarSectionComponent, + ], + providers: [], + exports: [ + ThemedNavbarComponent, + NavbarSectionComponent, + ThemedExpandableNavbarSectionComponent + ] }) /** diff --git a/src/app/page-error/page-error.component.html b/src/app/page-error/page-error.component.html new file mode 100644 index 0000000000..9a5f02600a --- /dev/null +++ b/src/app/page-error/page-error.component.html @@ -0,0 +1,10 @@ + diff --git a/src/app/page-error/page-error.component.scss b/src/app/page-error/page-error.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/page-error/page-error.component.spec.ts b/src/app/page-error/page-error.component.spec.ts new file mode 100644 index 0000000000..0f876f3196 --- /dev/null +++ b/src/app/page-error/page-error.component.spec.ts @@ -0,0 +1,48 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { PageErrorComponent } from './page-error.component'; +import { ActivatedRoute } from '@angular/router'; +import { ActivatedRouteStub } from '../shared/testing/active-router.stub'; +import { of as observableOf } from 'rxjs'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { By } from '@angular/platform-browser'; +import { TranslateLoaderMock } from '../shared/testing/translate-loader.mock'; + +describe('PageErrorComponent', () => { + let component: PageErrorComponent; + let fixture: ComponentFixture; + const activatedRouteStub = Object.assign(new ActivatedRouteStub(), { + queryParams: observableOf({ + status: 401, + code: 'orcid.generic-error' + }) + }); + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ PageErrorComponent ], + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }) + ], + providers: [ + { provide: ActivatedRoute, useValue: activatedRouteStub }, + ] + }).compileComponents(); + + fixture = TestBed.createComponent(PageErrorComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + })); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should show error for 401 unauthorized', () => { + const statusElement = fixture.debugElement.query(By.css('[data-test="status"]')).nativeElement; + expect(statusElement.innerHTML).toEqual('401'); + }); +}); diff --git a/src/app/page-error/page-error.component.ts b/src/app/page-error/page-error.component.ts new file mode 100644 index 0000000000..dea1b68407 --- /dev/null +++ b/src/app/page-error/page-error.component.ts @@ -0,0 +1,27 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; + +/** + * This component representing the `PageError` DSpace page. + */ +@Component({ + selector: 'ds-page-error', + styleUrls: ['./page-error.component.scss'], + templateUrl: './page-error.component.html', + changeDetection: ChangeDetectionStrategy.Default +}) +export class PageErrorComponent { + status: number; + code: string; + /** + * Initialize instance variables + * + * @param {ActivatedRoute} activatedRoute + */ + constructor(private activatedRoute: ActivatedRoute) { + this.activatedRoute.queryParams.subscribe((params) => { + this.status = params.status; + this.code = params.code; + }); + } +} diff --git a/src/app/page-error/themed-page-error.component.ts b/src/app/page-error/themed-page-error.component.ts new file mode 100644 index 0000000000..34d29fb2a9 --- /dev/null +++ b/src/app/page-error/themed-page-error.component.ts @@ -0,0 +1,26 @@ +import { Component } from '@angular/core'; +import { ThemedComponent } from '../shared/theme-support/themed.component'; +import { PageErrorComponent } from './page-error.component'; + +/** + * Themed wrapper for PageErrorComponent + */ +@Component({ + selector: 'ds-themed-search-page', + styleUrls: [], + templateUrl: '../shared/theme-support/themed.component.html', +}) +export class ThemedPageErrorComponent extends ThemedComponent { + + protected getComponentName(): string { + return 'PageErrorComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../themes/${themeName}/app/page-error/page-error.component`); + } + + protected importUnthemedComponent(): Promise { + return import(`src/app/page-error/page-error.component`); + } +} diff --git a/src/app/process-page/detail/process-detail.component.html b/src/app/process-page/detail/process-detail.component.html index ddf6a0aadc..995e56e081 100644 --- a/src/app/process-page/detail/process-detail.component.html +++ b/src/app/process-page/detail/process-detail.component.html @@ -38,7 +38,7 @@ - +
    {{ (outputLogs$ | async) }}

    - + diff --git a/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.html b/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.html index 37b275d8f8..4837e0dcda 100644 --- a/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.html +++ b/src/app/request-copy/grant-deny-request-copy/grant-deny-request-copy.component.html @@ -26,5 +26,5 @@

    - + diff --git a/src/app/request-copy/grant-request-copy/grant-request-copy.component.html b/src/app/request-copy/grant-request-copy/grant-request-copy.component.html index d2c2cfc3c8..5cb4dbac36 100644 --- a/src/app/request-copy/grant-request-copy/grant-request-copy.component.html +++ b/src/app/request-copy/grant-request-copy/grant-request-copy.component.html @@ -13,5 +13,5 @@ - + diff --git a/src/app/root.module.ts b/src/app/root.module.ts index e5a8aad949..8577f0d728 100644 --- a/src/app/root.module.ts +++ b/src/app/root.module.ts @@ -40,6 +40,8 @@ import { import { PageInternalServerErrorComponent } from './page-internal-server-error/page-internal-server-error.component'; +import { ThemedPageErrorComponent } from './page-error/themed-page-error.component'; +import { PageErrorComponent } from './page-error/page-error.component'; const IMPORTS = [ CommonModule, @@ -74,7 +76,9 @@ const DECLARATIONS = [ ThemedForbiddenComponent, IdleModalComponent, ThemedPageInternalServerErrorComponent, - PageInternalServerErrorComponent + PageInternalServerErrorComponent, + ThemedPageErrorComponent, + PageErrorComponent ]; const EXPORTS = [ diff --git a/src/app/root/root.component.html b/src/app/root/root.component.html index d187782094..9ef70540e9 100644 --- a/src/app/root/root.component.html +++ b/src/app/root/root.component.html @@ -9,7 +9,7 @@
    - +
    @@ -24,5 +24,5 @@
    - +
    diff --git a/src/app/shared/auth-nav-menu/themed-auth-nav-menu.component.ts b/src/app/shared/auth-nav-menu/themed-auth-nav-menu.component.ts new file mode 100644 index 0000000000..14ea45d236 --- /dev/null +++ b/src/app/shared/auth-nav-menu/themed-auth-nav-menu.component.ts @@ -0,0 +1,25 @@ +import { Component } from '@angular/core'; +import { ThemedComponent } from '../theme-support/themed.component'; +import { AuthNavMenuComponent } from './auth-nav-menu.component'; + +/** + * Themed wrapper for {@link AuthNavMenuComponent} + */ +@Component({ + selector: 'ds-themed-auth-nav-menu', + styleUrls: [], + templateUrl: '../theme-support/themed.component.html', +}) +export class ThemedAuthNavMenuComponent extends ThemedComponent { + protected getComponentName(): string { + return 'AuthNavMenuComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../themes/${themeName}/app/shared/auth-nav-menu/auth-nav-menu.component`); + } + + protected importUnthemedComponent(): Promise { + return import(`./auth-nav-menu.component`); + } +} diff --git a/src/app/shared/auth-nav-menu/user-menu/user-menu.component.html b/src/app/shared/auth-nav-menu/user-menu/user-menu.component.html index ac55a211e9..736d39d318 100644 --- a/src/app/shared/auth-nav-menu/user-menu/user-menu.component.html +++ b/src/app/shared/auth-nav-menu/user-menu/user-menu.component.html @@ -1,4 +1,4 @@ - +
    {{(user$ | async)?.name}} ({{(user$ | async)?.email}}) {{'nav.profile' | translate}} diff --git a/src/app/shared/browse-by/browse-by.component.html b/src/app/shared/browse-by/browse-by.component.html index ca9474f632..7d9153b9f8 100644 --- a/src/app/shared/browse-by/browse-by.component.html +++ b/src/app/shared/browse-by/browse-by.component.html @@ -14,7 +14,7 @@ (next)="goNext()">
    - +
    diff --git a/src/app/shared/browse-by/browse-by.component.spec.ts b/src/app/shared/browse-by/browse-by.component.spec.ts index 8bda44b11c..4553ba16bf 100644 --- a/src/app/shared/browse-by/browse-by.component.spec.ts +++ b/src/app/shared/browse-by/browse-by.component.spec.ts @@ -17,7 +17,6 @@ import { SortDirection, SortOptions } from '../../core/cache/models/sort-options import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils'; import { PaginationService } from '../../core/pagination/pagination.service'; import { PaginationServiceStub } from '../testing/pagination-service.stub'; -import { FindListOptions } from '../../core/data/find-list-options.model'; import { ListableObjectComponentLoaderComponent } from '../object-collection/shared/listable-object/listable-object-component-loader.component'; @@ -37,7 +36,6 @@ import { HostWindowServiceStub } from '../testing/host-window-service.stub'; import { HostWindowService } from '../host-window.service'; import { RouteService } from '../../core/services/route.service'; import { routeServiceStub } from '../testing/route-service.stub'; -import SpyObj = jasmine.SpyObj; import { GroupDataService } from '../../core/eperson/group-data.service'; import { createPaginatedList } from '../testing/utils.test'; import { LinkHeadService } from '../../core/services/link-head.service'; @@ -45,6 +43,7 @@ import { ConfigurationDataService } from '../../core/data/configuration-data.ser import { ConfigurationProperty } from '../../core/shared/configuration-property.model'; import { SearchConfigurationServiceStub } from '../testing/search-configuration-service.stub'; import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service'; +import { getMockThemeService } from '../mocks/theme-service.mock'; @listableObjectComponent(BrowseEntry, ViewMode.ListElement, DEFAULT_CONTEXT, 'custom') @Component({ @@ -107,13 +106,10 @@ describe('BrowseByComponent', () => { }); const paginationService = new PaginationServiceStub(paginationConfig); - let themeService: SpyObj; + let themeService; beforeEach(waitForAsync(() => { - themeService = jasmine.createSpyObj('themeService', { - getThemeName: 'dspace', - getThemeName$: observableOf('dspace'), - }); + themeService = getMockThemeService('dspace'); TestBed.configureTestingModule({ imports: [ CommonModule, @@ -142,19 +138,16 @@ describe('BrowseByComponent', () => { ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); - })); - - beforeEach(() => { fixture = TestBed.createComponent(BrowseByComponent); comp = fixture.componentInstance; comp.paginationConfig = paginationConfig; fixture.detectChanges(); - }); + })); it('should display a loading message when objects is empty', () => { (comp as any).objects = undefined; fixture.detectChanges(); - expect(fixture.debugElement.query(By.css('ds-loading'))).toBeDefined(); + expect(fixture.debugElement.query(By.css('ds-themed-loading'))).toBeDefined(); }); it('should display results when objects is not empty', () => { @@ -247,10 +240,12 @@ describe('BrowseByComponent', () => { }); describe('back', () => { it('should navigate back to the main browse page', () => { + const id = 'test-pagination'; comp.back(); - expect(paginationService.updateRoute).toHaveBeenCalledWith('test-pagination', {page: 1}, { + expect(paginationService.updateRoute).toHaveBeenCalledWith(id, {page: 1}, { value: null, - startsWith: null + startsWith: null, + [id + '.return']: null }); }); }); diff --git a/src/app/shared/browse-by/browse-by.component.ts b/src/app/shared/browse-by/browse-by.component.ts index 1132b41124..79edf39096 100644 --- a/src/app/shared/browse-by/browse-by.component.ts +++ b/src/app/shared/browse-by/browse-by.component.ts @@ -1,10 +1,10 @@ -import { Component, EventEmitter, Injector, Input, OnInit, Output } from '@angular/core'; +import { Component, EventEmitter, Injector, Input, OnDestroy, OnInit, Output } from '@angular/core'; import { RemoteData } from '../../core/data/remote-data'; import { PaginatedList } from '../../core/data/paginated-list.model'; import { PaginationComponentOptions } from '../pagination/pagination-component-options.model'; import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; import { fadeIn, fadeInOut } from '../animations/fade'; -import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; +import { BehaviorSubject, combineLatest as observableCombineLatest, Observable, Subscription } from 'rxjs'; import { ListableObject } from '../object-collection/shared/listable-object.model'; import { getStartsWithComponent, StartsWithType } from '../starts-with/starts-with-decorator'; import { PaginationService } from '../../core/pagination/pagination.service'; @@ -25,7 +25,7 @@ import { hasValue } from '../empty.util'; /** * Component to display a browse-by page for any ListableObject */ -export class BrowseByComponent implements OnInit { +export class BrowseByComponent implements OnInit, OnDestroy { /** * ViewMode that should be passed to {@link ListableObjectComponentLoaderComponent}. @@ -112,6 +112,16 @@ export class BrowseByComponent implements OnInit { */ shouldDisplayResetButton$: Observable; + /** + * Page number of the previous page + */ + previousPage$ = new BehaviorSubject('1'); + + /** + * Subscription that has to be unsubscribed from on destroy + */ + sub: Subscription; + public constructor(private injector: Injector, protected paginationService: PaginationService, private routeService: RouteService, @@ -171,9 +181,20 @@ export class BrowseByComponent implements OnInit { this.shouldDisplayResetButton$ = observableCombineLatest([startsWith$, value$]).pipe( map(([startsWith, value]) => hasValue(startsWith) || hasValue(value)) ); + this.sub = this.routeService.getQueryParameterValue(this.paginationConfig.id + '.return').subscribe(this.previousPage$); } + /** + * Navigate back to the previous browse by page + */ back() { - this.paginationService.updateRoute(this.paginationConfig.id, {page: 1}, {value: null, startsWith: null}); + const page = +this.previousPage$.value > 1 ? +this.previousPage$.value : 1; + this.paginationService.updateRoute(this.paginationConfig.id, {page: page}, {[this.paginationConfig.id + '.return']: null, value: null, startsWith: null}); + } + + ngOnDestroy(): void { + if (this.sub) { + this.sub.unsubscribe(); + } } } diff --git a/src/app/shared/collection-dropdown/collection-dropdown.component.html b/src/app/shared/collection-dropdown/collection-dropdown.component.html index 87337d9084..db6c1fb41d 100644 --- a/src/app/shared/collection-dropdown/collection-dropdown.component.html +++ b/src/app/shared/collection-dropdown/collection-dropdown.component.html @@ -35,8 +35,8 @@
    diff --git a/src/app/shared/comcol/comcol-forms/edit-comcol-page/comcol-role/comcol-role.component.html b/src/app/shared/comcol/comcol-forms/edit-comcol-page/comcol-role/comcol-role.component.html index 905186a232..1b21521d95 100644 --- a/src/app/shared/comcol/comcol-forms/edit-comcol-page/comcol-role/comcol-role.component.html +++ b/src/app/shared/comcol/comcol-forms/edit-comcol-page/comcol-role/comcol-role.component.html @@ -13,7 +13,7 @@
    - +
    {{'comcol-role.edit.no-group' | translate}}
    diff --git a/src/app/shared/comcol/comcol-page-handle/comcol-page-handle.component.html b/src/app/shared/comcol/comcol-page-handle/comcol-page-handle.component.html index b3ca75bf94..552854a0c0 100644 --- a/src/app/shared/comcol/comcol-page-handle/comcol-page-handle.component.html +++ b/src/app/shared/comcol/comcol-page-handle/comcol-page-handle.component.html @@ -1,4 +1,4 @@

    {{ title | translate }}

    - +
    diff --git a/src/app/shared/cookies/browser-klaro.service.ts b/src/app/shared/cookies/browser-klaro.service.ts index 4e6370f179..638d465864 100644 --- a/src/app/shared/cookies/browser-klaro.service.ts +++ b/src/app/shared/cookies/browser-klaro.service.ts @@ -63,6 +63,11 @@ export class BrowserKlaroService extends KlaroService { * - Add and translate klaro configuration messages */ initialize() { + if (!environment.info.enablePrivacyStatement) { + delete this.klaroConfig.privacyPolicy; + this.klaroConfig.translations.en.consentNotice.description = 'cookies.consent.content-notice.description.no-privacy'; + } + this.translateService.setDefaultLang(environment.defaultLanguage); const user$: Observable = this.getUser$(); @@ -90,7 +95,6 @@ export class BrowserKlaroService extends KlaroService { this.translateConfiguration(); Klaro.setup(this.klaroConfig); }); - } /** diff --git a/src/app/shared/cookies/klaro-configuration.ts b/src/app/shared/cookies/klaro-configuration.ts index 1c87e46309..659583ad87 100644 --- a/src/app/shared/cookies/klaro-configuration.ts +++ b/src/app/shared/cookies/klaro-configuration.ts @@ -24,7 +24,7 @@ export const klaroConfiguration: any = { /* Setting 'hideLearnMore' to 'true' will hide the "learn more / customize" link in the consent notice. We strongly advise against using this under most - circumstances, as it keeps the user from customizing his/her consent choices. + circumstances, as it keeps the user from customizing their consent choices. */ hideLearnMore: false, diff --git a/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.scss b/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.scss index e8b7d689a3..8b13789179 100644 --- a/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.scss +++ b/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.scss @@ -1,3 +1 @@ -.btn-dark { - background-color: var(--ds-admin-sidebar-bg); -} + diff --git a/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.html b/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.html new file mode 100644 index 0000000000..305900ae33 --- /dev/null +++ b/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.html @@ -0,0 +1,6 @@ + + diff --git a/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.scss b/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.spec.ts b/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.spec.ts new file mode 100644 index 0000000000..c70ec4b808 --- /dev/null +++ b/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.spec.ts @@ -0,0 +1,76 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +import { Item } from '../../../core/shared/item.model'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { of as observableOf } from 'rxjs'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; +import { By } from '@angular/platform-browser'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { DsoPageOrcidButtonComponent } from './dso-page-orcid-button.component'; + +describe('DsoPageOrcidButtonComponent', () => { + let component: DsoPageOrcidButtonComponent; + let fixture: ComponentFixture; + + let authorizationService: AuthorizationDataService; + let dso: DSpaceObject; + + beforeEach(waitForAsync(() => { + dso = Object.assign(new Item(), { + id: 'test-item', + _links: { + self: { href: 'test-item-selflink' } + } + }); + authorizationService = jasmine.createSpyObj('authorizationService', { + isAuthorized: observableOf(true) + }); + TestBed.configureTestingModule({ + declarations: [DsoPageOrcidButtonComponent], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NgbModule], + providers: [ + { provide: AuthorizationDataService, useValue: authorizationService } + ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DsoPageOrcidButtonComponent); + component = fixture.componentInstance; + component.dso = dso; + component.pageRoute = 'test'; + fixture.detectChanges(); + }); + + it('should check the authorization of the current user', () => { + expect(authorizationService.isAuthorized).toHaveBeenCalledWith(FeatureID.CanSynchronizeWithORCID, dso.self); + }); + + describe('when the user is authorized', () => { + beforeEach(() => { + (authorizationService.isAuthorized as jasmine.Spy).and.returnValue(observableOf(true)); + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('should render a link', () => { + const link = fixture.debugElement.query(By.css('a')); + expect(link).not.toBeNull(); + }); + }); + + describe('when the user is not authorized', () => { + beforeEach(() => { + (authorizationService.isAuthorized as jasmine.Spy).and.returnValue(observableOf(false)); + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('should not render a link', () => { + const link = fixture.debugElement.query(By.css('a')); + expect(link).toBeNull(); + }); + }); +}); diff --git a/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.ts b/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.ts new file mode 100644 index 0000000000..c345d8cbdc --- /dev/null +++ b/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.ts @@ -0,0 +1,39 @@ +import { Component, Input, OnInit } from '@angular/core'; + +import { BehaviorSubject } from 'rxjs'; +import { take } from 'rxjs/operators'; + +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; + +@Component({ + selector: 'ds-dso-page-orcid-button', + templateUrl: './dso-page-orcid-button.component.html', + styleUrls: ['./dso-page-orcid-button.component.scss'] +}) +export class DsoPageOrcidButtonComponent implements OnInit { + /** + * The DSpaceObject to display a button to the edit page for + */ + @Input() dso: DSpaceObject; + + /** + * The prefix of the route to the edit page (before the object's UUID, e.g. "items") + */ + @Input() pageRoute: string; + + /** + * Whether or not the current user is authorized to edit the DSpaceObject + */ + isAuthorized: BehaviorSubject = new BehaviorSubject(false); + + constructor(protected authorizationService: AuthorizationDataService) { } + + ngOnInit() { + this.authorizationService.isAuthorized(FeatureID.CanSynchronizeWithORCID, this.dso.self).pipe(take(1)).subscribe((isAuthorized: boolean) => { + this.isAuthorized.next(isAuthorized); + }); + } + +} diff --git a/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.html b/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.html index 12d5d2b47d..c4bba286bf 100644 --- a/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.html +++ b/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.html @@ -1 +1,7 @@ - + diff --git a/src/app/shared/dso-selector/dso-selector/dso-selector.component.html b/src/app/shared/dso-selector/dso-selector/dso-selector.component.html index 198da8d3ed..8abb8ad558 100644 --- a/src/app/shared/dso-selector/dso-selector/dso-selector.component.html +++ b/src/app/shared/dso-selector/dso-selector/dso-selector.component.html @@ -31,7 +31,7 @@
    diff --git a/src/app/shared/dso-selector/modal-wrappers/export-metadata-selector/export-metadata-selector.component.spec.ts b/src/app/shared/dso-selector/modal-wrappers/export-metadata-selector/export-metadata-selector.component.spec.ts index f6b3581df5..df3e4f095c 100644 --- a/src/app/shared/dso-selector/modal-wrappers/export-metadata-selector/export-metadata-selector.component.spec.ts +++ b/src/app/shared/dso-selector/modal-wrappers/export-metadata-selector/export-metadata-selector.component.spec.ts @@ -20,6 +20,7 @@ import { createSuccessfulRemoteDataObject$ } from '../../../remote-data.utils'; import { ExportMetadataSelectorComponent } from './export-metadata-selector.component'; +import { AuthorizationDataService } from '../../../../core/data/feature-authorization/authorization-data.service'; // No way to add entryComponents yet to testbed; alternative implemented; source: https://stackoverflow.com/questions/41689468/how-to-shallow-test-a-component-with-an-entrycomponents @NgModule({ @@ -47,6 +48,7 @@ describe('ExportMetadataSelectorComponent', () => { let router; let notificationService: NotificationsServiceStub; let scriptService; + let authorizationDataService; const mockItem = Object.assign(new Item(), { id: 'fake-id', @@ -95,6 +97,9 @@ describe('ExportMetadataSelectorComponent', () => { invoke: createSuccessfulRemoteDataObject$({ processId: '45' }) } ); + authorizationDataService = jasmine.createSpyObj('authorizationDataService', { + isAuthorized: observableOf(true) + }); TestBed.configureTestingModule({ imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), ModelTestModule], declarations: [ExportMetadataSelectorComponent], @@ -102,6 +107,7 @@ describe('ExportMetadataSelectorComponent', () => { { provide: NgbActiveModal, useValue: modalStub }, { provide: NotificationsService, useValue: notificationService }, { provide: ScriptDataService, useValue: scriptService }, + { provide: AuthorizationDataService, useValue: authorizationDataService }, { provide: ActivatedRoute, useValue: { @@ -150,7 +156,7 @@ describe('ExportMetadataSelectorComponent', () => { }); }); - describe('if collection is selected', () => { + describe('if collection is selected and is admin', () => { let scriptRequestSucceeded; beforeEach((done) => { spyOn((component as any).modalService, 'open').and.returnValue(modalRef); @@ -159,7 +165,32 @@ describe('ExportMetadataSelectorComponent', () => { done(); }); }); - it('should invoke the metadata-export script with option -i uuid', () => { + it('should invoke the metadata-export script with option -i uuid and -a option', () => { + const parameterValues: ProcessParameter[] = [ + Object.assign(new ProcessParameter(), { name: '-i', value: mockCollection.uuid }), + Object.assign(new ProcessParameter(), { name: '-a' }), + ]; + expect(scriptService.invoke).toHaveBeenCalledWith(METADATA_EXPORT_SCRIPT_NAME, parameterValues, []); + }); + it('success notification is shown', () => { + expect(scriptRequestSucceeded).toBeTrue(); + expect(notificationService.success).toHaveBeenCalled(); + }); + it('redirected to process page', () => { + expect(router.navigateByUrl).toHaveBeenCalledWith('/processes/45'); + }); + }); + describe('if collection is selected and is not admin', () => { + let scriptRequestSucceeded; + beforeEach((done) => { + (authorizationDataService.isAuthorized as jasmine.Spy).and.returnValue(observableOf(false)); + spyOn((component as any).modalService, 'open').and.returnValue(modalRef); + component.navigate(mockCollection).subscribe((succeeded: boolean) => { + scriptRequestSucceeded = succeeded; + done(); + }); + }); + it('should invoke the metadata-export script with option -i uuid without the -a option', () => { const parameterValues: ProcessParameter[] = [ Object.assign(new ProcessParameter(), { name: '-i', value: mockCollection.uuid }), ]; @@ -174,7 +205,7 @@ describe('ExportMetadataSelectorComponent', () => { }); }); - describe('if community is selected', () => { + describe('if community is selected and is an admin', () => { let scriptRequestSucceeded; beforeEach((done) => { spyOn((component as any).modalService, 'open').and.returnValue(modalRef); @@ -183,7 +214,32 @@ describe('ExportMetadataSelectorComponent', () => { done(); }); }); - it('should invoke the metadata-export script with option -i uuid', () => { + it('should invoke the metadata-export script with option -i uuid and -a option if the user is an admin', () => { + const parameterValues: ProcessParameter[] = [ + Object.assign(new ProcessParameter(), { name: '-i', value: mockCommunity.uuid }), + Object.assign(new ProcessParameter(), { name: '-a' }), + ]; + expect(scriptService.invoke).toHaveBeenCalledWith(METADATA_EXPORT_SCRIPT_NAME, parameterValues, []); + }); + it('success notification is shown', () => { + expect(scriptRequestSucceeded).toBeTrue(); + expect(notificationService.success).toHaveBeenCalled(); + }); + it('redirected to process page', () => { + expect(router.navigateByUrl).toHaveBeenCalledWith('/processes/45'); + }); + }); + describe('if community is selected and is not an admin', () => { + let scriptRequestSucceeded; + beforeEach((done) => { + (authorizationDataService.isAuthorized as jasmine.Spy).and.returnValue(observableOf(false)); + spyOn((component as any).modalService, 'open').and.returnValue(modalRef); + component.navigate(mockCommunity).subscribe((succeeded: boolean) => { + scriptRequestSucceeded = succeeded; + done(); + }); + }); + it('should invoke the metadata-export script with option -i uuid without the -a option', () => { const parameterValues: ProcessParameter[] = [ Object.assign(new ProcessParameter(), { name: '-i', value: mockCommunity.uuid }), ]; diff --git a/src/app/shared/dso-selector/modal-wrappers/export-metadata-selector/export-metadata-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/export-metadata-selector/export-metadata-selector.component.ts index a04fc0a1cd..d4b4314a99 100644 --- a/src/app/shared/dso-selector/modal-wrappers/export-metadata-selector/export-metadata-selector.component.ts +++ b/src/app/shared/dso-selector/modal-wrappers/export-metadata-selector/export-metadata-selector.component.ts @@ -19,6 +19,8 @@ import { getFirstCompletedRemoteData } from '../../../../core/shared/operators'; import { Process } from '../../../../process-page/processes/process.model'; import { RemoteData } from '../../../../core/data/remote-data'; import { getProcessDetailRoute } from '../../../../process-page/process-page-routing.paths'; +import { AuthorizationDataService } from '../../../../core/data/feature-authorization/authorization-data.service'; +import { FeatureID } from '../../../../core/data/feature-authorization/feature-id'; /** * Component to wrap a list of existing dso's inside a modal @@ -36,6 +38,7 @@ export class ExportMetadataSelectorComponent extends DSOSelectorModalWrapperComp constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute, private router: Router, protected notificationsService: NotificationsService, protected translationService: TranslateService, protected scriptDataService: ScriptDataService, + protected authorizationDataService: AuthorizationDataService, private modalService: NgbModal) { super(activeModal, route); } @@ -82,24 +85,29 @@ export class ExportMetadataSelectorComponent extends DSOSelectorModalWrapperComp const parameterValues: ProcessParameter[] = [ Object.assign(new ProcessParameter(), { name: '-i', value: dso.uuid }), ]; - return this.scriptDataService.invoke(METADATA_EXPORT_SCRIPT_NAME, parameterValues, []) - .pipe( - getFirstCompletedRemoteData(), - map((rd: RemoteData) => { - if (rd.hasSucceeded) { - const title = this.translationService.get('process.new.notification.success.title'); - const content = this.translationService.get('process.new.notification.success.content'); - this.notificationsService.success(title, content); - if (isNotEmpty(rd.payload)) { - this.router.navigateByUrl(getProcessDetailRoute(rd.payload.processId)); - } - return true; - } else { - const title = this.translationService.get('process.new.notification.error.title'); - const content = this.translationService.get('process.new.notification.error.content'); - this.notificationsService.error(title, content); - return false; + return this.authorizationDataService.isAuthorized(FeatureID.AdministratorOf).pipe( + switchMap((isAdmin) => { + if (isAdmin) { + parameterValues.push(Object.assign(new ProcessParameter(), {name: '-a'})); + } + return this.scriptDataService.invoke(METADATA_EXPORT_SCRIPT_NAME, parameterValues, []); + }), + getFirstCompletedRemoteData(), + map((rd: RemoteData) => { + if (rd.hasSucceeded) { + const title = this.translationService.get('process.new.notification.success.title'); + const content = this.translationService.get('process.new.notification.success.content'); + this.notificationsService.success(title, content); + if (isNotEmpty(rd.payload)) { + this.router.navigateByUrl(getProcessDetailRoute(rd.payload.processId)); } - })); + return true; + } else { + const title = this.translationService.get('process.new.notification.error.title'); + const content = this.translationService.get('process.new.notification.error.content'); + this.notificationsService.error(title, content); + return false; + } + })); } } diff --git a/src/app/shared/entity-dropdown/entity-dropdown.component.html b/src/app/shared/entity-dropdown/entity-dropdown.component.html index 59c242ef97..a4d539625f 100644 --- a/src/app/shared/entity-dropdown/entity-dropdown.component.html +++ b/src/app/shared/entity-dropdown/entity-dropdown.component.html @@ -21,8 +21,8 @@
    diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.html index 07ea131a00..62d34a8625 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component.html @@ -1,7 +1,7 @@
    - + diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/existing-relation-list-element/existing-relation-list-element.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/existing-relation-list-element/existing-relation-list-element.component.html index 15087d2553..ff91b18e1c 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/existing-relation-list-element/existing-relation-list-element.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/existing-relation-list-element/existing-relation-list-element.component.html @@ -1,7 +1,7 @@
    - + diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.component.html index 84bc0f4ffe..05f443aad5 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.component.html @@ -57,7 +57,7 @@
    - +
    diff --git a/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.html b/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.html index 3f57e6a4c3..39c62d6e53 100644 --- a/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.html +++ b/src/app/shared/vocabulary-treeview/vocabulary-treeview.component.html @@ -23,7 +23,7 @@
    - +

    {{'vocabulary-treeview.search.no-result' | translate}}

    diff --git a/src/app/statistics-page/statistics-page/statistics-page.component.html b/src/app/statistics-page/statistics-page/statistics-page.component.html index 5cf1e9c8b5..c6938c7582 100644 --- a/src/app/statistics-page/statistics-page/statistics-page.component.html +++ b/src/app/statistics-page/statistics-page/statistics-page.component.html @@ -11,7 +11,7 @@ - + diff --git a/src/app/submission/form/submission-form.component.html b/src/app/submission/form/submission-form.component.html index c86d4e0195..a80fe35f4e 100644 --- a/src/app/submission/form/submission-form.component.html +++ b/src/app/submission/form/submission-form.component.html @@ -22,7 +22,7 @@
    - +