diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index eeddb37441..04d426d091 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,7 +22,7 @@ jobs: strategy: # Create a matrix of Node versions to test against (in parallel) matrix: - node-version: [12.x, 14.x] + node-version: [14.x, 16.x] # Do NOT exit immediately if one matrix job fails fail-fast: false # These are the actual CI steps to perform per job @@ -82,11 +82,11 @@ jobs: run: yarn run test:headless # NOTE: Angular CLI only supports code coverage for specs. See https://github.com/angular/angular-cli/issues/6286 - # Upload coverage reports to Codecov (for Node v12 only) + # Upload coverage reports to Codecov (for one version of Node only) # https://github.com/codecov/codecov-action - name: Upload coverage to Codecov.io uses: codecov/codecov-action@v2 - if: matrix.node-version == '12.x' + if: matrix.node-version == '16.x' # Using docker-compose start backend using CI configuration # and load assetstore from a cached copy diff --git a/README.md b/README.md index 0e26d9e492..cef95a45fa 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ https://wiki.lyrasis.org/display/DSDOC7x/Installing+DSpace Quick start ----------- -**Ensure you're running [Node](https://nodejs.org) `v12.x`, `v14.x` or `v16.x`, [npm](https://www.npmjs.com/) >= `v5.x` and [yarn](https://yarnpkg.com) == `v1.x`** +**Ensure you're running [Node](https://nodejs.org) `v14.x` or `v16.x`, [npm](https://www.npmjs.com/) >= `v5.x` and [yarn](https://yarnpkg.com) == `v1.x`** ```bash # clone the repo @@ -90,7 +90,7 @@ Requirements ------------ - [Node.js](https://nodejs.org) and [yarn](https://yarnpkg.com) -- Ensure you're running node `v12.x`, `v14.x` or `v16.x` and yarn == `v1.x` +- Ensure you're running node `v14.x` or `v16.x` and yarn == `v1.x` If you have [`nvm`](https://github.com/creationix/nvm#install-script) or [`nvm-windows`](https://github.com/coreybutler/nvm-windows) installed, which is highly recommended, you can run `nvm install --lts && nvm use` to install and start using the latest Node LTS. diff --git a/angular.json b/angular.json index 56e06bd86c..2ece0c5e7d 100644 --- a/angular.json +++ b/angular.json @@ -63,7 +63,8 @@ "bundleName": "dspace-theme" } ], - "scripts": [] + "scripts": [], + "baseHref": "/" }, "configurations": { "development": { diff --git a/config/config.example.yml b/config/config.example.yml index 898b47784f..3f88b32324 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 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/docker/db.entities.yml b/docker/db.entities.yml index d1dfdf4a26..6473bf2e38 100644 --- a/docker/db.entities.yml +++ b/docker/db.entities.yml @@ -25,7 +25,7 @@ services: ### OVERRIDE default 'entrypoint' in 'docker-compose-rest.yml' #### # Ensure that the database is ready BEFORE starting tomcat # 1. While a TCP connection to dspacedb port 5432 is not available, continue to sleep - # 2. Then, run database migration to init database tables + # 2. Then, run database migration to init database tables (including any out-of-order ignored migrations, if any) # 3. (Custom for Entities) enable Entity-specific collection submission mappings in item-submission.xml # This 'sed' command inserts the sample configurations specific to the Entities data set, see: # https://github.com/DSpace/DSpace/blob/main/dspace/config/item-submission.xml#L36-L49 @@ -35,7 +35,7 @@ services: - '-c' - | while (! /dev/null 2>&1; do sleep 1; done; - /dspace/bin/dspace database migrate + /dspace/bin/dspace database migrate ignored sed -i '/name-map collection-handle="default".*/a \\n \ \ \ diff --git a/docker/docker-compose-ci.yml b/docker/docker-compose-ci.yml index 3bd8f52630..dbe9500499 100644 --- a/docker/docker-compose-ci.yml +++ b/docker/docker-compose-ci.yml @@ -46,14 +46,14 @@ services: - solr_configs:/dspace/solr # Ensure that the database is ready BEFORE starting tomcat # 1. While a TCP connection to dspacedb port 5432 is not available, continue to sleep - # 2. Then, run database migration to init database tables + # 2. Then, run database migration to init database tables (including any out-of-order ignored migrations, if any) # 3. Finally, start Tomcat entrypoint: - /bin/bash - '-c' - | while (! /dev/null 2>&1; do sleep 1; done; - /dspace/bin/dspace database migrate + /dspace/bin/dspace database migrate ignored catalina.sh run # DSpace database container # NOTE: This is customized to use our loadsql image, so that we are using a database with existing test data diff --git a/package.json b/package.json index c0e8a994fa..dbb4cca8a5 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,11 @@ "start:dev": "nodemon --exec \"cross-env NODE_ENV=development yarn run serve\"", "start:prod": "yarn run build:prod && cross-env NODE_ENV=production yarn run serve:ssr", "start:mirador:prod": "yarn run build:mirador && yarn run start:prod", - "serve": "ng serve -c development", + "preserve": "yarn base-href", + "serve": "ng serve --configuration development", "serve:ssr": "node dist/server/main", "analyze": "webpack-bundle-analyzer dist/browser/stats.json", - "build": "ng build -c development", + "build": "ng build --configuration development", "build:stats": "ng build --stats-json", "build:prod": "yarn run build:ssr", "build:ssr": "ng build --configuration production && ng run dspace-angular:server:production", @@ -37,6 +38,7 @@ "cypress:open": "cypress open", "cypress:run": "cypress run", "env:yaml": "ts-node --project ./tsconfig.ts-node.json scripts/env-to-yaml.ts", + "base-href": "ts-node --project ./tsconfig.ts-node.json scripts/base-href.ts", "check-circ-deps": "npx madge --exclude '(bitstream|bundle|collection|config-submission-form|eperson|item|version)\\.model\\.ts$' --circular --extensions ts ./" }, "browser": { @@ -78,6 +80,7 @@ "@nicky-lenaers/ngx-scroll-to": "^9.0.0", "angular-idle-preload": "3.0.0", "angulartics2": "^12.0.0", + "axios": "^0.27.2", "bootstrap": "4.3.1", "caniuse-lite": "^1.0.30001165", "cerialize": "0.1.18", @@ -104,7 +107,7 @@ "mirador": "^3.3.0", "mirador-dl-plugin": "^0.13.0", "mirador-share-plugin": "^0.11.0", - "moment": "^2.29.1", + "moment": "^2.29.2", "morgan": "^1.10.0", "ng-mocks": "^13.1.1", "ng2-file-upload": "1.4.0", @@ -125,7 +128,8 @@ "url-parse": "^1.5.6", "uuid": "^8.3.2", "webfontloader": "1.6.28", - "zone.js": "~0.11.5" + "zone.js": "~0.11.5", + "ngx-ui-switch": "^11.0.1" }, "devDependencies": { "@angular-builders/custom-webpack": "~13.1.0", @@ -210,4 +214,4 @@ "webpack-cli": "^4.2.0", "webpack-dev-server": "^4.5.0" } -} \ No newline at end of file +} diff --git a/scripts/base-href.ts b/scripts/base-href.ts new file mode 100644 index 0000000000..aee547b46d --- /dev/null +++ b/scripts/base-href.ts @@ -0,0 +1,36 @@ +import * as fs from 'fs'; +import { join } from 'path'; + +import { AppConfig } from '../src/config/app-config.interface'; +import { buildAppConfig } from '../src/config/config.server'; + +/** + * Script to set baseHref as `ui.nameSpace` for development mode. Adds `baseHref` to angular.json build options. + * + * Usage (see package.json): + * + * yarn base-href + */ + +const appConfig: AppConfig = buildAppConfig(); + +const angularJsonPath = join(process.cwd(), 'angular.json'); + +if (!fs.existsSync(angularJsonPath)) { + console.error(`Error:\n${angularJsonPath} does not exist\n`); + process.exit(1); +} + +try { + const angularJson = require(angularJsonPath); + + const baseHref = `${appConfig.ui.nameSpace}${appConfig.ui.nameSpace.endsWith('/') ? '' : '/'}`; + + console.log(`Setting baseHref to ${baseHref} in angular.json`); + + angularJson.projects['dspace-angular'].architect.build.options.baseHref = baseHref; + + fs.writeFileSync(angularJsonPath, JSON.stringify(angularJson, null, 2) + '\n'); +} catch (e) { + console.error(e); +} diff --git a/scripts/sync-i18n-files.ts b/scripts/sync-i18n-files.ts index ad8a712f21..96ba0d4010 100755 --- a/scripts/sync-i18n-files.ts +++ b/scripts/sync-i18n-files.ts @@ -1,4 +1,5 @@ -import { projectRoot} from '../webpack/helpers'; +import { projectRoot } from '../webpack/helpers'; + const commander = require('commander'); const fs = require('fs'); const JSON5 = require('json5'); @@ -119,7 +120,7 @@ function syncFileWithSource(pathToTargetFile, pathToOutputFile) { outputChunks.forEach(function (chunk) { progressBar.increment(); chunk.split("\n").forEach(function (line) { - file.write(" " + line + "\n"); + file.write((line === '' ? '' : ` ${line}`) + "\n"); }); }); file.write("\n}"); @@ -192,7 +193,10 @@ function createNewChunkComparingSourceAndTarget(correspondingTargetChunk, source const targetList = correspondingTargetChunk.split("\n"); const oldKeyValueInTargetComments = getSubStringWithRegex(correspondingTargetChunk, "\\s*\\/\\/\\s*\".*"); - const keyValueTarget = targetList[targetList.length - 1]; + let keyValueTarget = targetList[targetList.length - 1]; + if (!keyValueTarget.endsWith(",")) { + keyValueTarget = keyValueTarget + ","; + } if (oldKeyValueInTargetComments != null) { const oldKeyValueUncommented = getSubStringWithRegex(oldKeyValueInTargetComments[0], "\".*")[0]; diff --git a/server.ts b/server.ts index 12a5a3ecc2..9fe03fe5b5 100644 --- a/server.ts +++ b/server.ts @@ -19,6 +19,7 @@ import 'zone.js/node'; import 'reflect-metadata'; import 'rxjs'; +import axios from 'axios'; import * as pem from 'pem'; import * as https from 'https'; import * as morgan from 'morgan'; @@ -38,14 +39,14 @@ import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; import { environment } from './src/environments/environment'; import { createProxyMiddleware } from 'http-proxy-middleware'; -import { hasValue, hasNoValue } from './src/app/shared/empty.util'; +import { hasNoValue, hasValue } from './src/app/shared/empty.util'; import { UIServerConfig } from './src/config/ui-server-config.interface'; import { ServerAppModule } from './src/main.server'; import { buildAppConfig } from './src/config/config.server'; -import { AppConfig, APP_CONFIG } from './src/config/app-config.interface'; +import { APP_CONFIG, AppConfig } from './src/config/app-config.interface'; import { extendEnvironmentWithAppConfig } from './src/config/config.util'; /* @@ -67,6 +68,8 @@ extendEnvironmentWithAppConfig(environment, appConfig); // The Express app is exported so that it can be used by serverless Functions. export function app() { + const router = express.Router(); + /* * Create a new express application */ @@ -138,7 +141,11 @@ export function app() { /** * Proxy the sitemaps */ - server.use('/sitemap**', createProxyMiddleware({ target: `${environment.rest.baseUrl}/sitemaps`, changeOrigin: true })); + router.use('/sitemap**', createProxyMiddleware({ + target: `${environment.rest.baseUrl}/sitemaps`, + pathRewrite: path => path.replace(environment.ui.nameSpace, '/'), + changeOrigin: true + })); /** * Checks if the rateLimiter property is present @@ -157,7 +164,7 @@ export function app() { * Serve static resources (images, i18n messages, …) * Handle pre-compressed files with [express-static-gzip](https://github.com/tkoenig89/express-static-gzip) */ - server.get('*.*', cacheControl, expressStaticGzip(DIST_FOLDER, { + router.get('*.*', cacheControl, expressStaticGzip(DIST_FOLDER, { index: false, enableBrotli: true, orderPreference: ['br', 'gzip'], @@ -166,10 +173,17 @@ export function app() { /* * Fallthrough to the IIIF viewer (must be included in the build). */ - server.use('/iiif', express.static(IIIF_VIEWER, {index:false})); + router.use('/iiif', express.static(IIIF_VIEWER, { index: false })); + + /** + * Checking server status + */ + server.get('/app/health', healthCheck); // Register the ngApp callback function to handle incoming requests - server.get('*', ngApp); + router.get('*', ngApp); + + server.use(environment.ui.nameSpace, router); return server; } @@ -203,13 +217,25 @@ function ngApp(req, res) { if (hasValue(err)) { console.warn('Error details : ', err); } - res.sendFile(DIST_FOLDER + '/index.html'); + res.render(indexHtml, { + req, + providers: [{ + provide: APP_BASE_HREF, + useValue: req.baseUrl + }] + }); } }); } else { // If preboot is disabled, just serve the client console.log('Universal off, serving for direct CSR'); - res.sendFile(DIST_FOLDER + '/index.html'); + res.render(indexHtml, { + req, + providers: [{ + provide: APP_BASE_HREF, + useValue: req.baseUrl + }] + }); } } @@ -299,6 +325,21 @@ function start() { } } +/* + * The callback function to serve health check requests + */ +function healthCheck(req, res) { + const baseUrl = `${environment.rest.baseUrl}${environment.actuators.endpointPath}`; + axios.get(baseUrl) + .then((response) => { + res.status(response.status).send(response.data); + }) + .catch((error) => { + res.status(error.response.status).send({ + error: error.message + }); + }); +} // Webpack will replace 'require' with '__webpack_require__' // '__non_webpack_require__' is a proxy to Node 'require' // The below code is to ensure that the server is run only when not requiring the bundle. diff --git a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts index 334d69f19a..a6ea7e4946 100644 --- a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts +++ b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts @@ -18,8 +18,8 @@ import { ItemAdminSearchResultGridElementComponent } from './item-admin-search-r import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/remote-data.utils'; import { getMockThemeService } from '../../../../../shared/mocks/theme-service.mock'; import { ThemeService } from '../../../../../shared/theme-support/theme.service'; -import { AccessStatusDataService } from 'src/app/core/data/access-status-data.service'; -import { AccessStatusObject } from 'src/app/shared/object-list/access-status-badge/access-status.model'; +import { AccessStatusDataService } from '../../../../../core/data/access-status-data.service'; +import { AccessStatusObject } from '../../../../../shared/object-list/access-status-badge/access-status.model'; describe('ItemAdminSearchResultGridElementComponent', () => { let component: ItemAdminSearchResultGridElementComponent; diff --git a/src/app/app-routing-paths.ts b/src/app/app-routing-paths.ts index 6524edef77..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}`; @@ -122,3 +124,5 @@ export const REQUEST_COPY_MODULE_PATH = 'request-a-copy'; export function getRequestCopyModulePath() { return `/${REQUEST_COPY_MODULE_PATH}`; } + +export const HEALTH_PAGE_PATH = 'health'; diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index f0869d9fb6..d426b041ce 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -3,13 +3,17 @@ import { RouterModule, NoPreloading } from '@angular/router'; import { AuthBlockingGuard } from './core/auth/auth-blocking.guard'; import { AuthenticatedGuard } from './core/auth/authenticated.guard'; -import { SiteAdministratorGuard } from './core/data/feature-authorization/feature-authorization-guard/site-administrator.guard'; +import { + SiteAdministratorGuard +} from './core/data/feature-authorization/feature-authorization-guard/site-administrator.guard'; import { ACCESS_CONTROL_MODULE_PATH, ADMIN_MODULE_PATH, BITSTREAM_MODULE_PATH, + ERROR_PAGE, FORBIDDEN_PATH, FORGOT_PASSWORD_PATH, + HEALTH_PAGE_PATH, INFO_MODULE_PATH, INTERNAL_SERVER_ERROR, LEGACY_BITSTREAM_MODULE_PATH, @@ -27,15 +31,21 @@ import { EndUserAgreementCurrentUserGuard } from './core/end-user-agreement/end- import { SiteRegisterGuard } from './core/data/feature-authorization/feature-authorization-guard/site-register.guard'; import { ThemedPageNotFoundComponent } from './pagenotfound/themed-pagenotfound.component'; import { ThemedForbiddenComponent } from './forbidden/themed-forbidden.component'; -import { GroupAdministratorGuard } from './core/data/feature-authorization/feature-authorization-guard/group-administrator.guard'; -import { ThemedPageInternalServerErrorComponent } from './page-internal-server-error/themed-page-internal-server-error.component'; +import { + GroupAdministratorGuard +} from './core/data/feature-authorization/feature-authorization-guard/group-administrator.guard'; +import { + ThemedPageInternalServerErrorComponent +} 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], @@ -210,6 +220,11 @@ import { MenuResolver } from './menu.resolver'; loadChildren: () => import('./statistics-page/statistics-page-routing.module') .then((m) => m.StatisticsPageRoutingModule) }, + { + path: HEALTH_PAGE_PATH, + loadChildren: () => import('./health-page/health-page.module') + .then((m) => m.HealthPageModule) + }, { path: ACCESS_CONTROL_MODULE_PATH, loadChildren: () => import('./access-control/access-control.module').then((m) => m.AccessControlModule), diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 9f215da46d..f2243d435e 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -187,7 +187,7 @@ describe('App component', () => { link.setAttribute('rel', 'stylesheet'); link.setAttribute('type', 'text/css'); link.setAttribute('class', 'theme-css'); - link.setAttribute('href', '/custom-theme.css'); + link.setAttribute('href', 'custom-theme.css'); expect(headSpy.appendChild).toHaveBeenCalledWith(link); }); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 6aa569d8e6..ee8c4d685f 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -12,6 +12,7 @@ import { } from '@angular/core'; import { ActivatedRouteSnapshot, + ActivationEnd, NavigationCancel, NavigationEnd, NavigationStart, ResolveEnd, @@ -21,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'; @@ -48,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', @@ -105,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, ) { @@ -165,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() ); @@ -196,30 +209,30 @@ export class AppComponent implements OnInit, AfterViewInit { } ngAfterViewInit() { - let resolveEndFound = false; + let updatingTheme = false; + let snapshot: ActivatedRouteSnapshot; + this.router.events.subscribe((event) => { if (event instanceof NavigationStart) { - resolveEndFound = false; + updatingTheme = false; this.distinctNext(this.isRouteLoading$, true); - } else if (event instanceof ResolveEnd) { - resolveEndFound = true; - const activatedRouteSnapShot: ActivatedRouteSnapshot = event.state.root; - this.themeService.updateThemeOnRouteChange$(event.urlAfterRedirects, activatedRouteSnapShot).pipe( - switchMap((changed) => { - if (changed) { - return this.isThemeCSSLoading$; - } else { - return [false]; - } - }) - ).subscribe((changed) => { - this.distinctNext(this.isThemeLoading$, changed); - }); - } else if ( - event instanceof NavigationEnd || - event instanceof NavigationCancel - ) { - if (!resolveEndFound) { + } else if (event instanceof ResolveEnd) { + // this is the earliest point where we have all the information we need + // to update the theme, but this event is not emitted on first load + this.updateTheme(event.urlAfterRedirects, event.state.root); + updatingTheme = true; + } else if (!updatingTheme && event instanceof ActivationEnd) { + // if there was no ResolveEnd, keep track of the snapshot... + snapshot = event.snapshot; + } else if (event instanceof NavigationEnd) { + if (!updatingTheme) { + // ...and use it to update the theme on NavigationEnd instead + this.updateTheme(event.urlAfterRedirects, snapshot); + updatingTheme = true; + } + this.distinctNext(this.isRouteLoading$, false); + } else if (event instanceof NavigationCancel) { + if (!updatingTheme) { this.distinctNext(this.isThemeLoading$, false); } this.distinctNext(this.isRouteLoading$, false); @@ -227,6 +240,26 @@ export class AppComponent implements OnInit, AfterViewInit { }); } + /** + * Update the theme according to the current route, if applicable. + * @param urlAfterRedirects the current URL after redirects + * @param snapshot the current route snapshot + * @private + */ + private updateTheme(urlAfterRedirects: string, snapshot: ActivatedRouteSnapshot): void { + this.themeService.updateThemeOnRouteChange$(urlAfterRedirects, snapshot).pipe( + switchMap((changed) => { + if (changed) { + return this.isThemeCSSLoading$; + } else { + return [false]; + } + }) + ).subscribe((changed) => { + this.distinctNext(this.isThemeLoading$, changed); + }); + } + @HostListener('window:resize', ['$event']) public onResize(event): void { this.dispatchWindowSize(event.target.innerWidth, event.target.innerHeight); @@ -268,7 +301,7 @@ export class AppComponent implements OnInit, AfterViewInit { link.setAttribute('rel', 'stylesheet'); link.setAttribute('type', 'text/css'); link.setAttribute('class', 'theme-css'); - link.setAttribute('href', `/${encodeURIComponent(themeName)}-theme.css`); + link.setAttribute('href', `${encodeURIComponent(themeName)}-theme.css`); // wait for the new css to download before removing the old one to prevent a // flash of unstyled content link.onload = () => { diff --git a/src/app/app.module.ts b/src/app/app.module.ts index ff66ab6aa9..ebf9aa4937 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -1,4 +1,4 @@ -import { APP_BASE_HREF, CommonModule } from '@angular/common'; +import { APP_BASE_HREF, CommonModule, DOCUMENT } from '@angular/common'; import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http'; import { APP_INITIALIZER, NgModule } from '@angular/core'; import { AbstractControl } from '@angular/forms'; @@ -42,9 +42,11 @@ export function getConfig() { return environment; } -export function getBase(appConfig: AppConfig) { - return appConfig.ui.nameSpace; -} +const getBaseHref = (document: Document, appConfig: AppConfig): string => { + const baseTag = document.querySelector('head > base'); + baseTag.setAttribute('href', `${appConfig.ui.nameSpace}${appConfig.ui.nameSpace.endsWith('/') ? '' : '/'}`); + return baseTag.getAttribute('href'); +}; export function getMetaReducers(appConfig: AppConfig): MetaReducer[] { return appConfig.debug ? [...appMetaReducers, ...debugMetaReducers] : appMetaReducers; @@ -84,8 +86,8 @@ const PROVIDERS = [ }, { provide: APP_BASE_HREF, - useFactory: getBase, - deps: [APP_CONFIG] + useFactory: getBaseHref, + deps: [DOCUMENT, APP_CONFIG] }, { provide: USER_PROVIDED_META_REDUCERS, diff --git a/src/app/core/auth/auth.service.spec.ts b/src/app/core/auth/auth.service.spec.ts index d63e300ce0..b38d17aecd 100644 --- a/src/app/core/auth/auth.service.spec.ts +++ b/src/app/core/auth/auth.service.spec.ts @@ -377,25 +377,25 @@ describe('AuthService test', () => { it('should redirect to reload with redirect url', () => { authService.navigateToRedirectUrl('/collection/123'); // Reload with redirect URL set to /collection/123 - expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*\\?redirect=' + encodeURIComponent('/collection/123')))); + expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('reload/[0-9]*\\?redirect=' + encodeURIComponent('/collection/123')))); }); it('should redirect to reload with /home', () => { authService.navigateToRedirectUrl('/home'); // Reload with redirect URL set to /home - expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*\\?redirect=' + encodeURIComponent('/home')))); + expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('reload/[0-9]*\\?redirect=' + encodeURIComponent('/home')))); }); it('should redirect to regular reload and not to /login', () => { authService.navigateToRedirectUrl('/login'); // Reload without a redirect URL - expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*(?!\\?)$'))); + expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('reload/[0-9]*(?!\\?)$'))); }); it('should redirect to regular reload when no redirect url is found', () => { authService.navigateToRedirectUrl(undefined); // Reload without a redirect URL - expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*(?!\\?)$'))); + expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('reload/[0-9]*(?!\\?)$'))); }); describe('impersonate', () => { diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 7796094e39..999ea863df 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -468,8 +468,8 @@ export class AuthService { */ public navigateToRedirectUrl(redirectUrl: string) { // Don't do redirect if already on reload url - if (!hasValue(redirectUrl) || !redirectUrl.includes('/reload/')) { - let url = `/reload/${new Date().getTime()}`; + if (!hasValue(redirectUrl) || !redirectUrl.includes('reload/')) { + let url = `reload/${new Date().getTime()}`; if (isNotEmpty(redirectUrl) && !redirectUrl.startsWith(LOGIN_ROUTE)) { url += `?redirect=${encodeURIComponent(redirectUrl)}`; } 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/breadcrumbs/dso-name.service.spec.ts b/src/app/core/breadcrumbs/dso-name.service.spec.ts index 7a399ce748..9f2f76599a 100644 --- a/src/app/core/breadcrumbs/dso-name.service.spec.ts +++ b/src/app/core/breadcrumbs/dso-name.service.spec.ts @@ -78,15 +78,32 @@ describe(`DSONameService`, () => { }); describe(`factories.Person`, () => { - beforeEach(() => { - spyOn(mockPerson, 'firstMetadataValue').and.returnValues(...mockPersonName.split(', ')); + describe(`with person.familyName and person.givenName`, () => { + beforeEach(() => { + spyOn(mockPerson, 'firstMetadataValue').and.returnValues(...mockPersonName.split(', ')); + }); + + it(`should return 'person.familyName, person.givenName'`, () => { + const result = (service as any).factories.Person(mockPerson); + expect(result).toBe(mockPersonName); + expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('person.familyName'); + expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('person.givenName'); + expect(mockPerson.firstMetadataValue).not.toHaveBeenCalledWith('dc.title'); + }); }); - it(`should return 'person.familyName, person.givenName'`, () => { - const result = (service as any).factories.Person(mockPerson); - expect(result).toBe(mockPersonName); - expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('person.familyName'); - expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('person.givenName'); + describe(`without person.familyName and person.givenName`, () => { + beforeEach(() => { + spyOn(mockPerson, 'firstMetadataValue').and.returnValues(undefined, undefined, mockPersonName); + }); + + it(`should return dc.title`, () => { + const result = (service as any).factories.Person(mockPerson); + expect(result).toBe(mockPersonName); + expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('person.familyName'); + expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('person.givenName'); + expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('dc.title'); + }); }); }); diff --git a/src/app/core/breadcrumbs/dso-name.service.ts b/src/app/core/breadcrumbs/dso-name.service.ts index 38363d1989..02ead1615c 100644 --- a/src/app/core/breadcrumbs/dso-name.service.ts +++ b/src/app/core/breadcrumbs/dso-name.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { hasValue } from '../../shared/empty.util'; +import { hasValue, isEmpty } from '../../shared/empty.util'; import { DSpaceObject } from '../shared/dspace-object.model'; import { TranslateService } from '@ngx-translate/core'; @@ -27,7 +27,13 @@ export class DSONameService { */ private readonly factories = { Person: (dso: DSpaceObject): string => { - return `${dso.firstMetadataValue('person.familyName')}, ${dso.firstMetadataValue('person.givenName')}`; + const familyName = dso.firstMetadataValue('person.familyName'); + const givenName = dso.firstMetadataValue('person.givenName'); + if (isEmpty(familyName) && isEmpty(givenName)) { + return dso.firstMetadataValue('dc.title') || dso.name; + } else { + return `${familyName}, ${givenName}`; + } }, OrgUnit: (dso: DSpaceObject): string => { return dso.firstMetadataValue('organization.legalName'); diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 31fb5a9233..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'; @@ -166,6 +170,14 @@ import { SubmissionAccessesModel } from './config/models/config-submission-acces import { AccessStatusObject } from '../shared/object-list/access-status-badge/access-status.model'; import { AccessStatusDataService } from './data/access-status-data.service'; 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 @@ -292,6 +304,11 @@ const PROVIDERS = [ SequenceService, GroupDataService, FeedbackDataService, + ResearcherProfileService, + ProfileClaimService, + OrcidAuthService, + OrcidQueueService, + OrcidHistoryDataService, ]; /** @@ -352,6 +369,10 @@ export const models = Root, SearchConfig, SubmissionAccessesModel, + AccessStatusObject, + ResearcherProfile, + OrcidQueue, + OrcidHistory, AccessStatusObject ]; diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index 0759cb61ea..1cd9731a65 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -8,11 +8,12 @@ import { find, map, mergeMap, + skipWhile, + switchMap, take, takeWhile, - switchMap, tap, - skipWhile, toArray + toArray } from 'rxjs/operators'; import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; import { NotificationOptions } from '../../shared/notifications/models/notification-options.model'; @@ -26,18 +27,12 @@ import { ObjectCacheService } from '../cache/object-cache.service'; import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; import { DSpaceObject } from '../shared/dspace-object.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { getRemoteDataPayload, getFirstSucceededRemoteData, getFirstCompletedRemoteData } from '../shared/operators'; +import { getFirstCompletedRemoteData, getFirstSucceededRemoteData, getRemoteDataPayload } from '../shared/operators'; import { URLCombiner } from '../url-combiner/url-combiner'; import { ChangeAnalyzer } from './change-analyzer'; import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; -import { - CreateRequest, - GetRequest, - PatchRequest, - PutRequest, - DeleteRequest -} from './request.models'; +import { CreateRequest, DeleteRequest, GetRequest, PatchRequest, PutRequest } from './request.models'; import { RequestService } from './request.service'; import { RestRequestMethod } from './rest-request-method'; import { UpdateDataService } from './update-data.service'; @@ -169,7 +164,7 @@ export abstract class DataService implements UpdateDa * @return {Observable} * Return an observable that emits created HREF */ - protected buildHrefWithParams(href: string, params: RequestParam[], ...linksToFollow: FollowLinkConfig[]): string { + buildHrefWithParams(href: string, params: RequestParam[], ...linksToFollow: FollowLinkConfig[]): string { let args = []; if (hasValue(params)) { diff --git a/src/app/core/data/feature-authorization/authorization-data.service.ts b/src/app/core/data/feature-authorization/authorization-data.service.ts index 1f8c8b2284..f27919844d 100644 --- a/src/app/core/data/feature-authorization/authorization-data.service.ts +++ b/src/app/core/data/feature-authorization/authorization-data.service.ts @@ -60,14 +60,18 @@ export class AuthorizationDataService extends DataService { /** * Checks if an {@link EPerson} (or anonymous) has access to a specific object within a {@link Feature} - * @param objectUrl URL to the object to search {@link Authorization}s for. - * If not provided, the repository's {@link Site} will be used. - * @param ePersonUuid UUID of the {@link EPerson} to search {@link Authorization}s for. - * If not provided, the UUID of the currently authenticated {@link EPerson} will be used. - * @param featureId ID of the {@link Feature} to check {@link Authorization} for + * @param objectUrl URL to the object to search {@link Authorization}s for. + * If not provided, the repository's {@link Site} will be used. + * @param ePersonUuid UUID of the {@link EPerson} to search {@link Authorization}s for. + * If not provided, the UUID of the currently authenticated {@link EPerson} will be used. + * @param featureId ID of the {@link Feature} to check {@link Authorization} for + * @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 */ - isAuthorized(featureId?: FeatureID, objectUrl?: string, ePersonUuid?: string): Observable { - return this.searchByObject(featureId, objectUrl, ePersonUuid, {}, true, true, followLink('feature')).pipe( + isAuthorized(featureId?: FeatureID, objectUrl?: string, ePersonUuid?: string, useCachedVersionIfAvailable = true, reRequestOnStale = true): Observable { + return this.searchByObject(featureId, objectUrl, ePersonUuid, {}, useCachedVersionIfAvailable, reRequestOnStale, followLink('feature')).pipe( getFirstCompletedRemoteData(), map((authorizationRD) => { if (authorizationRD.statusCode !== 401 && hasValue(authorizationRD.payload) && isNotEmpty(authorizationRD.payload.page)) { diff --git a/src/app/core/data/feature-authorization/feature-id.ts b/src/app/core/data/feature-authorization/feature-id.ts index 029c75d9cb..3cb18bf515 100644 --- a/src/app/core/data/feature-authorization/feature-id.ts +++ b/src/app/core/data/feature-authorization/feature-id.ts @@ -28,4 +28,6 @@ export enum FeatureID { CanCreateVersion = 'canCreateVersion', CanViewUsageStatistics = 'canViewUsageStatistics', CanSendFeedback = 'canSendFeedback', + CanClaimItem = 'canClaimItem', + CanSynchronizeWithORCID = 'canSynchronizeWithORCID' } diff --git a/src/app/core/locale/locale.service.ts b/src/app/core/locale/locale.service.ts index 1052021479..68d2839d42 100644 --- a/src/app/core/locale/locale.service.ts +++ b/src/app/core/locale/locale.service.ts @@ -192,7 +192,7 @@ export class LocaleService { this.routeService.getCurrentUrl().pipe(take(1)).subscribe((currentURL) => { // Hard redirect to the reload page with a unique number behind it // so that all state is definitely lost - this._window.nativeWindow.location.href = `/reload/${new Date().getTime()}?redirect=` + encodeURIComponent(currentURL); + this._window.nativeWindow.location.href = `reload/${new Date().getTime()}?redirect=` + encodeURIComponent(currentURL); }); } 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/model/researcher-profile.model.ts b/src/app/core/profile/model/researcher-profile.model.ts new file mode 100644 index 0000000000..6c8b19db40 --- /dev/null +++ b/src/app/core/profile/model/researcher-profile.model.ts @@ -0,0 +1,61 @@ +import { Observable } from 'rxjs'; +import { autoserialize, deserialize, deserializeAs } from 'cerialize'; + +import { link, 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 { RESEARCHER_PROFILE } from './researcher-profile.resource-type'; +import { CacheableObject } from '../../cache/cacheable-object.model'; +import { RemoteData } from '../../data/remote-data'; +import { ITEM } from '../../shared/item.resource-type'; +import { Item } from '../../shared/item.model'; + +/** + * Class the represents a Researcher Profile. + */ +@typedObject +export class ResearcherProfile extends CacheableObject { + + static type = RESEARCHER_PROFILE; + + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The identifier of this Researcher Profile + */ + @autoserialize + id: string; + + @deserializeAs('id') + uuid: string; + + /** + * The visibility of this Researcher Profile + */ + @autoserialize + visible: boolean; + + /** + * The {@link HALLink}s for this Researcher Profile + */ + @deserialize + _links: { + self: HALLink, + item: HALLink, + eperson: HALLink + }; + + /** + * The related person Item + * Will be undefined unless the item {@link HALLink} has been resolved. + */ + @link(ITEM) + item?: Observable>; + +} diff --git a/src/app/core/profile/model/researcher-profile.resource-type.ts b/src/app/core/profile/model/researcher-profile.resource-type.ts new file mode 100644 index 0000000000..bfed441b0d --- /dev/null +++ b/src/app/core/profile/model/researcher-profile.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from '../../shared/resource-type'; + +/** + * The resource type for ResearcherProfile + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const RESEARCHER_PROFILE = new ResourceType('profile'); diff --git a/src/app/core/profile/researcher-profile.service.spec.ts b/src/app/core/profile/researcher-profile.service.spec.ts new file mode 100644 index 0000000000..899867ec8e --- /dev/null +++ b/src/app/core/profile/researcher-profile.service.spec.ts @@ -0,0 +1,419 @@ +import { HttpClient, HttpHeaders } from '@angular/common/http'; + +import { cold, getTestScheduler, hot } from 'jasmine-marbles'; +import { of as observableOf } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { RequestService } from '../data/request.service'; +import { PageInfo } from '../shared/page-info.model'; +import { buildPaginatedList } from '../data/paginated-list.model'; +import { + createNoContentRemoteDataObject$, + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$ +} from '../../shared/remote-data.utils'; +import { RestResponse } from '../cache/response.models'; +import { RequestEntry } from '../data/request-entry.model'; +import { ResearcherProfileService } from './researcher-profile.service'; +import { RouterMock } from '../../shared/mocks/router.mock'; +import { ResearcherProfile } from './model/researcher-profile.model'; +import { Item } from '../shared/item.model'; +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; + let service: ResearcherProfileService; + let serviceAsAny: any; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + 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'; + const researcherProfileItem: Item = Object.assign(new Item(), { + id: itemId, + _links: { + self: { + href: `https://rest.api/rest/api/items/${itemId}` + }, + } + }); + 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 researcherProfileId2 = 'agbf9946-f4ce-479e-8f11-b90cbe9f7241'; + const anotherResearcherProfile: ResearcherProfile = Object.assign(new ResearcherProfile(), { + id: researcherProfileId2, + visible: false, + type: 'profile', + _links: { + self: { + href: `https://rest.api/rest/api/profiles/${researcherProfileId2}` + }, + } + }); + + 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`; + const requestURL = `https://rest.api/rest/api/profiles/${researcherProfileId}`; + const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + + const pageInfo = new PageInfo(); + const array = [researcherProfile, anotherResearcherProfile]; + const paginatedList = buildPaginatedList(pageInfo, array); + const researcherProfileRD = createSuccessfulRemoteDataObject(researcherProfile); + const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); + + beforeEach(() => { + scheduler = getTestScheduler(); + + halService = jasmine.createSpyObj('halService', { + getEndpoint: cold('a', { a: endpointURL }) + }); + + responseCacheEntry = new RequestEntry(); + responseCacheEntry.request = { href: 'https://rest.api/' } as any; + responseCacheEntry.response = new RestResponse(true, 200, 'Success'); + + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + send: true, + removeByHrefSubstring: {}, + getByHref: observableOf(responseCacheEntry), + getByUUID: observableOf(responseCacheEntry), + setStaleByHrefSubstring: jasmine.createSpy('setStaleByHrefSubstring') + }); + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: hot('a|', { + a: researcherProfileRD + }), + buildList: hot('a|', { + a: paginatedListRD + }), + buildFromRequestUUID: hot('a|', { + a: researcherProfileRD + }) + }); + objectCache = {} as ObjectCacheService; + const notificationsService = {} as NotificationsService; + const http = {} as HttpClient; + const comparator = {} as any; + routerStub = new RouterMock(); + const itemService = jasmine.createSpyObj('ItemService', { + findByHref: jasmine.createSpy('findByHref') + }); + + service = new ResearcherProfileService( + requestService, + rdbService, + objectCache, + halService, + notificationsService, + http, + routerStub, + comparator, + itemService + ); + serviceAsAny = service; + + spyOn((service as any).dataService, 'create').and.callThrough(); + spyOn((service as any).dataService, 'delete').and.callThrough(); + spyOn((service as any).dataService, 'update').and.callThrough(); + spyOn((service as any).dataService, 'findById').and.callThrough(); + spyOn((service as any).dataService, 'findByHref').and.callThrough(); + spyOn((service as any).dataService, 'searchBy').and.callThrough(); + spyOn((service as any).dataService, 'getLinkPath').and.returnValue(observableOf(endpointURL)); + + }); + + describe('findById', () => { + it('should proxy the call to dataservice.findById with eperson UUID', () => { + scheduler.schedule(() => service.findById(researcherProfileId)); + scheduler.flush(); + + expect((service as any).dataService.findById).toHaveBeenCalledWith(researcherProfileId, true, true); + }); + + it('should return a ResearcherProfile object with the given id', () => { + const result = service.findById(researcherProfileId); + const expected = cold('a|', { + a: researcherProfileRD + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('create', () => { + it('should proxy the call to dataservice.create with eperson UUID', () => { + scheduler.schedule(() => service.create()); + scheduler.flush(); + + expect((service as any).dataService.create).toHaveBeenCalled(); + }); + + it('should return the RemoteData created', () => { + const result = service.create(); + const expected = cold('a|', { + a: researcherProfileRD + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('delete', () => { + it('should proxy the call to dataservice.delete', () => { + scheduler.schedule(() => service.delete(researcherProfile)); + scheduler.flush(); + + expect((service as any).dataService.delete).toHaveBeenCalledWith(researcherProfile.id); + }); + }); + + describe('findRelatedItemId', () => { + describe('with a related item', () => { + + beforeEach(() => { + (service as any).itemService.findByHref.and.returnValue(createSuccessfulRemoteDataObject$(researcherProfileItem)); + }); + + it('should proxy the call to dataservice.findById with eperson UUID', () => { + scheduler.schedule(() => service.findRelatedItemId(researcherProfile)); + scheduler.flush(); + + expect((service as any).itemService.findByHref).toHaveBeenCalledWith(researcherProfile._links.item.href, false); + }); + + it('should return a ResearcherProfile object with the given id', () => { + const result = service.findRelatedItemId(researcherProfile); + const expected = cold('(a|)', { + a: itemId + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('without a related item', () => { + + beforeEach(() => { + (service as any).itemService.findByHref.and.returnValue(createNoContentRemoteDataObject$()); + }); + + it('should proxy the call to dataservice.findById with eperson UUID', () => { + scheduler.schedule(() => service.findRelatedItemId(researcherProfile)); + scheduler.flush(); + + expect((service as any).itemService.findByHref).toHaveBeenCalledWith(researcherProfile._links.item.href, false); + }); + + it('should not return a ResearcherProfile object with the given id', () => { + const result = service.findRelatedItemId(researcherProfile); + const expected = cold('(a|)', { + a: null + }); + expect(result).toBeObservable(expected); + }); + }); + }); + + describe('setVisibility', () => { + let patchSpy; + beforeEach(() => { + spyOn((service as any).dataService, 'patch').and.returnValue(createSuccessfulRemoteDataObject$(researcherProfilePatched)); + spyOn((service as any), 'findById').and.returnValue(createSuccessfulRemoteDataObject$(researcherProfilePatched)); + }); + + it('should proxy the call to dataservice.patch', () => { + const replaceOperation: ReplaceOperation = { + path: '/visible', + op: 'replace', + value: true + }; + + scheduler.schedule(() => service.setVisibility(researcherProfile, true)); + scheduler.flush(); + + expect((service as any).dataService.patch).toHaveBeenCalledWith(researcherProfile, [replaceOperation]); + }); + }); + + describe('createFromExternalSource', () => { + + beforeEach(() => { + spyOn((service as any).dataService, 'patch').and.returnValue(createSuccessfulRemoteDataObject$(researcherProfilePatched)); + spyOn((service as any), 'findById').and.returnValue(createSuccessfulRemoteDataObject$(researcherProfilePatched)); + }); + + it('should proxy the call to dataservice.patch', () => { + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'text/uri-list'); + options.headers = headers; + const request = new PostRequest(requestUUID, endpointURLWithEmbed, sourceUri, options); + + scheduler.schedule(() => service.createFromExternalSource(sourceUri)); + scheduler.flush(); + + expect((service as any).requestService.send).toHaveBeenCalledWith(request); + expect((service as any).rdbService.buildFromRequestUUID).toHaveBeenCalledWith(requestUUID, followLink('item')); + + }); + }); + + 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 new file mode 100644 index 0000000000..882845d133 --- /dev/null +++ b/src/app/core/profile/researcher-profile.service.ts @@ -0,0 +1,194 @@ +/* eslint-disable max-classes-per-file */ +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Router } from '@angular/router'; + +import { Store } from '@ngrx/store'; +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'; +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 { RequestService } from '../data/request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { NoContent } from '../shared/NoContent.model'; +import { getAllCompletedRemoteData, getFirstCompletedRemoteData } from '../shared/operators'; +import { ResearcherProfile } from './model/researcher-profile.model'; +import { RESEARCHER_PROFILE } from './model/researcher-profile.resource-type'; +import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { PostRequest } from '../data/request.models'; +import { hasValue, 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. + */ +class ResearcherProfileServiceImpl extends DataService { + protected linkPath = 'profiles'; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer) { + super(); + } + +} + +/** + * A service that provides methods to make REST requests with researcher profile endpoint. + */ +@Injectable() +@dataService(RESEARCHER_PROFILE) +export class ResearcherProfileService { + + protected dataService: ResearcherProfileServiceImpl; + + protected responseMsToLive: number = 10 * 1000; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected router: Router, + protected comparator: DefaultChangeAnalyzer, + protected itemService: ItemDataService) { + + this.dataService = new ResearcherProfileServiceImpl(requestService, rdbService, null, objectCache, halService, + notificationsService, http, comparator); + + } + + /** + * Find the researcher profile with the given uuid. + * + * @param uuid the profile uuid + * @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 + */ + public findById(uuid: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { + return this.dataService.findById(uuid, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow).pipe( + getAllCompletedRemoteData(), + ); + } + + /** + * Create a new researcher profile for the current user. + */ + public create(): Observable> { + return this.dataService.create(new ResearcherProfile()); + } + + /** + * Delete a researcher profile. + * + * @param researcherProfile the profile to delete + */ + public delete(researcherProfile: ResearcherProfile): Observable { + return this.dataService.delete(researcherProfile.id).pipe( + getFirstCompletedRemoteData(), + map((response: RemoteData) => response.isSuccess) + ); + } + + /** + * 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. + * + * @param researcherProfile the profile to find for + */ + public findRelatedItemId(researcherProfile: ResearcherProfile): Observable { + const relatedItem$ = researcherProfile.item ? researcherProfile.item : this.itemService.findByHref(researcherProfile._links.item.href, false); + return relatedItem$.pipe( + getFirstCompletedRemoteData(), + map((itemRD: RemoteData) => (itemRD.hasSucceeded && itemRD.payload) ? itemRD.payload.id : null) + ); + } + + /** + * Change the visibility of the given researcher profile setting the given value. + * + * @param researcherProfile the profile to update + * @param visible the visibility value to set + */ + public setVisibility(researcherProfile: ResearcherProfile, visible: boolean): Observable> { + const replaceOperation: ReplaceOperation = { + path: '/visible', + op: 'replace', + value: visible + }; + + return this.dataService.patch(researcherProfile, [replaceOperation]); + } + + /** + * Creates a researcher profile starting from an external source URI + * @param sourceUri URI of source item of researcher profile. + */ + public createFromExternalSource(sourceUri: string): Observable> { + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'text/uri-list'); + options.headers = headers; + + const requestId = this.requestService.generateRequestId(); + const href$ = this.halService.getEndpoint(this.dataService.getLinkPath()); + + href$.pipe( + find((href: string) => hasValue(href)), + map((href: string) => this.dataService.buildHrefWithParams(href, [], followLink('item'))) + ).subscribe((endpoint: string) => { + const request = new PostRequest(requestId, endpoint, sourceUri, options); + this.requestService.send(request); + }); + + 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/reload/reload.guard.spec.ts b/src/app/core/reload/reload.guard.spec.ts index 317245bafa..6146f51572 100644 --- a/src/app/core/reload/reload.guard.spec.ts +++ b/src/app/core/reload/reload.guard.spec.ts @@ -1,13 +1,17 @@ -import { ReloadGuard } from './reload.guard'; import { Router } from '@angular/router'; +import { AppConfig } from '../../../config/app-config.interface'; +import { DefaultAppConfig } from '../../../config/default-app-config'; +import { ReloadGuard } from './reload.guard'; describe('ReloadGuard', () => { let guard: ReloadGuard; let router: Router; + let appConfig: AppConfig; beforeEach(() => { router = jasmine.createSpyObj('router', ['parseUrl', 'createUrlTree']); - guard = new ReloadGuard(router); + appConfig = new DefaultAppConfig(); + guard = new ReloadGuard(router, appConfig); }); describe('canActivate', () => { @@ -27,7 +31,7 @@ describe('ReloadGuard', () => { it('should create a UrlTree with the redirect URL', () => { guard.canActivate(route, undefined); - expect(router.parseUrl).toHaveBeenCalledWith(redirectUrl); + expect(router.parseUrl).toHaveBeenCalledWith(redirectUrl.substring(1)); }); }); diff --git a/src/app/core/reload/reload.guard.ts b/src/app/core/reload/reload.guard.ts index 78f9dcf642..1e99a5687a 100644 --- a/src/app/core/reload/reload.guard.ts +++ b/src/app/core/reload/reload.guard.ts @@ -1,5 +1,6 @@ +import { Inject, Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree } from '@angular/router'; -import { Injectable } from '@angular/core'; +import { AppConfig, APP_CONFIG } from '../../../config/app-config.interface'; import { isNotEmpty } from '../../shared/empty.util'; /** @@ -8,7 +9,10 @@ import { isNotEmpty } from '../../shared/empty.util'; */ @Injectable() export class ReloadGuard implements CanActivate { - constructor(private router: Router) { + constructor( + private router: Router, + @Inject(APP_CONFIG) private appConfig: AppConfig, + ) { } /** @@ -18,7 +22,10 @@ export class ReloadGuard implements CanActivate { */ canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): UrlTree { if (isNotEmpty(route.queryParams.redirect)) { - return this.router.parseUrl(route.queryParams.redirect); + const url = route.queryParams.redirect.startsWith(this.appConfig.ui.nameSpace) + ? route.queryParams.redirect.substring(this.appConfig.ui.nameSpace.length) + : route.queryParams.redirect; + return this.router.parseUrl(url); } else { return this.router.createUrlTree(['home']); } diff --git a/src/app/core/submission/models/sherpa-policies-details.model.ts b/src/app/core/submission/models/sherpa-policies-details.model.ts new file mode 100644 index 0000000000..af4d4a5890 --- /dev/null +++ b/src/app/core/submission/models/sherpa-policies-details.model.ts @@ -0,0 +1,89 @@ +/** + * An interface to represent an access condition. + */ +export class SherpaPoliciesDetailsObject { + + /** + * The sherpa policies error + */ + error: boolean; + + /** + * The sherpa policies journal details + */ + journals: Journal[]; + + /** + * The sherpa policies message + */ + message: string; + + /** + * The sherpa policies metadata + */ + metadata: Metadata; + +} + + +export interface Metadata { + id: number; + uri: string; + dateCreated: string; + dateModified: string; + inDOAJ: boolean; + publiclyVisible: boolean; +} + + +export interface Journal { + titles: string[]; + url: string; + issns: string[]; + romeoPub: string; + zetoPub: string; + inDOAJ: boolean; + publisher: Publisher; + publishers: Publisher[]; + policies: Policy[]; +} + +export interface Publisher { + name: string; + relationshipType: string; + country: string; + uri: string; + identifier: string; + paidAccessDescription: string; + paidAccessUrl: string; + publicationCount: number; +} + +export interface Policy { + id: number; + openAccessPermitted: boolean; + uri: string; + internalMoniker: string; + permittedVersions: PermittedVersions[]; + urls: any; + publicationCount: number; + preArchiving: string; + postArchiving: string; + pubArchiving: string; + openAccessProhibited: boolean; +} + +export interface PermittedVersions { + articleVersion: string; + option: number; + conditions: string[]; + prerequisites: string[]; + locations: string[]; + licenses: string[]; + embargo: Embargo; +} + +export interface Embargo { + units: any; + amount: any; +} diff --git a/src/app/core/submission/models/workspaceitem-section-sherpa-policies.model.ts b/src/app/core/submission/models/workspaceitem-section-sherpa-policies.model.ts new file mode 100644 index 0000000000..c57beadbb9 --- /dev/null +++ b/src/app/core/submission/models/workspaceitem-section-sherpa-policies.model.ts @@ -0,0 +1,22 @@ +import { SherpaPoliciesDetailsObject } from './sherpa-policies-details.model'; + +/** + * An interface to represent the submission's item accesses condition. + */ +export interface WorkspaceitemSectionSherpaPoliciesObject { + + /** + * The access condition id + */ + id: string; + + /** + * The sherpa policies retrievalTime + */ + retrievalTime: string; + + /** + * The sherpa policies details + */ + sherpaResponse: SherpaPoliciesDetailsObject; +} diff --git a/src/app/core/submission/models/workspaceitem-sections.model.ts b/src/app/core/submission/models/workspaceitem-sections.model.ts index 084da3f088..1112d740ed 100644 --- a/src/app/core/submission/models/workspaceitem-sections.model.ts +++ b/src/app/core/submission/models/workspaceitem-sections.model.ts @@ -3,6 +3,7 @@ import { WorkspaceitemSectionFormObject } from './workspaceitem-section-form.mod import { WorkspaceitemSectionLicenseObject } from './workspaceitem-section-license.model'; import { WorkspaceitemSectionUploadObject } from './workspaceitem-section-upload.model'; import { WorkspaceitemSectionCcLicenseObject } from './workspaceitem-section-cc-license.model'; +import { WorkspaceitemSectionSherpaPoliciesObject } from './workspaceitem-section-sherpa-policies.model'; /** * An interface to represent submission's section object. @@ -21,4 +22,5 @@ export type WorkspaceitemSectionDataType | WorkspaceitemSectionLicenseObject | WorkspaceitemSectionCcLicenseObject | WorkspaceitemSectionAccessesObject + | WorkspaceitemSectionSherpaPoliciesObject | string; 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-list-elements/search-result-list-elements/org-unit/org-unit-search-result-list-element.component.html b/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/org-unit/org-unit-search-result-list-element.component.html index fb0ad21b6e..40f837bcd1 100644 --- a/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/org-unit/org-unit-search-result-list-element.component.html +++ b/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/org-unit/org-unit-search-result-list-element.component.html @@ -7,13 +7,11 @@ class="lead" [innerHTML]="firstMetadataValue('organization.legalName')"> - - - - - - + + + + diff --git a/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/person/person-search-result-list-element.component.html b/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/person/person-search-result-list-element.component.html index c8a9ea9e28..6d9cfe10c4 100644 --- a/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/person/person-search-result-list-element.component.html +++ b/src/app/entity-groups/research-entities/item-list-elements/search-result-list-elements/person/person-search-result-list-element.component.html @@ -5,7 +5,7 @@ [innerHTML]="name"> + [innerHTML]="name">
+
@@ -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 8cf6117121..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 @@ -1,9 +1,14 @@

- {{'person.page.titleprefix' | translate}} + {{'person.page.titleprefix' | translate}}

+ + +
@@ -19,18 +24,10 @@ [fields]="['person.email']" [label]="'person.page.email'"> - - - - - - - -
+ + diff --git a/src/app/entity-groups/research-entities/item-pages/person/person.component.spec.ts b/src/app/entity-groups/research-entities/item-pages/person/person.component.spec.ts index 546621700a..efbc48a209 100644 --- a/src/app/entity-groups/research-entities/item-pages/person/person.component.spec.ts +++ b/src/app/entity-groups/research-entities/item-pages/person/person.component.spec.ts @@ -17,24 +17,12 @@ const mockItem: Item = Object.assign(new Item(), { value: 'fake@email.com' } ], - // 'person.identifier.orcid': [ - // { - // language: 'en_US', - // value: 'ORCID-1' - // } - // ], 'person.birthDate': [ { language: 'en_US', value: '1993' } ], - // 'person.identifier.staffid': [ - // { - // language: 'en_US', - // value: '1' - // } - // ], 'person.jobTitle': [ { language: 'en_US', @@ -54,7 +42,50 @@ const mockItem: Item = Object.assign(new Item(), { } ] }, - relationships: createRelationshipsObservable() + relationships: createRelationshipsObservable(), + _links: { + self : { + href: 'item-href' + } + } }); -describe('PersonComponent', getItemPageFieldsTest(mockItem, PersonComponent)); +const mockItemWithTitle: Item = Object.assign(new Item(), { + bundles: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])), + metadata: { + 'person.email': [ + { + language: 'en_US', + value: 'fake@email.com' + } + ], + 'person.birthDate': [ + { + language: 'en_US', + value: '1993' + } + ], + 'person.jobTitle': [ + { + language: 'en_US', + value: 'Developer' + } + ], + 'dc.title': [ + { + language: 'en_US', + value: 'Doe, John' + } + ] + }, + relationships: createRelationshipsObservable(), + _links: { + self : { + href: 'item-href' + } + } +}); + +describe('PersonComponent with family and given names', getItemPageFieldsTest(mockItem, PersonComponent)); + +describe('PersonComponent with dc.title', getItemPageFieldsTest(mockItemWithTitle, PersonComponent)); 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 8b104cc9b1..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,7 +1,8 @@ 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'; +import { MetadataValue } from '../../../../core/shared/metadata.models'; @listableObjectComponent('Person', ViewMode.StandalonePage) @Component({ @@ -12,5 +13,26 @@ import { listableObjectComponent } from '../../../../shared/object-collection/sh /** * 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. + */ + getTitleMetadataValues(): MetadataValue[] { + const metadataValues = []; + const familyName = this.object?.firstMetadata('person.familyName'); + const givenName = this.object?.firstMetadata('person.givenName'); + const title = this.object?.firstMetadata('dc.title'); + if (familyName) { + metadataValues.push(familyName); + } + if (givenName) { + metadataValues.push(givenName); + } + if (metadataValues.length === 0 && title) { + metadataValues.push(title); + } + 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/health-page/health-info/health-info-component/health-info-component.component.html b/src/app/health-page/health-info/health-info-component/health-info-component.component.html new file mode 100644 index 0000000000..dbaaa7a6b6 --- /dev/null +++ b/src/app/health-page/health-info/health-info-component/health-info-component.component.html @@ -0,0 +1,27 @@ +
+
+
+ +
+ + +
+
+
+
+
+ +
+
+
+
+ +

{{ getPropertyLabel(entry.key) | titlecase }} : {{entry.value}}

+
+
diff --git a/src/app/health-page/health-info/health-info-component/health-info-component.component.scss b/src/app/health-page/health-info/health-info-component/health-info-component.component.scss new file mode 100644 index 0000000000..a6f0e73413 --- /dev/null +++ b/src/app/health-page/health-info/health-info-component/health-info-component.component.scss @@ -0,0 +1,3 @@ +.collapse-toggle { + cursor: pointer; +} diff --git a/src/app/health-page/health-info/health-info-component/health-info-component.component.spec.ts b/src/app/health-page/health-info/health-info-component/health-info-component.component.spec.ts new file mode 100644 index 0000000000..b4532415b8 --- /dev/null +++ b/src/app/health-page/health-info/health-info-component/health-info-component.component.spec.ts @@ -0,0 +1,82 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { CommonModule } from '@angular/common'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap'; + +import { HealthInfoComponentComponent } from './health-info-component.component'; +import { HealthInfoComponentOne, HealthInfoComponentTwo } from '../../../shared/mocks/health-endpoint.mocks'; +import { ObjNgFor } from '../../../shared/utils/object-ngfor.pipe'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock'; + +describe('HealthInfoComponentComponent', () => { + let component: HealthInfoComponentComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + CommonModule, + NgbCollapseModule, + NoopAnimationsModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }) + ], + declarations: [ + HealthInfoComponentComponent, + ObjNgFor + ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(HealthInfoComponentComponent); + component = fixture.componentInstance; + }); + + describe('when has nested components', () => { + beforeEach(() => { + component.healthInfoComponentName = 'App'; + component.healthInfoComponent = HealthInfoComponentOne; + component.isCollapsed = false; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display property', () => { + const properties = fixture.debugElement.queryAll(By.css('[data-test="property"]')); + expect(properties.length).toBe(14); + const components = fixture.debugElement.queryAll(By.css('[data-test="info-component"]')); + expect(components.length).toBe(4); + }); + + }); + + describe('when has plain properties', () => { + beforeEach(() => { + component.healthInfoComponentName = 'Java'; + component.healthInfoComponent = HealthInfoComponentTwo; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display property', () => { + const property = fixture.debugElement.queryAll(By.css('[data-test="property"]')); + expect(property.length).toBe(1); + }); + + }); +}); diff --git a/src/app/health-page/health-info/health-info-component/health-info-component.component.ts b/src/app/health-page/health-info/health-info-component/health-info-component.component.ts new file mode 100644 index 0000000000..d2cb393f09 --- /dev/null +++ b/src/app/health-page/health-info/health-info-component/health-info-component.component.ts @@ -0,0 +1,46 @@ +import { Component, Input } from '@angular/core'; + +import { HealthInfoComponent } from '../../models/health-component.model'; +import { HealthComponentComponent } from '../../health-panel/health-component/health-component.component'; + +/** + * Shows a health info object + */ +@Component({ + selector: 'ds-health-info-component', + templateUrl: './health-info-component.component.html', + styleUrls: ['./health-info-component.component.scss'] +}) +export class HealthInfoComponentComponent extends HealthComponentComponent { + + /** + * The HealthInfoComponent object to display + */ + @Input() healthInfoComponent: HealthInfoComponent|string; + + /** + * The HealthInfoComponent object name + */ + @Input() healthInfoComponentName: string; + + /** + * A boolean representing if div should start collapsed + */ + @Input() isNested = false; + + /** + * A boolean representing if div should start collapsed + */ + public isCollapsed = false; + + /** + * Check if the HealthInfoComponent is has only string property or contains object + * + * @param entry The HealthInfoComponent to check + * @return boolean + */ + isPlainProperty(entry: HealthInfoComponent | string): boolean { + return typeof entry === 'string'; + } + +} diff --git a/src/app/health-page/health-info/health-info.component.html b/src/app/health-page/health-info/health-info.component.html new file mode 100644 index 0000000000..4bafcaa2d8 --- /dev/null +++ b/src/app/health-page/health-info/health-info.component.html @@ -0,0 +1,25 @@ + + + + +
+ +
+ +
+ + +
+
+
+
+ + + +
+
+
diff --git a/src/app/health-page/health-info/health-info.component.scss b/src/app/health-page/health-info/health-info.component.scss new file mode 100644 index 0000000000..a6f0e73413 --- /dev/null +++ b/src/app/health-page/health-info/health-info.component.scss @@ -0,0 +1,3 @@ +.collapse-toggle { + cursor: pointer; +} diff --git a/src/app/health-page/health-info/health-info.component.spec.ts b/src/app/health-page/health-info/health-info.component.spec.ts new file mode 100644 index 0000000000..5a9b8bf0aa --- /dev/null +++ b/src/app/health-page/health-info/health-info.component.spec.ts @@ -0,0 +1,51 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { HealthInfoComponent } from './health-info.component'; +import { HealthInfoResponseObj } from '../../shared/mocks/health-endpoint.mocks'; +import { ObjNgFor } from '../../shared/utils/object-ngfor.pipe'; +import { By } from '@angular/platform-browser'; +import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; + +describe('HealthInfoComponent', () => { + let component: HealthInfoComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + NgbAccordionModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }) + ], + declarations: [ + HealthInfoComponent, + ObjNgFor + ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(HealthInfoComponent); + component = fixture.componentInstance; + component.healthInfoResponse = HealthInfoResponseObj; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should create info component properly', () => { + const components = fixture.debugElement.queryAll(By.css('[data-test="info-component"]')); + expect(components.length).toBe(3); + }); +}); diff --git a/src/app/health-page/health-info/health-info.component.ts b/src/app/health-page/health-info/health-info.component.ts new file mode 100644 index 0000000000..186d00299c --- /dev/null +++ b/src/app/health-page/health-info/health-info.component.ts @@ -0,0 +1,46 @@ +import { Component, Input, OnInit } from '@angular/core'; + +import { TranslateService } from '@ngx-translate/core'; + +import { HealthInfoResponse } from '../models/health-component.model'; + +/** + * A component to render a "health-info component" object. + * + * Note that the word "component" in "health-info component" doesn't refer to Angular use of the term + * but rather to the components used in the response of the health endpoint of Spring's Actuator + * API. + */ +@Component({ + selector: 'ds-health-info', + templateUrl: './health-info.component.html', + styleUrls: ['./health-info.component.scss'] +}) +export class HealthInfoComponent implements OnInit { + + @Input() healthInfoResponse: HealthInfoResponse; + + /** + * The first active panel id + */ + activeId: string; + + constructor(private translate: TranslateService) { + } + + ngOnInit(): void { + this.activeId = Object.keys(this.healthInfoResponse)[0]; + } + + /** + * Return translated label if exist for the given property + * + * @param panelKey + */ + public getPanelLabel(panelKey: string): string { + const translationKey = `health-page.section-info.${panelKey}.title`; + const translation = this.translate.instant(translationKey); + + return (translation === translationKey) ? panelKey : translation; + } +} diff --git a/src/app/health-page/health-page.component.html b/src/app/health-page/health-page.component.html new file mode 100644 index 0000000000..8083389e1b --- /dev/null +++ b/src/app/health-page/health-page.component.html @@ -0,0 +1,27 @@ +
+ + diff --git a/src/app/health-page/health-page.component.scss b/src/app/health-page/health-page.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/health-page/health-page.component.spec.ts b/src/app/health-page/health-page.component.spec.ts new file mode 100644 index 0000000000..f3847ab092 --- /dev/null +++ b/src/app/health-page/health-page.component.spec.ts @@ -0,0 +1,72 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { CommonModule } from '@angular/common'; +import { By } from '@angular/platform-browser'; + +import { of } from 'rxjs'; +import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; + +import { HealthPageComponent } from './health-page.component'; +import { HealthService } from './health.service'; +import { HealthInfoResponseObj, HealthResponseObj } from '../shared/mocks/health-endpoint.mocks'; +import { RawRestResponse } from '../core/dspace-rest/raw-rest-response.model'; +import { TranslateLoaderMock } from '../shared/mocks/translate-loader.mock'; + +describe('HealthPageComponent', () => { + let component: HealthPageComponent; + let fixture: ComponentFixture; + + const healthService = jasmine.createSpyObj('healthDataService', { + getHealth: jasmine.createSpy('getHealth'), + getInfo: jasmine.createSpy('getInfo'), + }); + + const healthRestResponse$ = of({ + payload: HealthResponseObj, + statusCode: 200, + statusText: 'OK' + } as RawRestResponse); + + const healthInfoRestResponse$ = of({ + payload: HealthInfoResponseObj, + statusCode: 200, + statusText: 'OK' + } as RawRestResponse); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + CommonModule, + NgbNavModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }) + ], + declarations: [ HealthPageComponent ], + providers: [ + { provide: HealthService, useValue: healthService } + ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(HealthPageComponent); + component = fixture.componentInstance; + healthService.getHealth.and.returnValue(healthRestResponse$); + healthService.getInfo.and.returnValue(healthInfoRestResponse$); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should create nav items properly', () => { + const navItems = fixture.debugElement.queryAll(By.css('li.nav-item')); + expect(navItems.length).toBe(2); + }); +}); diff --git a/src/app/health-page/health-page.component.ts b/src/app/health-page/health-page.component.ts new file mode 100644 index 0000000000..aa7bd7cba4 --- /dev/null +++ b/src/app/health-page/health-page.component.ts @@ -0,0 +1,66 @@ +import { Component, OnInit } from '@angular/core'; + +import { BehaviorSubject } from 'rxjs'; +import { take } from 'rxjs/operators'; + +import { HealthService } from './health.service'; +import { HealthInfoResponse, HealthResponse } from './models/health-component.model'; + +@Component({ + selector: 'ds-health-page', + templateUrl: './health-page.component.html', + styleUrls: ['./health-page.component.scss'] +}) +export class HealthPageComponent implements OnInit { + + /** + * Health info endpoint response + */ + healthInfoResponse: BehaviorSubject = new BehaviorSubject(null); + + /** + * Health endpoint response + */ + healthResponse: BehaviorSubject = new BehaviorSubject(null); + + /** + * Represent if the response from health status endpoint is already retrieved or not + */ + healthResponseInitialised: BehaviorSubject = new BehaviorSubject(false); + + /** + * Represent if the response from health info endpoint is already retrieved or not + */ + healthInfoResponseInitialised: BehaviorSubject = new BehaviorSubject(false); + + constructor(private healthDataService: HealthService) { + } + + /** + * Retrieve responses from rest + */ + ngOnInit(): void { + this.healthDataService.getHealth().pipe(take(1)).subscribe({ + next: (data: any) => { + this.healthResponse.next(data.payload); + this.healthResponseInitialised.next(true); + }, + error: () => { + this.healthResponse.next(null); + this.healthResponseInitialised.next(true); + } + }); + + this.healthDataService.getInfo().pipe(take(1)).subscribe({ + next: (data: any) => { + this.healthInfoResponse.next(data.payload); + this.healthInfoResponseInitialised.next(true); + }, + error: () => { + this.healthInfoResponse.next(null); + this.healthInfoResponseInitialised.next(true); + } + }); + + } +} diff --git a/src/app/health-page/health-page.module.ts b/src/app/health-page/health-page.module.ts new file mode 100644 index 0000000000..02a6a91a5f --- /dev/null +++ b/src/app/health-page/health-page.module.ts @@ -0,0 +1,35 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; + +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; + +import { HealthPageRoutingModule } from './health-page.routing.module'; +import { HealthPanelComponent } from './health-panel/health-panel.component'; +import { HealthStatusComponent } from './health-panel/health-status/health-status.component'; +import { SharedModule } from '../shared/shared.module'; +import { HealthPageComponent } from './health-page.component'; +import { HealthComponentComponent } from './health-panel/health-component/health-component.component'; +import { HealthInfoComponent } from './health-info/health-info.component'; +import { HealthInfoComponentComponent } from './health-info/health-info-component/health-info-component.component'; + + +@NgModule({ + imports: [ + CommonModule, + HealthPageRoutingModule, + NgbModule, + SharedModule, + TranslateModule + ], + declarations: [ + HealthPageComponent, + HealthPanelComponent, + HealthStatusComponent, + HealthComponentComponent, + HealthInfoComponent, + HealthInfoComponentComponent, + ] +}) +export class HealthPageModule { +} diff --git a/src/app/health-page/health-page.routing.module.ts b/src/app/health-page/health-page.routing.module.ts new file mode 100644 index 0000000000..82d541dc31 --- /dev/null +++ b/src/app/health-page/health-page.routing.module.ts @@ -0,0 +1,28 @@ +import { RouterModule } from '@angular/router'; +import { NgModule } from '@angular/core'; + +import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; +import { HealthPageComponent } from './health-page.component'; +import { + SiteAdministratorGuard +} from '../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { + path: '', + resolve: { breadcrumb: I18nBreadcrumbResolver }, + data: { + breadcrumbKey: 'health', + title: 'health-page.title', + }, + canActivate: [SiteAdministratorGuard], + component: HealthPageComponent + } + ]) + ] +}) +export class HealthPageRoutingModule { + +} diff --git a/src/app/health-page/health-panel/health-component/health-component.component.html b/src/app/health-page/health-panel/health-component/health-component.component.html new file mode 100644 index 0000000000..1f29c8c9fc --- /dev/null +++ b/src/app/health-page/health-panel/health-component/health-component.component.html @@ -0,0 +1,30 @@ + +
+
+ +
+ + +
+
+
+
+
+ +
+
+
+
+
+ +
+

{{ getPropertyLabel(item.key) | titlecase }} : {{item.value}}

+
+
+ + + diff --git a/src/app/health-page/health-panel/health-component/health-component.component.scss b/src/app/health-page/health-panel/health-component/health-component.component.scss new file mode 100644 index 0000000000..a6f0e73413 --- /dev/null +++ b/src/app/health-page/health-panel/health-component/health-component.component.scss @@ -0,0 +1,3 @@ +.collapse-toggle { + cursor: pointer; +} diff --git a/src/app/health-page/health-panel/health-component/health-component.component.spec.ts b/src/app/health-page/health-panel/health-component/health-component.component.spec.ts new file mode 100644 index 0000000000..a8ec2b65e0 --- /dev/null +++ b/src/app/health-page/health-panel/health-component/health-component.component.spec.ts @@ -0,0 +1,85 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { CommonModule } from '@angular/common'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; + +import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap'; + +import { HealthComponentComponent } from './health-component.component'; +import { HealthComponentOne, HealthComponentTwo } from '../../../shared/mocks/health-endpoint.mocks'; +import { ObjNgFor } from '../../../shared/utils/object-ngfor.pipe'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock'; + +describe('HealthComponentComponent', () => { + let component: HealthComponentComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + CommonModule, + NgbCollapseModule, + NoopAnimationsModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }) + ], + declarations: [ + HealthComponentComponent, + ObjNgFor + ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(HealthComponentComponent); + component = fixture.componentInstance; + }); + + describe('when has nested components', () => { + beforeEach(() => { + component.healthComponentName = 'db'; + component.healthComponent = HealthComponentOne; + component.isCollapsed = false; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should create collapsible divs properly', () => { + const collapseDivs = fixture.debugElement.queryAll(By.css('[data-test="collapse"]')); + expect(collapseDivs.length).toBe(2); + const detailsDivs = fixture.debugElement.queryAll(By.css('[data-test="details"]')); + expect(detailsDivs.length).toBe(6); + }); + }); + + describe('when has details', () => { + beforeEach(() => { + component.healthComponentName = 'geoIp'; + component.healthComponent = HealthComponentTwo; + + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should create detail divs properly', () => { + const detailsDivs = fixture.debugElement.queryAll(By.css('[data-test="details"]')); + expect(detailsDivs.length).toBe(1); + const collapseDivs = fixture.debugElement.queryAll(By.css('[data-test="collapse"]')); + expect(collapseDivs.length).toBe(0); + }); + }); +}); diff --git a/src/app/health-page/health-panel/health-component/health-component.component.ts b/src/app/health-page/health-panel/health-component/health-component.component.ts new file mode 100644 index 0000000000..e212a07289 --- /dev/null +++ b/src/app/health-page/health-panel/health-component/health-component.component.ts @@ -0,0 +1,53 @@ +import { Component, Input } from '@angular/core'; + +import { TranslateService } from '@ngx-translate/core'; + +import { HealthComponent } from '../../models/health-component.model'; +import { AlertType } from '../../../shared/alert/aletr-type'; + +/** + * A component to render a "health component" object. + * + * Note that the word "component" in "health component" doesn't refer to Angular use of the term + * but rather to the components used in the response of the health endpoint of Spring's Actuator + * API. + */ +@Component({ + selector: 'ds-health-component', + templateUrl: './health-component.component.html', + styleUrls: ['./health-component.component.scss'] +}) +export class HealthComponentComponent { + + /** + * The HealthComponent object to display + */ + @Input() healthComponent: HealthComponent; + + /** + * The HealthComponent object name + */ + @Input() healthComponentName: string; + + public AlertTypeEnum = AlertType; + + /** + * A boolean representing if div should start collapsed + */ + public isCollapsed = false; + + constructor(private translate: TranslateService) { + } + + /** + * Return translated label if exist for the given property + * + * @param property + */ + public getPropertyLabel(property: string): string { + const translationKey = `health-page.property.${property}`; + const translation = this.translate.instant(translationKey); + + return (translation === translationKey) ? property : translation; + } +} diff --git a/src/app/health-page/health-panel/health-panel.component.html b/src/app/health-page/health-panel/health-panel.component.html new file mode 100644 index 0000000000..2d67fa537b --- /dev/null +++ b/src/app/health-page/health-panel/health-panel.component.html @@ -0,0 +1,25 @@ +

{{'health-page.status' | translate}} :

+ + + +
+ +
+ +
+ + +
+
+
+
+ + + +
+
+ + diff --git a/src/app/health-page/health-panel/health-panel.component.scss b/src/app/health-page/health-panel/health-panel.component.scss new file mode 100644 index 0000000000..a6f0e73413 --- /dev/null +++ b/src/app/health-page/health-panel/health-panel.component.scss @@ -0,0 +1,3 @@ +.collapse-toggle { + cursor: pointer; +} diff --git a/src/app/health-page/health-panel/health-panel.component.spec.ts b/src/app/health-page/health-panel/health-panel.component.spec.ts new file mode 100644 index 0000000000..1d9c856ddb --- /dev/null +++ b/src/app/health-page/health-panel/health-panel.component.spec.ts @@ -0,0 +1,57 @@ +import { CommonModule } from '@angular/common'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; + +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { NgbAccordionModule, NgbNavModule } from '@ng-bootstrap/ng-bootstrap'; + +import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; +import { HealthPanelComponent } from './health-panel.component'; +import { HealthResponseObj } from '../../shared/mocks/health-endpoint.mocks'; +import { ObjNgFor } from '../../shared/utils/object-ngfor.pipe'; + +describe('HealthPanelComponent', () => { + let component: HealthPanelComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + NgbNavModule, + NgbAccordionModule, + CommonModule, + BrowserAnimationsModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), + ], + declarations: [ + HealthPanelComponent, + ObjNgFor + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(HealthPanelComponent); + component = fixture.componentInstance; + component.healthResponse = HealthResponseObj; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render a panel for each component', () => { + const components = fixture.debugElement.queryAll(By.css('[data-test="component"]')); + expect(components.length).toBe(5); + }); + +}); diff --git a/src/app/health-page/health-panel/health-panel.component.ts b/src/app/health-page/health-panel/health-panel.component.ts new file mode 100644 index 0000000000..1c056daf20 --- /dev/null +++ b/src/app/health-page/health-panel/health-panel.component.ts @@ -0,0 +1,45 @@ +import { Component, Input, OnInit } from '@angular/core'; + +import { TranslateService } from '@ngx-translate/core'; + +import { HealthResponse } from '../models/health-component.model'; + +/** + * Show the health panel + */ +@Component({ + selector: 'ds-health-panel', + templateUrl: './health-panel.component.html', + styleUrls: ['./health-panel.component.scss'] +}) +export class HealthPanelComponent implements OnInit { + + /** + * Health endpoint response + */ + @Input() healthResponse: HealthResponse; + + /** + * The first active panel id + */ + activeId: string; + + constructor(private translate: TranslateService) { + } + + ngOnInit(): void { + this.activeId = Object.keys(this.healthResponse.components)[0]; + } + + /** + * Return translated label if exist for the given property + * + * @param panelKey + */ + public getPanelLabel(panelKey: string): string { + const translationKey = `health-page.section.${panelKey}.title`; + const translation = this.translate.instant(translationKey); + + return (translation === translationKey) ? panelKey : translation; + } +} diff --git a/src/app/health-page/health-panel/health-status/health-status.component.html b/src/app/health-page/health-panel/health-status/health-status.component.html new file mode 100644 index 0000000000..38a6f72601 --- /dev/null +++ b/src/app/health-page/health-panel/health-status/health-status.component.html @@ -0,0 +1,12 @@ + + + + + + diff --git a/src/app/health-page/health-panel/health-status/health-status.component.scss b/src/app/health-page/health-panel/health-status/health-status.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/health-page/health-panel/health-status/health-status.component.spec.ts b/src/app/health-page/health-panel/health-status/health-status.component.spec.ts new file mode 100644 index 0000000000..f0f61ebdbb --- /dev/null +++ b/src/app/health-page/health-panel/health-status/health-status.component.spec.ts @@ -0,0 +1,60 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { HealthStatusComponent } from './health-status.component'; +import { HealthStatus } from '../../models/health-component.model'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock'; +import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; + +describe('HealthStatusComponent', () => { + let component: HealthStatusComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + NgbTooltipModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }) + ], + declarations: [ HealthStatusComponent ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(HealthStatusComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should create success icon', () => { + component.status = HealthStatus.UP; + fixture.detectChanges(); + const icon = fixture.debugElement.query(By.css('i.text-success')); + expect(icon).toBeTruthy(); + }); + + it('should create warning icon', () => { + component.status = HealthStatus.UP_WITH_ISSUES; + fixture.detectChanges(); + const icon = fixture.debugElement.query(By.css('i.text-warning')); + expect(icon).toBeTruthy(); + }); + + it('should create success icon', () => { + component.status = HealthStatus.DOWN; + fixture.detectChanges(); + const icon = fixture.debugElement.query(By.css('i.text-danger')); + expect(icon).toBeTruthy(); + }); +}); diff --git a/src/app/health-page/health-panel/health-status/health-status.component.ts b/src/app/health-page/health-panel/health-status/health-status.component.ts new file mode 100644 index 0000000000..19f83713fc --- /dev/null +++ b/src/app/health-page/health-panel/health-status/health-status.component.ts @@ -0,0 +1,23 @@ +import { Component, Input } from '@angular/core'; +import { HealthStatus } from '../../models/health-component.model'; + +/** + * Show a health status object + */ +@Component({ + selector: 'ds-health-status', + templateUrl: './health-status.component.html', + styleUrls: ['./health-status.component.scss'] +}) +export class HealthStatusComponent { + /** + * The current status to show + */ + @Input() status: HealthStatus; + + /** + * He + */ + HealthStatus = HealthStatus; + +} diff --git a/src/app/health-page/health.service.ts b/src/app/health-page/health.service.ts new file mode 100644 index 0000000000..7c238769a1 --- /dev/null +++ b/src/app/health-page/health.service.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { map, switchMap } from 'rxjs/operators'; +import { DspaceRestService } from '../core/dspace-rest/dspace-rest.service'; +import { RawRestResponse } from '../core/dspace-rest/raw-rest-response.model'; +import { HALEndpointService } from '../core/shared/hal-endpoint.service'; + +@Injectable({ + providedIn: 'root' +}) +export class HealthService { + constructor(protected halService: HALEndpointService, + protected restService: DspaceRestService) { + } + /** + * @returns health data + */ + getHealth(): Observable { + return this.halService.getEndpoint('/actuator').pipe( + map((restURL: string) => restURL + '/health'), + switchMap((endpoint: string) => this.restService.get(endpoint))); + } + + /** + * @returns information of server + */ + getInfo(): Observable { + return this.halService.getEndpoint('/actuator').pipe( + map((restURL: string) => restURL + '/info'), + switchMap((endpoint: string) => this.restService.get(endpoint))); + } +} diff --git a/src/app/health-page/models/health-component.model.ts b/src/app/health-page/models/health-component.model.ts new file mode 100644 index 0000000000..8461d4d967 --- /dev/null +++ b/src/app/health-page/models/health-component.model.ts @@ -0,0 +1,48 @@ +/** + * Interface for Health Status + */ +export enum HealthStatus { + UP = 'UP', + UP_WITH_ISSUES = 'UP_WITH_ISSUES', + DOWN = 'DOWN' +} + +/** + * Interface describing the Health endpoint response + */ +export interface HealthResponse { + status: HealthStatus; + components: { + [name: string]: HealthComponent; + }; +} + +/** + * Interface describing a single component retrieved from the Health endpoint response + */ +export interface HealthComponent { + status: HealthStatus; + details?: { + [name: string]: number|string; + }; + components?: { + [name: string]: HealthComponent; + }; +} + +/** + * Interface describing the Health info endpoint response + */ +export interface HealthInfoResponse { + [name: string]: HealthInfoComponent|string; +} + +/** + * Interface describing a single component retrieved from the Health info endpoint response + */ +export interface HealthInfoComponent { + [property: string]: HealthInfoComponent|string; +} + + + diff --git a/src/app/item-page/edit-item-page/edit-item-page.module.ts b/src/app/item-page/edit-item-page/edit-item-page.module.ts index 67cf31c6ed..3ed741bc1a 100644 --- a/src/app/item-page/edit-item-page/edit-item-page.module.ts +++ b/src/app/item-page/edit-item-page/edit-item-page.module.ts @@ -1,7 +1,7 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; +import { NgbTooltipModule, NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { SharedModule } from '../../shared/shared.module'; import { EditItemPageRoutingModule } from './edit-item-page.routing.module'; @@ -49,7 +49,8 @@ import { ResourcePoliciesModule } from '../../shared/resource-policies/resource- EditItemPageRoutingModule, SearchPageModule, DragDropModule, - ResourcePoliciesModule + ResourcePoliciesModule, + NgbModule ], declarations: [ EditItemPageComponent, diff --git a/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.html b/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.html index 71aa7b44de..5437525185 100644 --- a/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.html +++ b/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.html @@ -1,13 +1,33 @@
- - - - - + + + + + + +
+
+ +
+
+ + + +
+ +
+
+
+
+ +
- diff --git a/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.scss b/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.scss new file mode 100644 index 0000000000..c3694e6784 --- /dev/null +++ b/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.scss @@ -0,0 +1,4 @@ +.auth-bitstream-container { + margin-top: -1em; + margin-bottom: 1.5em; +} diff --git a/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.spec.ts b/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.spec.ts index 97280c3ea0..2fe8a562c6 100644 --- a/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.spec.ts +++ b/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.spec.ts @@ -1,11 +1,12 @@ +import { Observable } from 'rxjs/internal/Observable'; import { waitForAsync, ComponentFixture, inject, TestBed } from '@angular/core/testing'; import { Component, NO_ERRORS_SCHEMA } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { of as observableOf } from 'rxjs'; +import { of as observableOf, of } from 'rxjs'; import { TranslateModule } from '@ngx-translate/core'; import { cold } from 'jasmine-marbles'; -import { ItemAuthorizationsComponent } from './item-authorizations.component'; +import { ItemAuthorizationsComponent, BitstreamMapValue } from './item-authorizations.component'; import { Bitstream } from '../../../core/shared/bitstream.model'; import { Bundle } from '../../../core/shared/bundle.model'; import { Item } from '../../../core/shared/item.model'; @@ -57,8 +58,6 @@ describe('ItemAuthorizationsComponent test suite', () => { bitstreams: createSuccessfulRemoteDataObject$(createPaginatedList([bitstream3, bitstream4])) }); const bundles = [bundle1, bundle2]; - const bitstreamList1: PaginatedList = buildPaginatedList(new PageInfo(), [bitstream1, bitstream2]); - const bitstreamList2: PaginatedList = buildPaginatedList(new PageInfo(), [bitstream3, bitstream4]); const item = Object.assign(new Item(), { uuid: 'item', @@ -142,13 +141,12 @@ describe('ItemAuthorizationsComponent test suite', () => { expect(compAsAny.bundleBitstreamsMap.has('bundle1')).toBeTruthy(); expect(compAsAny.bundleBitstreamsMap.has('bundle2')).toBeTruthy(); let bitstreamList = compAsAny.bundleBitstreamsMap.get('bundle1'); - expect(bitstreamList).toBeObservable(cold('(a|)', { - a: bitstreamList1 + expect(bitstreamList.bitstreams).toBeObservable(cold('(a|)', { + a : [bitstream1, bitstream2] })); - bitstreamList = compAsAny.bundleBitstreamsMap.get('bundle2'); - expect(bitstreamList).toBeObservable(cold('(a|)', { - a: bitstreamList2 + expect(bitstreamList.bitstreams).toBeObservable(cold('(a|)', { + a: [bitstream3, bitstream4] })); }); diff --git a/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.ts b/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.ts index 76597a135b..8ed2f9a12e 100644 --- a/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.ts +++ b/src/app/item-page/edit-item-page/item-authorizations/item-authorizations.component.ts @@ -1,3 +1,5 @@ +import { isEqual } from 'lodash'; +import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; import { Component, OnDestroy, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; @@ -6,7 +8,8 @@ import { catchError, filter, first, map, mergeMap, take } from 'rxjs/operators'; import { buildPaginatedList, PaginatedList } from '../../../core/data/paginated-list.model'; import { - getFirstSucceededRemoteDataPayload, getFirstSucceededRemoteDataWithNotEmptyPayload, + getFirstSucceededRemoteDataPayload, + getFirstSucceededRemoteDataWithNotEmptyPayload, } from '../../../core/shared/operators'; import { Item } from '../../../core/shared/item.model'; import { followLink } from '../../../shared/utils/follow-link-config.model'; @@ -25,7 +28,8 @@ interface BundleBitstreamsMapEntry { @Component({ selector: 'ds-item-authorizations', - templateUrl: './item-authorizations.component.html' + templateUrl: './item-authorizations.component.html', + styleUrls:['./item-authorizations.component.scss'] }) /** * Component that handles the item Authorizations @@ -36,13 +40,13 @@ export class ItemAuthorizationsComponent implements OnInit, OnDestroy { * A map that contains all bitstream of the item's bundles * @type {Observable>>>} */ - public bundleBitstreamsMap: Map>> = new Map>>(); + public bundleBitstreamsMap: Map = new Map(); /** - * The list of bundle for the item + * The list of all bundles for the item * @type {Observable>} */ - private bundles$: BehaviorSubject = new BehaviorSubject([]); + bundles$: BehaviorSubject = new BehaviorSubject([]); /** * The target editing item @@ -56,15 +60,48 @@ export class ItemAuthorizationsComponent implements OnInit, OnDestroy { */ private subs: Subscription[] = []; + /** + * The size of the bundles to be loaded on demand + * @type {number} + */ + bundlesPerPage = 6; + + /** + * The number of current page + * @type {number} + */ + bundlesPageSize = 1; + + /** + * The flag to show or not the 'Load more' button + * based on the condition if all the bundles are loaded or not + * @type {boolean} + */ + allBundlesLoaded = false; + + /** + * Initial size of loaded bitstreams + * The size of incrementation used in bitstream pagination + */ + bitstreamSize = 4; + + /** + * The size of the loaded bitstremas at a certain moment + * @private + */ + private bitstreamPageSize = 4; + /** * Initialize instance variables * * @param {LinkService} linkService * @param {ActivatedRoute} route + * @param nameService */ constructor( private linkService: LinkService, - private route: ActivatedRoute + private route: ActivatedRoute, + private nameService: DSONameService ) { } @@ -72,16 +109,53 @@ export class ItemAuthorizationsComponent implements OnInit, OnDestroy { * Initialize the component, setting up the bundle and bitstream within the item */ ngOnInit(): void { + this.getBundlesPerItem(); + } + + /** + * Return the item's UUID + */ + getItemUUID(): Observable { + return this.item$.pipe( + map((item: Item) => item.id), + first((UUID: string) => isNotEmpty(UUID)) + ); + } + + /** + * Return the item's name + */ + getItemName(): Observable { + return this.item$.pipe( + map((item: Item) => this.nameService.getName(item)) + ); + } + + /** + * Return all item's bundles + * + * @return an observable that emits all item's bundles + */ + getItemBundles(): Observable { + return this.bundles$.asObservable(); + } + + /** + * Get all bundles per item + * and all the bitstreams per bundle + * @param page number of current page + */ + getBundlesPerItem(page: number = 1) { this.item$ = this.route.data.pipe( map((data) => data.dso), getFirstSucceededRemoteDataWithNotEmptyPayload(), map((item: Item) => this.linkService.resolveLink( item, - followLink('bundles', {}, followLink('bitstreams')) + followLink('bundles', {findListOptions: {currentPage : page, elementsPerPage: this.bundlesPerPage}}, followLink('bitstreams')) )) ) as Observable; - const bundles$: Observable> = this.item$.pipe( + const bundles$: Observable> = this.item$.pipe( filter((item: Item) => isNotEmpty(item.bundles)), mergeMap((item: Item) => item.bundles), getFirstSucceededRemoteDataWithNotEmptyPayload(), @@ -96,37 +170,36 @@ export class ItemAuthorizationsComponent implements OnInit, OnDestroy { take(1), map((list: PaginatedList) => list.page) ).subscribe((bundles: Bundle[]) => { - this.bundles$.next(bundles); + if (isEqual(bundles.length,0) || bundles.length < this.bundlesPerPage) { + this.allBundlesLoaded = true; + } + if (isEqual(page, 1)) { + this.bundles$.next(bundles); + } else { + this.bundles$.next(this.bundles$.getValue().concat(bundles)); + } }), bundles$.pipe( take(1), mergeMap((list: PaginatedList) => list.page), map((bundle: Bundle) => ({ id: bundle.id, bitstreams: this.getBundleBitstreams(bundle) })) ).subscribe((entry: BundleBitstreamsMapEntry) => { - this.bundleBitstreamsMap.set(entry.id, entry.bitstreams); + let bitstreamMapValues: BitstreamMapValue = { + isCollapsed: true, + allBitstreamsLoaded: false, + bitstreams: null + }; + bitstreamMapValues.bitstreams = entry.bitstreams.pipe( + map((b: PaginatedList) => { + bitstreamMapValues.allBitstreamsLoaded = b?.page.length < this.bitstreamSize; + return [...b.page.slice(0, this.bitstreamSize)]; + }) + ); + this.bundleBitstreamsMap.set(entry.id, bitstreamMapValues); }) ); } - /** - * Return the item's UUID - */ - getItemUUID(): Observable { - return this.item$.pipe( - map((item: Item) => item.id), - first((UUID: string) => isNotEmpty(UUID)) - ); - } - - /** - * Return all item's bundles - * - * @return an observable that emits all item's bundles - */ - getItemBundles(): Observable { - return this.bundles$.asObservable(); - } - /** * Return all bundle's bitstreams * @@ -142,6 +215,46 @@ export class ItemAuthorizationsComponent implements OnInit, OnDestroy { ); } + /** + * Changes the collapsible state of the area that contains the bitstream list + * @param bundleId Id of bundle responsible for the requested bitstreams + */ + collapseArea(bundleId: string) { + this.bundleBitstreamsMap.get(bundleId).isCollapsed = !this.bundleBitstreamsMap.get(bundleId).isCollapsed; + } + + /** + * Loads as much bundles as initial value of bundleSize to be displayed + */ + onBundleLoad(){ + this.bundlesPageSize ++; + this.getBundlesPerItem(this.bundlesPageSize); + } + + /** + * Calculates the bitstreams that are going to be loaded on demand, + * based on the number configured on this.bitstreamSize. + * @param bundle parent of bitstreams that are requested to be shown + * @returns Subscription + */ + onBitstreamsLoad(bundle: Bundle) { + return this.getBundleBitstreams(bundle).subscribe((res: PaginatedList) => { + let nextBitstreams = res?.page.slice(this.bitstreamPageSize, this.bitstreamPageSize + this.bitstreamSize); + let bitstreamsToShow = this.bundleBitstreamsMap.get(bundle.id).bitstreams.pipe( + map((existingBits: Bitstream[])=> { + return [... existingBits, ...nextBitstreams]; + }) + ); + this.bitstreamPageSize = this.bitstreamPageSize + this.bitstreamSize; + let bitstreamMapValues: BitstreamMapValue = { + bitstreams: bitstreamsToShow , + isCollapsed: this.bundleBitstreamsMap.get(bundle.id).isCollapsed, + allBitstreamsLoaded: res?.page.length <= this.bitstreamPageSize + }; + this.bundleBitstreamsMap.set(bundle.id, bitstreamMapValues); + }); + } + /** * Unsubscribe from all subscriptions */ @@ -151,3 +264,9 @@ export class ItemAuthorizationsComponent implements OnInit, OnDestroy { .forEach((subscription) => subscription.unsubscribe()); } } + +export interface BitstreamMapValue { + bitstreams: Observable; + isCollapsed: boolean; + allBitstreamsLoaded: boolean; +} diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss index 46d172dadc..7de575b785 100644 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss +++ b/src/app/item-page/edit-item-page/item-bitstreams/item-bitstreams.component.scss @@ -50,3 +50,7 @@ cursor: grabbing; } } + +:host ::ng-deep .larger-tooltip .tooltip-inner { + max-width: 500px; +} diff --git a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.html b/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.html index 5d1edb847f..0f0fad2199 100644 --- a/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.html +++ b/src/app/item-page/edit-item-page/item-bitstreams/item-edit-bitstream/item-edit-bitstream.component.html @@ -9,9 +9,10 @@
- - {{ bitstream?.firstMetadataValue('dc.description') }} - +
+ {{ bitstream?.firstMetadataValue('dc.description') }} +
diff --git a/src/app/item-page/item-page-routing-paths.ts b/src/app/item-page/item-page-routing-paths.ts index 74ad0aae07..90a4a54b1e 100644 --- a/src/app/item-page/item-page-routing-paths.ts +++ b/src/app/item-page/item-page-routing-paths.ts @@ -50,3 +50,4 @@ export const ITEM_EDIT_PATH = 'edit'; export const ITEM_EDIT_VERSIONHISTORY_PATH = 'versionhistory'; export const ITEM_VERSION_PATH = 'version'; export const UPLOAD_BITSTREAM_PATH = 'bitstreams/new'; +export const ORCID_PATH = 'orcid'; diff --git a/src/app/item-page/item-page-routing.module.ts b/src/app/item-page/item-page-routing.module.ts index 59dafd4d99..add2c3d768 100644 --- a/src/app/item-page/item-page-routing.module.ts +++ b/src/app/item-page/item-page-routing.module.ts @@ -7,15 +7,19 @@ import { VersionResolver } from './version-page/version.resolver'; import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service'; import { LinkService } from '../core/cache/builders/link.service'; import { UploadBitstreamComponent } from './bitstreams/upload/upload-bitstream.component'; -import { ITEM_EDIT_PATH, UPLOAD_BITSTREAM_PATH } from './item-page-routing-paths'; +import { ITEM_EDIT_PATH, ORCID_PATH, UPLOAD_BITSTREAM_PATH } from './item-page-routing-paths'; import { ItemPageAdministratorGuard } from './item-page-administrator.guard'; import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model'; import { ThemedItemPageComponent } from './simple/themed-item-page.component'; import { ThemedFullItemPageComponent } from './full/themed-full-item-page.component'; import { MenuItemType } from '../shared/menu/menu-item-type.model'; import { VersionPageComponent } from './version-page/version-page/version-page.component'; -import { BitstreamRequestACopyPageComponent } from '../shared/bitstream-request-a-copy-page/bitstream-request-a-copy-page.component'; +import { + BitstreamRequestACopyPageComponent +} from '../shared/bitstream-request-a-copy-page/bitstream-request-a-copy-page.component'; import { REQUEST_COPY_MODULE_PATH } from '../app-routing-paths'; +import { OrcidPageComponent } from './orcid-page/orcid-page.component'; +import { OrcidPageGuard } from './orcid-page/orcid-page.guard'; @NgModule({ imports: [ @@ -50,6 +54,11 @@ import { REQUEST_COPY_MODULE_PATH } from '../app-routing-paths'; { path: REQUEST_COPY_MODULE_PATH, component: BitstreamRequestACopyPageComponent, + }, + { + path: ORCID_PATH, + component: OrcidPageComponent, + canActivate: [AuthenticatedGuard, OrcidPageGuard] } ], data: { @@ -88,6 +97,7 @@ import { REQUEST_COPY_MODULE_PATH } from '../app-routing-paths'; LinkService, ItemPageAdministratorGuard, VersionResolver, + OrcidPageGuard ] }) diff --git a/src/app/item-page/item-page.module.ts b/src/app/item-page/item-page.module.ts index 80cb1f61a2..cbb9f3299e 100644 --- a/src/app/item-page/item-page.module.ts +++ b/src/app/item-page/item-page.module.ts @@ -6,11 +6,19 @@ import { SharedModule } from '../shared/shared.module'; import { ItemPageComponent } from './simple/item-page.component'; import { ItemPageRoutingModule } from './item-page-routing.module'; import { MetadataUriValuesComponent } from './field-components/metadata-uri-values/metadata-uri-values.component'; -import { ItemPageAuthorFieldComponent } from './simple/field-components/specific-field/author/item-page-author-field.component'; -import { ItemPageDateFieldComponent } from './simple/field-components/specific-field/date/item-page-date-field.component'; -import { ItemPageAbstractFieldComponent } from './simple/field-components/specific-field/abstract/item-page-abstract-field.component'; +import { + ItemPageAuthorFieldComponent +} from './simple/field-components/specific-field/author/item-page-author-field.component'; +import { + ItemPageDateFieldComponent +} from './simple/field-components/specific-field/date/item-page-date-field.component'; +import { + ItemPageAbstractFieldComponent +} from './simple/field-components/specific-field/abstract/item-page-abstract-field.component'; import { ItemPageUriFieldComponent } from './simple/field-components/specific-field/uri/item-page-uri-field.component'; -import { ItemPageTitleFieldComponent } from './simple/field-components/specific-field/title/item-page-title-field.component'; +import { + ItemPageTitleFieldComponent +} from './simple/field-components/specific-field/title/item-page-title-field.component'; import { ItemPageFieldComponent } from './simple/field-components/specific-field/item-page-field.component'; import { CollectionsComponent } from './field-components/collections/collections.component'; import { FullItemPageComponent } from './full/full-item-page.component'; @@ -20,7 +28,9 @@ import { ItemComponent } from './simple/item-types/shared/item.component'; import { EditItemPageModule } from './edit-item-page/edit-item-page.module'; import { UploadBitstreamComponent } from './bitstreams/upload/upload-bitstream.component'; import { StatisticsModule } from '../statistics/statistics.module'; -import { AbstractIncrementalListComponent } from './simple/abstract-incremental-list/abstract-incremental-list.component'; +import { + AbstractIncrementalListComponent +} from './simple/abstract-incremental-list/abstract-incremental-list.component'; import { UntypedItemComponent } from './simple/item-types/untyped-item/untyped-item.component'; import { JournalEntitiesModule } from '../entity-groups/journal-entities/journal-entities.module'; import { ResearchEntitiesModule } from '../entity-groups/research-entities/research-entities.module'; @@ -34,6 +44,11 @@ import { MiradorViewerComponent } from './mirador-viewer/mirador-viewer.componen import { VersionPageComponent } from './version-page/version-page/version-page.component'; import { VersionedItemComponent } from './simple/item-types/versioned-item/versioned-item.component'; import { ThemedFileSectionComponent } from './simple/field-components/file-section/themed-file-section.component'; +import { OrcidAuthComponent } from './orcid-page/orcid-auth/orcid-auth.component'; +import { OrcidPageComponent } from './orcid-page/orcid-page.component'; +import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'; +import { OrcidSyncSettingsComponent } from './orcid-page/orcid-sync-settings/orcid-sync-settings.component'; +import { OrcidQueueComponent } from './orcid-page/orcid-queue/orcid-queue.component'; const ENTRY_COMPONENTS = [ @@ -67,6 +82,10 @@ const DECLARATIONS = [ MediaViewerImageComponent, MiradorViewerComponent, VersionPageComponent, + OrcidPageComponent, + OrcidAuthComponent, + OrcidSyncSettingsComponent, + OrcidQueueComponent ]; @NgModule({ @@ -79,6 +98,7 @@ const DECLARATIONS = [ JournalEntitiesModule.withEntryComponents(), ResearchEntitiesModule.withEntryComponents(), NgxGalleryModule, + NgbAccordionModule ], declarations: [ ...DECLARATIONS, diff --git a/src/app/item-page/orcid-page/orcid-auth/orcid-auth.component.html b/src/app/item-page/orcid-page/orcid-auth/orcid-auth.component.html new file mode 100644 index 0000000000..e57ce33008 --- /dev/null +++ b/src/app/item-page/orcid-page/orcid-auth/orcid-auth.component.html @@ -0,0 +1,84 @@ +
+

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

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

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

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

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

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

{{status}}

+

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

+
+

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

+
+

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

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

{{"500.help" | translate}}


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

diff --git a/src/app/profile-page/profile-claim-item-modal/profile-claim-item-modal.component.html b/src/app/profile-page/profile-claim-item-modal/profile-claim-item-modal.component.html new file mode 100644 index 0000000000..eec9f437f1 --- /dev/null +++ b/src/app/profile-page/profile-claim-item-modal/profile-claim-item-modal.component.html @@ -0,0 +1,37 @@ +
+ + + +
diff --git a/src/app/profile-page/profile-claim-item-modal/profile-claim-item-modal.component.spec.ts b/src/app/profile-page/profile-claim-item-modal/profile-claim-item-modal.component.spec.ts new file mode 100644 index 0000000000..2843322818 --- /dev/null +++ b/src/app/profile-page/profile-claim-item-modal/profile-claim-item-modal.component.spec.ts @@ -0,0 +1,223 @@ +import { ActivatedRoute, Router } from '@angular/router'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; + +import { of } from 'rxjs'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +import { ProfileClaimItemModalComponent } from './profile-claim-item-modal.component'; +import { ProfileClaimService } from '../profile-claim/profile-claim.service'; +import { Item } from '../../core/shared/item.model'; +import { ItemSearchResult } from '../../shared/object-collection/shared/item-search-result.model'; +import { SearchObjects } from '../../shared/search/models/search-objects.model'; +import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; +import { getItemPageRoute } from '../../item-page/item-page-routing-paths'; +import { RouterStub } from '../../shared/testing/router.stub'; + +describe('ProfileClaimItemModalComponent', () => { + let component: ProfileClaimItemModalComponent; + let fixture: ComponentFixture; + + const item1: Item = Object.assign(new Item(), { + uuid: 'e1c51c69-896d-42dc-8221-1d5f2ad5516e', + metadata: { + 'person.email': [ + { + value: 'fake@email.com' + } + ], + 'person.familyName': [ + { + value: 'Doe' + } + ], + 'person.givenName': [ + { + value: 'John' + } + ] + }, + _links: { + self: { + href: 'item-href' + } + } + }); + const item2: Item = Object.assign(new Item(), { + uuid: 'c8279647-1acc-41ae-b036-951d5f65649b', + metadata: { + 'person.email': [ + { + value: 'fake2@email.com' + } + ], + 'dc.title': [ + { + value: 'John, Doe' + } + ] + }, + _links: { + self: { + href: 'item-href' + } + } + }); + const item3: Item = Object.assign(new Item(), { + uuid: 'c8279647-1acc-41ae-b036-951d5f65649b', + metadata: { + 'person.email': [ + { + value: 'fake3@email.com' + } + ], + 'dc.title': [ + { + value: 'John, Doe' + } + ] + }, + _links: { + self: { + href: 'item-href' + } + } + }); + + const searchResult1 = Object.assign(new ItemSearchResult(), { indexableObject: item1 }); + const searchResult2 = Object.assign(new ItemSearchResult(), { indexableObject: item2 }); + const searchResult3 = Object.assign(new ItemSearchResult(), { indexableObject: item3 }); + + const searchResult = Object.assign(new SearchObjects(), { + page: [searchResult1, searchResult2, searchResult3] + }); + const emptySearchResult = Object.assign(new SearchObjects(), { + page: [] + }); + const searchResultRD = createSuccessfulRemoteDataObject(searchResult); + const emptySearchResultRD = createSuccessfulRemoteDataObject(emptySearchResult); + + const profileClaimService = jasmine.createSpyObj('profileClaimService', { + searchForSuggestions: jasmine.createSpy('searchForSuggestions') + }); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [ProfileClaimItemModalComponent], + providers: [ + { provide: NgbActiveModal, useValue: {} }, + { provide: ActivatedRoute, useValue: {} }, + { provide: Router, useValue: new RouterStub() }, + { provide: ProfileClaimService, useValue: profileClaimService } + ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ProfileClaimItemModalComponent); + component = fixture.componentInstance; + }); + + describe('when there are suggestions', () => { + + beforeEach(() => { + profileClaimService.searchForSuggestions.and.returnValue(of(searchResultRD)); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should init the list of suggestions', () => { + const entries = fixture.debugElement.queryAll(By.css('.list-group-item')); + expect(component.listEntries$.value).toEqual(searchResultRD); + expect(entries.length).toBe(3); + }); + + it('should close modal and call navigate method', () => { + spyOn(component, 'close'); + spyOn(component, 'navigate'); + component.selectItem(item1); + + expect(component.close).toHaveBeenCalled(); + expect(component.navigate).toHaveBeenCalledWith(item1); + }); + + it('should call router navigate method', () => { + const route = [getItemPageRoute(item1)]; + component.navigate(item1); + + expect((component as any).router.navigate).toHaveBeenCalledWith(route); + }); + + it('should toggle checkbox', () => { + component.toggleCheckbox(); + + expect((component as any).checked).toBe(true); + }); + + it('should emit create event', () => { + spyOn(component, 'close'); + spyOn(component.create, 'emit'); + component.createFromScratch(); + + expect(component.create.emit).toHaveBeenCalled(); + expect(component.close).toHaveBeenCalled(); + }); + }); + + describe('when there are not suggestions', () => { + + beforeEach(() => { + profileClaimService.searchForSuggestions.and.returnValue(of(emptySearchResultRD)); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should init the list of suggestions', () => { + const entries = fixture.debugElement.queryAll(By.css('.list-group-item')); + expect(component.listEntries$.value).toEqual(emptySearchResultRD); + expect(entries.length).toBe(0); + }); + + it('should close modal and call navigate method', () => { + spyOn(component, 'close'); + spyOn(component, 'navigate'); + component.selectItem(item1); + + expect(component.close).toHaveBeenCalled(); + expect(component.navigate).toHaveBeenCalledWith(item1); + }); + + it('should call router navigate method', () => { + const route = [getItemPageRoute(item1)]; + component.navigate(item1); + + expect((component as any).router.navigate).toHaveBeenCalledWith(route); + }); + + it('should toggle checkbox', () => { + component.toggleCheckbox(); + + expect((component as any).checked).toBe(true); + }); + + it('should emit create event', () => { + spyOn(component, 'close'); + spyOn(component.create, 'emit'); + component.createFromScratch(); + + expect(component.create.emit).toHaveBeenCalled(); + expect(component.close).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/profile-page/profile-claim-item-modal/profile-claim-item-modal.component.ts b/src/app/profile-page/profile-claim-item-modal/profile-claim-item-modal.component.ts new file mode 100644 index 0000000000..6838704560 --- /dev/null +++ b/src/app/profile-page/profile-claim-item-modal/profile-claim-item-modal.component.ts @@ -0,0 +1,108 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { BehaviorSubject } from 'rxjs'; +import { RemoteData } from '../../core/data/remote-data'; +import { Item } from '../../core/shared/item.model'; +import { + DSOSelectorModalWrapperComponent +} from '../../shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component'; +import { getItemPageRoute } from '../../item-page/item-page-routing-paths'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { ViewMode } from '../../core/shared/view-mode.model'; +import { ProfileClaimService } from '../profile-claim/profile-claim.service'; +import { CollectionElementLinkType } from '../../shared/object-collection/collection-element-link.type'; +import { SearchObjects } from '../../shared/search/models/search-objects.model'; +import { getFirstCompletedRemoteData } from '../../core/shared/operators'; + +/** + * Component representing a modal that show a list of suggested profile item to claim + */ +@Component({ + selector: 'ds-profile-claim-item-modal', + templateUrl: './profile-claim-item-modal.component.html' +}) +export class ProfileClaimItemModalComponent extends DSOSelectorModalWrapperComponent implements OnInit { + + /** + * The current page's DSO + */ + @Input() dso: DSpaceObject; + + /** + * List of suggested profiles, if any + */ + listEntries$: BehaviorSubject>> = new BehaviorSubject(null); + + /** + * The view mode of the listed objects + */ + viewMode = ViewMode.ListElement; + + /** + * The available link types + */ + linkTypes = CollectionElementLinkType; + + /** + * A boolean representing form checkbox status + */ + checked = false; + + /** + * An event fired when user click on submit button + */ + @Output() create: EventEmitter = new EventEmitter(); + + constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute, private router: Router, + private profileClaimService: ProfileClaimService) { + super(activeModal, route); + } + + /** + * Retrieve suggested profiles, if any + */ + ngOnInit(): void { + this.profileClaimService.searchForSuggestions(this.dso as EPerson).pipe( + getFirstCompletedRemoteData(), + ).subscribe( + (result: RemoteData>) => this.listEntries$.next(result) + ); + } + + /** + * Close modal and Navigate to given DSO + * + * @param dso + */ + selectItem(dso: DSpaceObject): void { + this.close(); + this.navigate(dso); + } + + /** + * Navigate to given DSO + * + * @param dso + */ + navigate(dso: DSpaceObject) { + this.router.navigate([getItemPageRoute(dso as Item)]); + } + + /** + * Change the status of form's checkbox + */ + toggleCheckbox() { + this.checked = !this.checked; + } + + /** + * Emit an event when profile should be created from scratch + */ + createFromScratch() { + this.create.emit(); + this.close(); + } + +} diff --git a/src/app/profile-page/profile-claim/profile-claim.service.spec.ts b/src/app/profile-page/profile-claim/profile-claim.service.spec.ts new file mode 100644 index 0000000000..e2a9ff1461 --- /dev/null +++ b/src/app/profile-page/profile-claim/profile-claim.service.spec.ts @@ -0,0 +1,215 @@ +import { cold, getTestScheduler } from 'jasmine-marbles'; + +import { of as observableOf } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { ProfileClaimService } from './profile-claim.service'; +import { SearchService } from '../../core/shared/search/search.service'; +import { ItemSearchResult } from '../../shared/object-collection/shared/item-search-result.model'; +import { SearchObjects } from '../../shared/search/models/search-objects.model'; +import { Item } from '../../core/shared/item.model'; +import { createNoContentRemoteDataObject, createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; +import { EPerson } from '../../core/eperson/models/eperson.model'; + +describe('ProfileClaimService', () => { + let scheduler: TestScheduler; + let service: ProfileClaimService; + let serviceAsAny: any; + let searchService: jasmine.SpyObj; + + const eperson: EPerson = Object.assign(new EPerson(), { + id: 'id', + metadata: { + 'eperson.firstname': [ + { + value: 'John' + } + ], + 'eperson.lastname': [ + { + value: 'Doe' + }, + ], + }, + email: 'fake@email.com' + }); + const item1: Item = Object.assign(new Item(), { + uuid: 'e1c51c69-896d-42dc-8221-1d5f2ad5516e', + metadata: { + 'person.email': [ + { + value: 'fake@email.com' + } + ], + 'person.familyName': [ + { + value: 'Doe' + } + ], + 'person.givenName': [ + { + value: 'John' + } + ] + }, + _links: { + self: { + href: 'item-href' + } + } + }); + const item2: Item = Object.assign(new Item(), { + uuid: 'c8279647-1acc-41ae-b036-951d5f65649b', + metadata: { + 'person.email': [ + { + value: 'fake2@email.com' + } + ], + 'dc.title': [ + { + value: 'John, Doe' + } + ] + }, + _links: { + self: { + href: 'item-href' + } + } + }); + const item3: Item = Object.assign(new Item(), { + uuid: 'c8279647-1acc-41ae-b036-951d5f65649b', + metadata: { + 'person.email': [ + { + value: 'fake3@email.com' + } + ], + 'dc.title': [ + { + value: 'John, Doe' + } + ] + }, + _links: { + self: { + href: 'item-href' + } + } + }); + + const searchResult1 = Object.assign(new ItemSearchResult(), { indexableObject: item1 }); + const searchResult2 = Object.assign(new ItemSearchResult(), { indexableObject: item2 }); + const searchResult3 = Object.assign(new ItemSearchResult(), { indexableObject: item3 }); + + const searchResult = Object.assign(new SearchObjects(), { + page: [searchResult1, searchResult2, searchResult3] + }); + const emptySearchResult = Object.assign(new SearchObjects(), { + page: [] + }); + const searchResultRD = createSuccessfulRemoteDataObject(searchResult); + const emptySearchResultRD = createSuccessfulRemoteDataObject(emptySearchResult); + + beforeEach(() => { + scheduler = getTestScheduler(); + + searchService = jasmine.createSpyObj('SearchService', { + search: jasmine.createSpy('search') + }); + + service = new ProfileClaimService(searchService); + serviceAsAny = service; + }); + + describe('hasProfilesToSuggest', () => { + + describe('when has suggestions', () => { + beforeEach(() => { + spyOn(service, 'searchForSuggestions').and.returnValue(observableOf(searchResultRD)); + }); + + it('should return true', () => { + const result = service.hasProfilesToSuggest(eperson); + const expected = cold('(a|)', { + a: true + }); + expect(result).toBeObservable(expected); + }); + + }); + + describe('when has not suggestions', () => { + beforeEach(() => { + spyOn(service, 'searchForSuggestions').and.returnValue(observableOf(emptySearchResultRD)); + }); + + it('should return false', () => { + const result = service.hasProfilesToSuggest(eperson); + const expected = cold('(a|)', { + a: false + }); + expect(result).toBeObservable(expected); + }); + + }); + + describe('when has not valid eperson', () => { + it('should return false', () => { + const result = service.hasProfilesToSuggest(null); + const expected = cold('(a|)', { + a: false + }); + expect(result).toBeObservable(expected); + }); + + }); + + }); + + describe('search', () => { + + describe('when has search results', () => { + beforeEach(() => { + searchService.search.and.returnValue(observableOf(searchResultRD)); + }); + + it('should return the proper search object', () => { + const result = service.searchForSuggestions(eperson); + const expected = cold('(a|)', { + a: searchResultRD + }); + expect(result).toBeObservable(expected); + }); + + }); + + describe('when has not suggestions', () => { + beforeEach(() => { + searchService.search.and.returnValue(observableOf(emptySearchResultRD)); + }); + + it('should return null', () => { + const result = service.searchForSuggestions(eperson); + const expected = cold('(a|)', { + a: emptySearchResultRD + }); + expect(result).toBeObservable(expected); + }); + + }); + + describe('when has not valid eperson', () => { + it('should return null', () => { + const result = service.searchForSuggestions(null); + const expected = cold('(a|)', { + a: createNoContentRemoteDataObject() + }); + expect(result).toBeObservable(expected); + }); + + }); + + }); +}); diff --git a/src/app/profile-page/profile-claim/profile-claim.service.ts b/src/app/profile-page/profile-claim/profile-claim.service.ts new file mode 100644 index 0000000000..ea907ef629 --- /dev/null +++ b/src/app/profile-page/profile-claim/profile-claim.service.ts @@ -0,0 +1,80 @@ +import { Injectable } from '@angular/core'; + +import { Observable, of } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { RemoteData } from '../../core/data/remote-data'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { SearchService } from '../../core/shared/search/search.service'; +import { isEmpty, isNotEmpty } from '../../shared/empty.util'; +import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; +import { getFirstCompletedRemoteData } from '../../core/shared/operators'; +import { SearchObjects } from '../../shared/search/models/search-objects.model'; +import { createNoContentRemoteDataObject } from '../../shared/remote-data.utils'; + +/** + * Service that handle profiles claim. + */ +@Injectable() +export class ProfileClaimService { + + constructor(private searchService: SearchService) { + } + + /** + * Returns true if it is possible to suggest profiles to be claimed to the given eperson. + * + * @param eperson the eperson + */ + hasProfilesToSuggest(eperson: EPerson): Observable { + return this.searchForSuggestions(eperson).pipe( + getFirstCompletedRemoteData(), + map((rd: RemoteData>) => { + return isNotEmpty(rd) && rd.hasSucceeded && rd.payload?.page?.length > 0; + }) + ); + } + + /** + * Returns profiles that could be associated with the given user. + * + * @param eperson the user + */ + searchForSuggestions(eperson: EPerson): Observable>> { + const query = this.personQueryData(eperson); + if (isEmpty(query)) { + return of(createNoContentRemoteDataObject() as RemoteData>); + } + return this.lookup(query); + } + + /** + * Search object by the given query. + * + * @param query the query for the search + */ + private lookup(query: string): Observable>> { + if (isEmpty(query)) { + return of(createNoContentRemoteDataObject() as RemoteData>); + } + return this.searchService.search(new PaginatedSearchOptions({ + configuration: 'eperson_claims', + query: query + }), null, false, true); + } + + /** + * Return the search query for person lookup, from the given eperson + * + * @param eperson The eperson to use for the lookup + */ + private personQueryData(eperson: EPerson): string { + if (eperson && eperson.email) { + return 'person.email:' + eperson.email; + } else { + return null; + } + } + +} diff --git a/src/app/profile-page/profile-page-researcher-form/profile-page-researcher-form.component.html b/src/app/profile-page/profile-page-researcher-form/profile-page-researcher-form.component.html new file mode 100644 index 0000000000..2d959c1dfe --- /dev/null +++ b/src/app/profile-page/profile-page-researcher-form/profile-page-researcher-form.component.html @@ -0,0 +1,38 @@ +
+
+

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

+

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

+
+
+

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

+
+ + + + + +
diff --git a/src/app/profile-page/profile-page-researcher-form/profile-page-researcher-form.component.spec.ts b/src/app/profile-page/profile-page-researcher-form/profile-page-researcher-form.component.spec.ts new file mode 100644 index 0000000000..aa0d1d187d --- /dev/null +++ b/src/app/profile-page/profile-page-researcher-form/profile-page-researcher-form.component.spec.ts @@ -0,0 +1,183 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { of as observableOf } from 'rxjs'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; + +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { ResearcherProfile } from '../../core/profile/model/researcher-profile.model'; +import { ResearcherProfileService } from '../../core/profile/researcher-profile.service'; +import { VarDirective } from '../../shared/utils/var.directive'; +import { ProfilePageResearcherFormComponent } from './profile-page-researcher-form.component'; +import { ProfileClaimService } from '../profile-claim/profile-claim.service'; +import { AuthService } from '../../core/auth/auth.service'; +import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { followLink } from '../../shared/utils/follow-link-config.model'; + +describe('ProfilePageResearcherFormComponent', () => { + + let component: ProfilePageResearcherFormComponent; + let fixture: ComponentFixture; + let router: Router; + + let user: EPerson; + let profile: ResearcherProfile; + + let researcherProfileService: jasmine.SpyObj; + + let notificationsServiceStub: NotificationsServiceStub; + + let profileClaimService: jasmine.SpyObj; + + let authService: jasmine.SpyObj; + + function init() { + + user = Object.assign(new EPerson(), { + id: 'beef9946-f4ce-479e-8f11-b90cbe9f7241' + }); + + profile = Object.assign(new ResearcherProfile(), { + id: 'beef9946-f4ce-479e-8f11-b90cbe9f7241', + visible: false, + type: 'profile' + }); + + authService = jasmine.createSpyObj('authService', { + getAuthenticatedUserFromStore: observableOf(user) + }); + + researcherProfileService = jasmine.createSpyObj('researcherProfileService', { + findById: createSuccessfulRemoteDataObject$(profile), + create: observableOf(profile), + setVisibility: jasmine.createSpy('setVisibility'), + delete: observableOf(true), + findRelatedItemId: observableOf('a42557ca-cbb8-4442-af9c-3bb5cad2d075') + }); + + notificationsServiceStub = new NotificationsServiceStub(); + + profileClaimService = jasmine.createSpyObj('profileClaimService', { + hasProfilesToSuggest: observableOf(false), + }); + + } + + beforeEach(waitForAsync(() => { + init(); + TestBed.configureTestingModule({ + declarations: [ProfilePageResearcherFormComponent, VarDirective], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], + providers: [ + NgbModal, + { provide: ResearcherProfileService, useValue: researcherProfileService }, + { provide: NotificationsService, useValue: notificationsServiceStub }, + { provide: ProfileClaimService, useValue: profileClaimService }, + { provide: AuthService, useValue: authService } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ProfilePageResearcherFormComponent); + component = fixture.componentInstance; + component.user = user; + router = TestBed.inject(Router); + fixture.detectChanges(); + }); + + it('should search the researcher profile for the current user', () => { + expect(researcherProfileService.findById).toHaveBeenCalledWith(user.id, false, true, followLink('item')); + }); + + describe('createProfile', () => { + + it('should create the profile', () => { + component.createProfile(); + expect(researcherProfileService.create).toHaveBeenCalledWith(); + }); + + }); + + describe('toggleProfileVisibility', () => { + + describe('', () => { + + beforeEach(() => { + researcherProfileService.setVisibility.and.returnValue(createSuccessfulRemoteDataObject$(profile)); + }); + + it('should set the profile visibility to true', () => { + profile.visible = false; + component.toggleProfileVisibility(profile); + expect(researcherProfileService.setVisibility).toHaveBeenCalledWith(profile, true); + }); + + it('should set the profile visibility to false', () => { + profile.visible = true; + component.toggleProfileVisibility(profile); + expect(researcherProfileService.setVisibility).toHaveBeenCalledWith(profile, false); + }); + }); + + describe('on successful', () => { + beforeEach(() => { + researcherProfileService.setVisibility.and.returnValue(createSuccessfulRemoteDataObject$(profile)); + }); + + it('should update the profile properly', () => { + profile.visible = true; + component.toggleProfileVisibility(profile); + expect(component.researcherProfile$.value).toEqual(profile); + }); + + }); + + describe('on error', () => { + beforeEach(() => { + researcherProfileService.setVisibility.and.returnValue(createFailedRemoteDataObject$()); + }); + + it('should update the profile properly', () => { + const unchangedProfile = profile; + profile.visible = true; + component.toggleProfileVisibility(profile); + expect(component.researcherProfile$.value).toEqual(unchangedProfile); + expect((component as any).notificationService.error).toHaveBeenCalled(); + }); + + }); + }); + + describe('deleteProfile', () => { + beforeEach(() => { + const modalService = (component as any).modalService; + spyOn(modalService, 'open').and.returnValue(Object.assign({ componentInstance: Object.assign({ response: observableOf(true) }) })); + component.deleteProfile(profile); + fixture.detectChanges(); + }); + + it('should delete the profile', () => { + + expect(researcherProfileService.delete).toHaveBeenCalledWith(profile); + }); + + }); + + describe('viewProfile', () => { + + it('should open the item details page', () => { + spyOn(router, 'navigate'); + component.viewProfile(profile); + expect(router.navigate).toHaveBeenCalledWith(['items', 'a42557ca-cbb8-4442-af9c-3bb5cad2d075']); + }); + + }); + +}); diff --git a/src/app/profile-page/profile-page-researcher-form/profile-page-researcher-form.component.ts b/src/app/profile-page/profile-page-researcher-form/profile-page-researcher-form.component.ts new file mode 100644 index 0000000000..f7662e0522 --- /dev/null +++ b/src/app/profile-page/profile-page-researcher-form/profile-page-researcher-form.component.ts @@ -0,0 +1,208 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; + +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateService } from '@ngx-translate/core'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { mergeMap, switchMap, take, tap } from 'rxjs/operators'; + +import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../../core/shared/operators'; +import { ProfileClaimItemModalComponent } from '../profile-claim-item-modal/profile-claim-item-modal.component'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { AuthService } from '../../core/auth/auth.service'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { ResearcherProfile } from '../../core/profile/model/researcher-profile.model'; +import { ResearcherProfileService } from '../../core/profile/researcher-profile.service'; +import { ProfileClaimService } from '../profile-claim/profile-claim.service'; +import { RemoteData } from '../../core/data/remote-data'; +import { isNotEmpty } from '../../shared/empty.util'; +import { followLink } from '../../shared/utils/follow-link-config.model'; +import { ConfirmationModalComponent } from '../../shared/confirmation-modal/confirmation-modal.component'; + +@Component({ + selector: 'ds-profile-page-researcher-form', + templateUrl: './profile-page-researcher-form.component.html', +}) +/** + * Component for a user to create/delete or change his researcher profile. + */ +export class ProfilePageResearcherFormComponent implements OnInit { + + /** + * The user to display the form for. + */ + @Input() user: EPerson; + + /** + * The researcher profile to show. + */ + researcherProfile$: BehaviorSubject = new BehaviorSubject(null); + + /** + * A boolean representing if a delete operation is pending + * @type {BehaviorSubject} + */ + processingDelete$: BehaviorSubject = new BehaviorSubject(false); + + /** + * A boolean representing if a create delete operation is pending + * @type {BehaviorSubject} + */ + processingCreate$: BehaviorSubject = new BehaviorSubject(false); + + /** + * If exists The uuid of the item associated to the researcher profile + */ + researcherProfileItemId: string; + + constructor(protected researcherProfileService: ResearcherProfileService, + protected profileClaimService: ProfileClaimService, + protected translationService: TranslateService, + protected notificationService: NotificationsService, + protected authService: AuthService, + protected router: Router, + protected modalService: NgbModal) { + + } + + /** + * Initialize the component searching the current user researcher profile. + */ + ngOnInit(): void { + // Retrieve researcherProfile if exists + this.initResearchProfile(); + } + + /** + * Create a new profile for the current user. + */ + createProfile(): void { + this.processingCreate$.next(true); + + this.authService.getAuthenticatedUserFromStore().pipe( + take(1), + switchMap((currentUser) => this.profileClaimService.hasProfilesToSuggest(currentUser))) + .subscribe((hasProfilesToSuggest) => { + + if (hasProfilesToSuggest) { + this.processingCreate$.next(false); + const modal = this.modalService.open(ProfileClaimItemModalComponent); + modal.componentInstance.dso = this.user; + modal.componentInstance.create.pipe(take(1)).subscribe(() => { + this.createProfileFromScratch(); + }); + } else { + this.createProfileFromScratch(); + } + + }); + } + + /** + * Navigate to the items section to show the profile item details. + * + * @param researcherProfile the current researcher profile + */ + viewProfile(researcherProfile: ResearcherProfile): void { + if (this.researcherProfileItemId != null) { + this.router.navigate(['items', this.researcherProfileItemId]); + } + } + + /** + * Delete the given researcher profile. + * + * @param researcherProfile the profile to delete + */ + deleteProfile(researcherProfile: ResearcherProfile): void { + const modalRef = this.modalService.open(ConfirmationModalComponent); + modalRef.componentInstance.headerLabel = 'confirmation-modal.delete-profile.header'; + modalRef.componentInstance.infoLabel = 'confirmation-modal.delete-profile.info'; + modalRef.componentInstance.cancelLabel = 'confirmation-modal.delete-profile.cancel'; + modalRef.componentInstance.confirmLabel = 'confirmation-modal.delete-profile.confirm'; + modalRef.componentInstance.brandColor = 'danger'; + modalRef.componentInstance.confirmIcon = 'fas fa-trash'; + modalRef.componentInstance.response.pipe(take(1)).subscribe((confirm: boolean) => { + if (confirm) { + this.processingDelete$.next(true); + this.researcherProfileService.delete(researcherProfile) + .subscribe((deleted) => { + if (deleted) { + this.researcherProfile$.next(null); + this.researcherProfileItemId = null; + } + this.processingDelete$.next(false); + }); + } + }); + } + + /** + * Toggle the visibility of the given researcher profile. + * + * @param researcherProfile the profile to update + */ + toggleProfileVisibility(researcherProfile: ResearcherProfile): void { + this.researcherProfileService.setVisibility(researcherProfile, !researcherProfile.visible).pipe( + getFirstCompletedRemoteData() + ).subscribe((rd: RemoteData) => { + if (rd.hasSucceeded) { + this.researcherProfile$.next(rd.payload); + } else { + this.notificationService.error(null, this.translationService.get('researcher.profile.change-visibility.fail')); + } + }); + } + + /** + * Return a boolean representing if a delete operation is pending. + * + * @return {Observable} + */ + isProcessingDelete(): Observable { + return this.processingDelete$.asObservable(); + } + + /** + * Return a boolean representing if a create operation is pending. + * + * @return {Observable} + */ + isProcessingCreate(): Observable { + return this.processingCreate$.asObservable(); + } + + /** + * Create a new profile related to the current user from scratch. + */ + createProfileFromScratch() { + this.processingCreate$.next(true); + this.researcherProfileService.create().pipe( + getFirstCompletedRemoteData() + ).subscribe((remoteData) => { + this.processingCreate$.next(false); + if (remoteData.isSuccess) { + this.initResearchProfile(); + this.notificationService.success(null, this.translationService.get('researcher.profile.create.success')); + } else { + this.notificationService.error(null, this.translationService.get('researcher.profile.create.fail')); + } + }); + } + + /** + * Initializes the researcherProfile and researcherProfileItemId attributes using the profile of the current user. + */ + private initResearchProfile(): void { + this.researcherProfileService.findById(this.user.id, false, true, followLink('item')).pipe( + getFirstSucceededRemoteDataPayload(), + tap((researcherProfile) => this.researcherProfile$.next(researcherProfile)), + mergeMap((researcherProfile) => this.researcherProfileService.findRelatedItemId(researcherProfile)), + ).subscribe((itemId: string) => { + if (isNotEmpty(itemId)) { + this.researcherProfileItemId = itemId; + } + }); + } + +} diff --git a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.html b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.html index cdaa3ce31c..7c1dff5bdf 100644 --- a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.html +++ b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.html @@ -1,4 +1,4 @@ -
{{FORM_PREFIX + 'info' | translate}}
+{{FORM_PREFIX + 'info' | translate}}
-

{{'profile.head' | translate}}

+ +

{{'profile.head' | translate}}

+
+
{{'profile.card.researcher' | translate}}
+
+
+ +
+
+
+
{{'profile.card.identify' | translate}}
diff --git a/src/app/profile-page/profile-page.component.spec.ts b/src/app/profile-page/profile-page.component.spec.ts index 46f83c964b..6893ac2437 100644 --- a/src/app/profile-page/profile-page.component.spec.ts +++ b/src/app/profile-page/profile-page.component.spec.ts @@ -11,16 +11,18 @@ import { AuthTokenInfo } from '../core/auth/models/auth-token-info.model'; import { EPersonDataService } from '../core/eperson/eperson-data.service'; import { NotificationsService } from '../shared/notifications/notifications.service'; import { authReducer } from '../core/auth/auth.reducer'; -import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; +import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; import { createPaginatedList } from '../shared/testing/utils.test'; import { BehaviorSubject, of as observableOf } from 'rxjs'; import { AuthService } from '../core/auth/auth.service'; import { RestResponse } from '../core/cache/response.models'; import { provideMockStore } from '@ngrx/store/testing'; import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; -import { getTestScheduler } from 'jasmine-marbles'; +import { cold, getTestScheduler } from 'jasmine-marbles'; import { By } from '@angular/platform-browser'; import { EmptySpecialGroupDataMock$, SpecialGroupDataMock$ } from '../shared/testing/special-group.mock'; +import { ConfigurationDataService } from '../core/data/configuration-data.service'; +import { ConfigurationProperty } from '../core/shared/configuration-property.model'; describe('ProfilePageComponent', () => { let component: ProfilePageComponent; @@ -29,16 +31,28 @@ describe('ProfilePageComponent', () => { let initialState: any; let authService; + let authorizationService; let epersonService; let notificationsService; + let configurationService; const canChangePassword = new BehaviorSubject(true); + const validConfiguration = Object.assign(new ConfigurationProperty(), { + name: 'researcher-profile.entity-type', + values: [ + 'Person' + ] + }); + const emptyConfiguration = Object.assign(new ConfigurationProperty(), { + name: 'researcher-profile.entity-type', + values: [] + }); function init() { user = Object.assign(new EPerson(), { id: 'userId', groups: createSuccessfulRemoteDataObject$(createPaginatedList([])), - _links: {self: {href: 'test.com/uuid/1234567654321'}} + _links: { self: { href: 'test.com/uuid/1234567654321' } } }); initialState = { core: { @@ -53,7 +67,7 @@ describe('ProfilePageComponent', () => { } } }; - + authorizationService = jasmine.createSpyObj('authorizationService', { isAuthorized: canChangePassword }); authService = jasmine.createSpyObj('authService', { getAuthenticatedUserFromStore: observableOf(user), getSpecialGroupsFromAuthStatus: SpecialGroupDataMock$ @@ -67,6 +81,9 @@ describe('ProfilePageComponent', () => { error: {}, warning: {} }); + configurationService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: jasmine.createSpy('findByPropertyName') + }); } beforeEach(waitForAsync(() => { @@ -82,7 +99,8 @@ describe('ProfilePageComponent', () => { { provide: EPersonDataService, useValue: epersonService }, { provide: NotificationsService, useValue: notificationsService }, { provide: AuthService, useValue: authService }, - { provide: AuthorizationDataService, useValue: jasmine.createSpyObj('authorizationService', { isAuthorized: canChangePassword }) }, + { provide: ConfigurationDataService, useValue: configurationService }, + { provide: AuthorizationDataService, useValue: authorizationService }, provideMockStore({ initialState }), ], schemas: [NO_ERRORS_SCHEMA] @@ -92,151 +110,157 @@ describe('ProfilePageComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(ProfilePageComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); - describe('updateProfile', () => { - describe('when the metadata form returns false and the security form returns true', () => { - beforeEach(() => { - component.metadataForm = jasmine.createSpyObj('metadataForm', { - updateProfile: false + describe('', () => { + + beforeEach(() => { + configurationService.findByPropertyName.and.returnValue(createSuccessfulRemoteDataObject$(validConfiguration)); + fixture.detectChanges(); + }); + + describe('updateProfile', () => { + describe('when the metadata form returns false and the security form returns true', () => { + beforeEach(() => { + component.metadataForm = jasmine.createSpyObj('metadataForm', { + updateProfile: false + }); + spyOn(component, 'updateSecurity').and.returnValue(true); + component.updateProfile(); }); - spyOn(component, 'updateSecurity').and.returnValue(true); - component.updateProfile(); - }); - it('should not display a warning', () => { - expect(notificationsService.warning).not.toHaveBeenCalled(); - }); - }); - - describe('when the metadata form returns true and the security form returns false', () => { - beforeEach(() => { - component.metadataForm = jasmine.createSpyObj('metadataForm', { - updateProfile: true + it('should not display a warning', () => { + expect(notificationsService.warning).not.toHaveBeenCalled(); }); - component.updateProfile(); }); - it('should not display a warning', () => { - expect(notificationsService.warning).not.toHaveBeenCalled(); - }); - }); - - describe('when the metadata form returns true and the security form returns true', () => { - beforeEach(() => { - component.metadataForm = jasmine.createSpyObj('metadataForm', { - updateProfile: true + describe('when the metadata form returns true and the security form returns false', () => { + beforeEach(() => { + component.metadataForm = jasmine.createSpyObj('metadataForm', { + updateProfile: true + }); + component.updateProfile(); }); - component.updateProfile(); - }); - it('should not display a warning', () => { - expect(notificationsService.warning).not.toHaveBeenCalled(); - }); - }); - - describe('when the metadata form returns false and the security form returns false', () => { - beforeEach(() => { - component.metadataForm = jasmine.createSpyObj('metadataForm', { - updateProfile: false + it('should not display a warning', () => { + expect(notificationsService.warning).not.toHaveBeenCalled(); }); - component.updateProfile(); }); - it('should display a warning', () => { - expect(notificationsService.warning).toHaveBeenCalled(); - }); - }); - }); + describe('when the metadata form returns true and the security form returns true', () => { + beforeEach(() => { + component.metadataForm = jasmine.createSpyObj('metadataForm', { + updateProfile: true + }); + component.updateProfile(); + }); - describe('updateSecurity', () => { - describe('when no password value present', () => { - let result; - - beforeEach(() => { - component.setPasswordValue(''); - - result = component.updateSecurity(); + it('should not display a warning', () => { + expect(notificationsService.warning).not.toHaveBeenCalled(); + }); }); - it('should return false', () => { - expect(result).toEqual(false); - }); + describe('when the metadata form returns false and the security form returns false', () => { + beforeEach(() => { + component.metadataForm = jasmine.createSpyObj('metadataForm', { + updateProfile: false + }); + component.updateProfile(); + }); - it('should not call epersonService.patch', () => { - expect(epersonService.patch).not.toHaveBeenCalled(); + it('should display a warning', () => { + expect(notificationsService.warning).toHaveBeenCalled(); + }); }); }); - describe('when password is filled in, but the password is invalid', () => { - let result; + describe('updateSecurity', () => { + describe('when no password value present', () => { + let result; - beforeEach(() => { - component.setPasswordValue('test'); - component.setInvalid(true); - result = component.updateSecurity(); + beforeEach(() => { + component.setPasswordValue(''); + + result = component.updateSecurity(); + }); + + it('should return false', () => { + expect(result).toEqual(false); + }); + + it('should not call epersonService.patch', () => { + expect(epersonService.patch).not.toHaveBeenCalled(); + }); }); - it('should return true', () => { - expect(result).toEqual(true); - expect(epersonService.patch).not.toHaveBeenCalled(); + describe('when password is filled in, but the password is invalid', () => { + let result; + + beforeEach(() => { + component.setPasswordValue('test'); + component.setInvalid(true); + result = component.updateSecurity(); + }); + + it('should return true', () => { + expect(result).toEqual(true); + expect(epersonService.patch).not.toHaveBeenCalled(); + }); + }); + + describe('when password is filled in, and is valid', () => { + let result; + let operations; + + beforeEach(() => { + component.setPasswordValue('testest'); + component.setInvalid(false); + + operations = [{ op: 'add', path: '/password', value: 'testest' }]; + result = component.updateSecurity(); + }); + + it('should return true', () => { + expect(result).toEqual(true); + }); + + it('should return call epersonService.patch', () => { + expect(epersonService.patch).toHaveBeenCalledWith(user, operations); + }); }); }); - describe('when password is filled in, and is valid', () => { - let result; - let operations; + describe('canChangePassword$', () => { + describe('when the user is allowed to change their password', () => { + beforeEach(() => { + canChangePassword.next(true); + }); - beforeEach(() => { - component.setPasswordValue('testest'); - component.setInvalid(false); + it('should contain true', () => { + getTestScheduler().expectObservable(component.canChangePassword$).toBe('(a)', { a: true }); + }); - operations = [{ op: 'add', path: '/password', value: 'testest' }]; - result = component.updateSecurity(); + it('should show the security section on the page', () => { + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('.security-section'))).not.toBeNull(); + }); }); - it('should return true', () => { - expect(result).toEqual(true); - }); + describe('when the user is not allowed to change their password', () => { + beforeEach(() => { + canChangePassword.next(false); + }); - it('should return call epersonService.patch', () => { - expect(epersonService.patch).toHaveBeenCalledWith(user, operations); + it('should contain false', () => { + getTestScheduler().expectObservable(component.canChangePassword$).toBe('(a)', { a: false }); + }); + + it('should not show the security section on the page', () => { + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('.security-section'))).toBeNull(); + }); }); }); - }); - - describe('canChangePassword$', () => { - describe('when the user is allowed to change their password', () => { - beforeEach(() => { - canChangePassword.next(true); - }); - - it('should contain true', () => { - getTestScheduler().expectObservable(component.canChangePassword$).toBe('(a)', { a: true }); - }); - - it('should show the security section on the page', () => { - fixture.detectChanges(); - expect(fixture.debugElement.query(By.css('.security-section'))).not.toBeNull(); - }); - }); - - describe('when the user is not allowed to change their password', () => { - beforeEach(() => { - canChangePassword.next(false); - }); - - it('should contain false', () => { - getTestScheduler().expectObservable(component.canChangePassword$).toBe('(a)', { a: false }); - }); - - it('should not show the security section on the page', () => { - fixture.detectChanges(); - expect(fixture.debugElement.query(By.css('.security-section'))).toBeNull(); - }); - }); - }); describe('check for specialGroups', () => { it('should contains specialGroups list', () => { @@ -258,4 +282,56 @@ describe('ProfilePageComponent', () => { expect(specialGroupsEle).toBeFalsy(); }); }); + }); + + describe('isResearcherProfileEnabled', () => { + + describe('when configuration service return values', () => { + + beforeEach(() => { + configurationService.findByPropertyName.and.returnValue(createSuccessfulRemoteDataObject$(validConfiguration)); + fixture.detectChanges(); + }); + + it('should return true', () => { + const result = component.isResearcherProfileEnabled(); + const expected = cold('a', { + a: true + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('when configuration service return no values', () => { + + beforeEach(() => { + configurationService.findByPropertyName.and.returnValue(createSuccessfulRemoteDataObject$(emptyConfiguration)); + fixture.detectChanges(); + }); + + it('should return false', () => { + const result = component.isResearcherProfileEnabled(); + const expected = cold('a', { + a: false + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('when configuration service return an error', () => { + + beforeEach(() => { + configurationService.findByPropertyName.and.returnValue(createFailedRemoteDataObject$()); + fixture.detectChanges(); + }); + + it('should return false', () => { + const result = component.isResearcherProfileEnabled(); + const expected = cold('a', { + a: false + }); + expect(result).toBeObservable(expected); + }); + }); + }); }); diff --git a/src/app/profile-page/profile-page.component.ts b/src/app/profile-page/profile-page.component.ts index 7623e9e6ea..5629a1ae18 100644 --- a/src/app/profile-page/profile-page.component.ts +++ b/src/app/profile-page/profile-page.component.ts @@ -1,5 +1,5 @@ import { Component, OnInit, ViewChild } from '@angular/core'; -import { Observable } from 'rxjs'; +import { BehaviorSubject, Observable } from 'rxjs'; import { EPerson } from '../core/eperson/models/eperson.model'; import { ProfilePageMetadataFormComponent } from './profile-page-metadata-form/profile-page-metadata-form.component'; import { NotificationsService } from '../shared/notifications/notifications.service'; @@ -16,6 +16,8 @@ import { AuthService } from '../core/auth/auth.service'; import { Operation } from 'fast-json-patch'; import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; import { FeatureID } from '../core/data/feature-authorization/feature-id'; +import { ConfigurationDataService } from '../core/data/configuration-data.service'; +import { ConfigurationProperty } from '../core/shared/configuration-property.model'; @Component({ selector: 'ds-profile-page', @@ -72,11 +74,14 @@ export class ProfilePageComponent implements OnInit { private currentUser: EPerson; canChangePassword$: Observable; + isResearcherProfileEnabled$: BehaviorSubject = new BehaviorSubject(false); + constructor(private authService: AuthService, private notificationsService: NotificationsService, private translate: TranslateService, private epersonService: EPersonDataService, - private authorizationService: AuthorizationDataService) { + private authorizationService: AuthorizationDataService, + private configurationService: ConfigurationDataService) { } ngOnInit(): void { @@ -90,6 +95,12 @@ export class ProfilePageComponent implements OnInit { this.groupsRD$ = this.user$.pipe(switchMap((user: EPerson) => user.groups)); this.canChangePassword$ = this.user$.pipe(switchMap((user: EPerson) => this.authorizationService.isAuthorized(FeatureID.CanChangePassword, user._links.self.href))); this.specialGroupsRD$ = this.authService.getSpecialGroupsFromAuthStatus(); + + this.configurationService.findByPropertyName('researcher-profile.entity-type').pipe( + getFirstCompletedRemoteData() + ).subscribe((configRD: RemoteData) => { + this.isResearcherProfileEnabled$.next(configRD.hasSucceeded && configRD.payload.values.length > 0); + }); } /** @@ -165,4 +176,12 @@ export class ProfilePageComponent implements OnInit { submit() { this.updateProfile(); } + + /** + * Returns true if the researcher profile feature is enabled, false otherwise. + */ + isResearcherProfileEnabled(): Observable { + return this.isResearcherProfileEnabled$.asObservable(); + } + } diff --git a/src/app/profile-page/profile-page.module.ts b/src/app/profile-page/profile-page.module.ts index dc9595140b..0e2902de33 100644 --- a/src/app/profile-page/profile-page.module.ts +++ b/src/app/profile-page/profile-page.module.ts @@ -5,25 +5,37 @@ import { ProfilePageRoutingModule } from './profile-page-routing.module'; import { ProfilePageComponent } from './profile-page.component'; import { ProfilePageMetadataFormComponent } from './profile-page-metadata-form/profile-page-metadata-form.component'; import { ProfilePageSecurityFormComponent } from './profile-page-security-form/profile-page-security-form.component'; +import { + ProfilePageResearcherFormComponent +} from './profile-page-researcher-form/profile-page-researcher-form.component'; import { ThemedProfilePageComponent } from './themed-profile-page.component'; import { FormModule } from '../shared/form/form.module'; +import { UiSwitchModule } from 'ngx-ui-switch'; +import { ProfileClaimItemModalComponent } from './profile-claim-item-modal/profile-claim-item-modal.component'; + @NgModule({ imports: [ ProfilePageRoutingModule, CommonModule, SharedModule, - FormModule + FormModule, + UiSwitchModule ], exports: [ + ProfilePageComponent, + ThemedProfilePageComponent, + ProfilePageMetadataFormComponent, ProfilePageSecurityFormComponent, - ProfilePageMetadataFormComponent + ProfilePageResearcherFormComponent ], declarations: [ ProfilePageComponent, ThemedProfilePageComponent, + ProfileClaimItemModalComponent, ProfilePageMetadataFormComponent, - ProfilePageSecurityFormComponent + ProfilePageSecurityFormComponent, + ProfilePageResearcherFormComponent ] }) export class ProfilePageModule { diff --git a/src/app/root.module.ts b/src/app/root.module.ts index e5a8aad949..8577f0d728 100644 --- a/src/app/root.module.ts +++ b/src/app/root.module.ts @@ -40,6 +40,8 @@ import { import { PageInternalServerErrorComponent } from './page-internal-server-error/page-internal-server-error.component'; +import { ThemedPageErrorComponent } from './page-error/themed-page-error.component'; +import { PageErrorComponent } from './page-error/page-error.component'; const IMPORTS = [ CommonModule, @@ -74,7 +76,9 @@ const DECLARATIONS = [ ThemedForbiddenComponent, IdleModalComponent, ThemedPageInternalServerErrorComponent, - PageInternalServerErrorComponent + PageInternalServerErrorComponent, + ThemedPageErrorComponent, + PageErrorComponent ]; const EXPORTS = [ diff --git a/src/app/shared/comcol/comcol-page-handle/comcol-page-handle.component.html b/src/app/shared/comcol/comcol-page-handle/comcol-page-handle.component.html index b3ca75bf94..552854a0c0 100644 --- a/src/app/shared/comcol/comcol-page-handle/comcol-page-handle.component.html +++ b/src/app/shared/comcol/comcol-page-handle/comcol-page-handle.component.html @@ -1,4 +1,4 @@

{{ title | translate }}

- +
diff --git a/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.scss b/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.scss index e8b7d689a3..8b13789179 100644 --- a/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.scss +++ b/src/app/shared/dso-page/dso-page-edit-button/dso-page-edit-button.component.scss @@ -1,3 +1 @@ -.btn-dark { - background-color: var(--ds-admin-sidebar-bg); -} + diff --git a/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.html b/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.html new file mode 100644 index 0000000000..305900ae33 --- /dev/null +++ b/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.html @@ -0,0 +1,6 @@ + + diff --git a/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.scss b/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.spec.ts b/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.spec.ts new file mode 100644 index 0000000000..c70ec4b808 --- /dev/null +++ b/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.spec.ts @@ -0,0 +1,76 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +import { Item } from '../../../core/shared/item.model'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { of as observableOf } from 'rxjs'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; +import { By } from '@angular/platform-browser'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { DsoPageOrcidButtonComponent } from './dso-page-orcid-button.component'; + +describe('DsoPageOrcidButtonComponent', () => { + let component: DsoPageOrcidButtonComponent; + let fixture: ComponentFixture; + + let authorizationService: AuthorizationDataService; + let dso: DSpaceObject; + + beforeEach(waitForAsync(() => { + dso = Object.assign(new Item(), { + id: 'test-item', + _links: { + self: { href: 'test-item-selflink' } + } + }); + authorizationService = jasmine.createSpyObj('authorizationService', { + isAuthorized: observableOf(true) + }); + TestBed.configureTestingModule({ + declarations: [DsoPageOrcidButtonComponent], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NgbModule], + providers: [ + { provide: AuthorizationDataService, useValue: authorizationService } + ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DsoPageOrcidButtonComponent); + component = fixture.componentInstance; + component.dso = dso; + component.pageRoute = 'test'; + fixture.detectChanges(); + }); + + it('should check the authorization of the current user', () => { + expect(authorizationService.isAuthorized).toHaveBeenCalledWith(FeatureID.CanSynchronizeWithORCID, dso.self); + }); + + describe('when the user is authorized', () => { + beforeEach(() => { + (authorizationService.isAuthorized as jasmine.Spy).and.returnValue(observableOf(true)); + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('should render a link', () => { + const link = fixture.debugElement.query(By.css('a')); + expect(link).not.toBeNull(); + }); + }); + + describe('when the user is not authorized', () => { + beforeEach(() => { + (authorizationService.isAuthorized as jasmine.Spy).and.returnValue(observableOf(false)); + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('should not render a link', () => { + const link = fixture.debugElement.query(By.css('a')); + expect(link).toBeNull(); + }); + }); +}); diff --git a/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.ts b/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.ts new file mode 100644 index 0000000000..c345d8cbdc --- /dev/null +++ b/src/app/shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component.ts @@ -0,0 +1,39 @@ +import { Component, Input, OnInit } from '@angular/core'; + +import { BehaviorSubject } from 'rxjs'; +import { take } from 'rxjs/operators'; + +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; + +@Component({ + selector: 'ds-dso-page-orcid-button', + templateUrl: './dso-page-orcid-button.component.html', + styleUrls: ['./dso-page-orcid-button.component.scss'] +}) +export class DsoPageOrcidButtonComponent implements OnInit { + /** + * The DSpaceObject to display a button to the edit page for + */ + @Input() dso: DSpaceObject; + + /** + * The prefix of the route to the edit page (before the object's UUID, e.g. "items") + */ + @Input() pageRoute: string; + + /** + * Whether or not the current user is authorized to edit the DSpaceObject + */ + isAuthorized: BehaviorSubject = new BehaviorSubject(false); + + constructor(protected authorizationService: AuthorizationDataService) { } + + ngOnInit() { + this.authorizationService.isAuthorized(FeatureID.CanSynchronizeWithORCID, this.dso.self).pipe(take(1)).subscribe((isAuthorized: boolean) => { + this.isAuthorized.next(isAuthorized); + }); + } + +} diff --git a/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.html b/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.html new file mode 100644 index 0000000000..c4bba286bf --- /dev/null +++ b/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.html @@ -0,0 +1,7 @@ + diff --git a/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.scss b/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.spec.ts b/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.spec.ts new file mode 100644 index 0000000000..168517b47a --- /dev/null +++ b/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.spec.ts @@ -0,0 +1,186 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { of as observableOf } from 'rxjs'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; + +import { PersonPageClaimButtonComponent } from './person-page-claim-button.component'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { NotificationsService } from '../../notifications/notifications.service'; +import { NotificationsServiceStub } from '../../testing/notifications-service.stub'; +import { TranslateLoaderMock } from '../../mocks/translate-loader.mock'; +import { ResearcherProfileService } from '../../../core/profile/researcher-profile.service'; +import { RouteService } from '../../../core/services/route.service'; +import { routeServiceStub } from '../../testing/route-service.stub'; +import { Item } from '../../../core/shared/item.model'; +import { ResearcherProfile } from '../../../core/profile/model/researcher-profile.model'; +import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../remote-data.utils'; +import { getTestScheduler } from 'jasmine-marbles'; +import { TestScheduler } from 'rxjs/testing'; + +describe('PersonPageClaimButtonComponent', () => { + let scheduler: TestScheduler; + let component: PersonPageClaimButtonComponent; + let fixture: ComponentFixture; + + const mockItem: Item = Object.assign(new Item(), { + metadata: { + 'person.email': [ + { + language: 'en_US', + value: 'fake@email.com' + } + ], + 'person.birthDate': [ + { + language: 'en_US', + value: '1993' + } + ], + 'person.jobTitle': [ + { + language: 'en_US', + value: 'Developer' + } + ], + 'person.familyName': [ + { + language: 'en_US', + value: 'Doe' + } + ], + 'person.givenName': [ + { + language: 'en_US', + value: 'John' + } + ] + }, + _links: { + self: { + href: 'item-href' + } + } + }); + + const mockResearcherProfile: ResearcherProfile = Object.assign(new ResearcherProfile(), { + id: 'test-id', + visible: true, + type: 'profile', + _links: { + item: { + href: 'https://rest.api/rest/api/profiles/test-id/item' + }, + self: { + href: 'https://rest.api/rest/api/profiles/test-id' + }, + } + }); + + const notificationsService = new NotificationsServiceStub(); + + const authorizationDataService = jasmine.createSpyObj('authorizationDataService', { + isAuthorized: jasmine.createSpy('isAuthorized') + }); + + const researcherProfileService = jasmine.createSpyObj('researcherProfileService', { + createFromExternalSource: jasmine.createSpy('createFromExternalSource'), + findRelatedItemId: jasmine.createSpy('findRelatedItemId'), + }); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }) + ], + declarations: [PersonPageClaimButtonComponent], + providers: [ + { provide: AuthorizationDataService, useValue: authorizationDataService }, + { provide: NotificationsService, useValue: notificationsService }, + { provide: ResearcherProfileService, useValue: researcherProfileService }, + { provide: RouteService, useValue: routeServiceStub }, + ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(PersonPageClaimButtonComponent); + component = fixture.componentInstance; + component.object = mockItem; + }); + + describe('when item can be claimed', () => { + beforeEach(() => { + authorizationDataService.isAuthorized.and.returnValue(observableOf(true)); + researcherProfileService.createFromExternalSource.calls.reset(); + researcherProfileService.findRelatedItemId.calls.reset(); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should create claim button', () => { + const btn = fixture.debugElement.query(By.css('[data-test="item-claim"]')); + expect(btn).toBeTruthy(); + }); + + describe('claim', () => { + describe('when successfully', () => { + beforeEach(() => { + scheduler = getTestScheduler(); + researcherProfileService.createFromExternalSource.and.returnValue(createSuccessfulRemoteDataObject$(mockResearcherProfile)); + researcherProfileService.findRelatedItemId.and.returnValue(observableOf('test-id')); + }); + + it('should display success notification', () => { + scheduler.schedule(() => component.claim()); + scheduler.flush(); + + expect(researcherProfileService.findRelatedItemId).toHaveBeenCalled(); + expect(notificationsService.success).toHaveBeenCalled(); + }); + }); + + describe('when not successfully', () => { + beforeEach(() => { + scheduler = getTestScheduler(); + researcherProfileService.createFromExternalSource.and.returnValue(createFailedRemoteDataObject$()); + }); + + it('should display success notification', () => { + scheduler.schedule(() => component.claim()); + scheduler.flush(); + + expect(researcherProfileService.findRelatedItemId).not.toHaveBeenCalled(); + expect(notificationsService.error).toHaveBeenCalled(); + }); + }); + }); + + }); + + describe('when item cannot be claimed', () => { + beforeEach(() => { + authorizationDataService.isAuthorized.and.returnValue(observableOf(false)); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should create claim button', () => { + const btn = fixture.debugElement.query(By.css('[data-test="item-claim"]')); + expect(btn).toBeFalsy(); + }); + + }); +}); diff --git a/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.ts b/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.ts new file mode 100644 index 0000000000..903b9d3679 --- /dev/null +++ b/src/app/shared/dso-page/person-page-claim-button/person-page-claim-button.component.ts @@ -0,0 +1,84 @@ +import { Component, Input, OnInit } from '@angular/core'; + +import { BehaviorSubject, Observable, of as observableOf } from 'rxjs'; +import { mergeMap, take } from 'rxjs/operators'; +import { TranslateService } from '@ngx-translate/core'; + +import { RouteService } from '../../../core/services/route.service'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { NotificationsService } from '../../notifications/notifications.service'; +import { ResearcherProfileService } from '../../../core/profile/researcher-profile.service'; +import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; +import { getFirstCompletedRemoteData } from '../../../core/shared/operators'; +import { RemoteData } from '../../../core/data/remote-data'; +import { ResearcherProfile } from '../../../core/profile/model/researcher-profile.model'; +import { isNotEmpty } from '../../empty.util'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; + +@Component({ + selector: 'ds-person-page-claim-button', + templateUrl: './person-page-claim-button.component.html', + styleUrls: ['./person-page-claim-button.component.scss'] +}) +export class PersonPageClaimButtonComponent implements OnInit { + + /** + * The target person item to claim + */ + @Input() object: DSpaceObject; + + /** + * A boolean representing if item can be claimed or not + */ + claimable$: BehaviorSubject = new BehaviorSubject(false); + + constructor(protected routeService: RouteService, + protected authorizationService: AuthorizationDataService, + protected notificationsService: NotificationsService, + protected translate: TranslateService, + protected researcherProfileService: ResearcherProfileService) { + } + + ngOnInit(): void { + this.authorizationService.isAuthorized(FeatureID.CanClaimItem, this.object._links.self.href, null, false).pipe( + take(1) + ).subscribe((isAuthorized: boolean) => { + this.claimable$.next(isAuthorized); + }); + + } + + /** + * Create a new researcher profile claiming the current item. + */ + claim() { + this.researcherProfileService.createFromExternalSource(this.object._links.self.href).pipe( + getFirstCompletedRemoteData(), + mergeMap((rd: RemoteData) => { + if (rd.hasSucceeded) { + return this.researcherProfileService.findRelatedItemId(rd.payload); + } else { + return observableOf(null); + } + })) + .subscribe((id: string) => { + if (isNotEmpty(id)) { + this.notificationsService.success(this.translate.get('researcherprofile.success.claim.title'), + this.translate.get('researcherprofile.success.claim.body')); + this.claimable$.next(false); + } else { + this.notificationsService.error( + this.translate.get('researcherprofile.error.claim.title'), + this.translate.get('researcherprofile.error.claim.body')); + } + }); + } + + /** + * Returns true if the item is claimable, false otherwise. + */ + isClaimable(): Observable { + return this.claimable$; + } + +} diff --git a/src/app/shared/handle.service.spec.ts b/src/app/shared/handle.service.spec.ts new file mode 100644 index 0000000000..b326eb0416 --- /dev/null +++ b/src/app/shared/handle.service.spec.ts @@ -0,0 +1,47 @@ +import { HandleService } from './handle.service'; + +describe('HandleService', () => { + let service: HandleService; + + beforeEach(() => { + service = new HandleService(); + }); + + describe(`normalizeHandle`, () => { + it(`should simply return an already normalized handle`, () => { + let input, output; + + input = '123456789/123456'; + output = service.normalizeHandle(input); + expect(output).toEqual(input); + + input = '12.3456.789/123456'; + output = service.normalizeHandle(input); + expect(output).toEqual(input); + }); + + it(`should normalize a handle url`, () => { + let input, output; + + input = 'https://hdl.handle.net/handle/123456789/123456'; + output = service.normalizeHandle(input); + expect(output).toEqual('123456789/123456'); + + input = 'https://rest.api/server/handle/123456789/123456'; + output = service.normalizeHandle(input); + expect(output).toEqual('123456789/123456'); + }); + + it(`should return null if the input doesn't contain a handle`, () => { + let input, output; + + input = 'https://hdl.handle.net/handle/123456789'; + output = service.normalizeHandle(input); + expect(output).toBeNull(); + + input = 'something completely different'; + output = service.normalizeHandle(input); + expect(output).toBeNull(); + }); + }); +}); diff --git a/src/app/shared/handle.service.ts b/src/app/shared/handle.service.ts new file mode 100644 index 0000000000..da0f17f7de --- /dev/null +++ b/src/app/shared/handle.service.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@angular/core'; +import { isNotEmpty, isEmpty } from './empty.util'; + +const PREFIX_REGEX = /handle\/([^\/]+\/[^\/]+)$/; +const NO_PREFIX_REGEX = /^([^\/]+\/[^\/]+)$/; + +@Injectable({ + providedIn: 'root' +}) +export class HandleService { + + + /** + * Turns a handle string into the default 123456789/12345 format + * + * @param handle the input handle + * + * normalizeHandle('123456789/123456') // '123456789/123456' + * normalizeHandle('12.3456.789/123456') // '12.3456.789/123456' + * normalizeHandle('https://hdl.handle.net/handle/123456789/123456') // '123456789/123456' + * normalizeHandle('https://rest.api/server/handle/123456789/123456') // '123456789/123456' + * normalizeHandle('https://rest.api/server/handle/123456789') // null + */ + normalizeHandle(handle: string): string { + let matches: string[]; + if (isNotEmpty(handle)) { + matches = handle.match(PREFIX_REGEX); + } + + if (isEmpty(matches) || matches.length < 2) { + matches = handle.match(NO_PREFIX_REGEX); + } + + if (isEmpty(matches) || matches.length < 2) { + return null; + } else { + return matches[1]; + } + } + +} diff --git a/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.html b/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.html index 3b150a46c9..726dc9ca0e 100644 --- a/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.html +++ b/src/app/shared/input-suggestions/filter-suggestions/filter-input-suggestions.component.html @@ -27,7 +27,7 @@