Merge branch 'main' into CST-7755-refactoring

# Conflicts:
#	src/app/core/core.module.ts
#	src/app/shared/shared.module.ts
This commit is contained in:
Giuseppe Digilio
2023-02-10 19:52:55 +01:00
224 changed files with 5670 additions and 1622 deletions

View File

@@ -32,12 +32,60 @@ cache:
# NOTE: how long should objects be cached for by default
msToLive:
default: 900000 # 15 minutes
control: max-age=60 # revalidate browser
# Default 'Cache-Control' HTTP Header to set for all static content (including compiled *.js files)
# Defaults to max-age=604,800 seconds (one week). This lets a user's browser know that it can cache these
# files for one week, after which they will be "stale" and need to be redownloaded.
# NOTE: When updates are made to compiled *.js files, it will automatically bypass this browser cache, because
# all compiled *.js files include a unique hash in their name which updates when content is modified.
control: max-age=604800 # revalidate browser
autoSync:
defaultTime: 0
maxBufferSize: 100
timePerMethod:
PATCH: 3 # time in seconds
# In-memory cache(s) of server-side rendered pages. These caches will store the most recently accessed public pages.
# Pages are automatically added/dropped from these caches based on how recently they have been used.
# Restarting the app clears all page caches.
# NOTE: To control the cache size, use the "max" setting. Keep in mind, individual cached pages are usually small (<100KB).
# Enabling *both* caches will mean that a page may be cached twice, once in each cache (but may expire at different times via timeToLive).
serverSide:
# Set to true to see all cache hits/misses/refreshes in your console logs. Useful for debugging SSR caching issues.
debug: false
# When enabled (i.e. max > 0), known bots will be sent pages from a server side cache specific for bots.
# (Keep in mind, bot detection cannot be guarranteed. It is possible some bots will bypass this cache.)
botCache:
# Maximum number of pages to cache for known bots. Set to zero (0) to disable server side caching for bots.
# Default is 1000, which means the 1000 most recently accessed public pages will be cached.
# As all pages are cached in server memory, increasing this value will increase memory needs.
# Individual cached pages are usually small (<100KB), so max=1000 should only require ~100MB of memory.
max: 1000
# Amount of time after which cached pages are considered stale (in ms). After becoming stale, the cached
# copy is automatically refreshed on the next request.
# NOTE: For the bot cache, this setting may impact how quickly search engine bots will index new content on your site.
# For example, setting this to one week may mean that search engine bots may not find all new content for one week.
timeToLive: 86400000 # 1 day
# When set to true, after timeToLive expires, the next request will receive the *cached* page & then re-render the page
# behind the scenes to update the cache. This ensures users primarily interact with the cache, but may receive stale pages (older than timeToLive).
# When set to false, after timeToLive expires, the next request will wait on SSR to complete & receive a fresh page (which is then saved to cache).
# This ensures stale pages (older than timeToLive) are never returned from the cache, but some users will wait on SSR.
allowStale: true
# When enabled (i.e. max > 0), all anonymous users will be sent pages from a server side cache.
# This allows anonymous users to interact more quickly with the site, but also means they may see slightly
# outdated content (based on timeToLive)
anonymousCache:
# Maximum number of pages to cache. Default is zero (0) which means anonymous user cache is disabled.
# As all pages are cached in server memory, increasing this value will increase memory needs.
# Individual cached pages are usually small (<100KB), so a value of max=1000 would only require ~100MB of memory.
max: 0
# Amount of time after which cached pages are considered stale (in ms). After becoming stale, the cached
# copy is automatically refreshed on the next request.
# NOTE: For the anonymous cache, it is recommended to keep this value low to avoid anonymous users seeing outdated content.
timeToLive: 10000 # 10 seconds
# When set to true, after timeToLive expires, the next request will receive the *cached* page & then re-render the page
# behind the scenes to update the cache. This ensures users primarily interact with the cache, but may receive stale pages (older than timeToLive).
# When set to false, after timeToLive expires, the next request will wait on SSR to complete & receive a fresh page (which is then saved to cache).
# This ensures stale pages (older than timeToLive) are never returned from the cache, but some users will wait on SSR.
allowStale: true
# Authentication settings
auth:

View File

@@ -99,6 +99,7 @@
"fast-json-patch": "^3.0.0-1",
"filesize": "^6.1.0",
"http-proxy-middleware": "^1.0.5",
"isbot": "^3.6.5",
"js-cookie": "2.2.1",
"js-yaml": "^4.1.0",
"json5": "^2.2.2",
@@ -106,6 +107,7 @@
"jwt-decode": "^3.1.2",
"klaro": "^0.7.18",
"lodash": "^4.17.21",
"lru-cache": "^7.14.1",
"markdown-it": "^13.0.1",
"markdown-it-mathjax3": "^4.3.1",
"mirador": "^3.3.0",

306
server.ts
View File

@@ -28,6 +28,8 @@ import * as expressStaticGzip from 'express-static-gzip';
/* eslint-enable import/no-namespace */
import axios from 'axios';
import LRU from 'lru-cache';
import isbot from 'isbot';
import { createCertificate } from 'pem';
import { createServer } from 'https';
import { json } from 'body-parser';
@@ -35,7 +37,6 @@ import { json } from 'body-parser';
import { existsSync, readFileSync } from 'fs';
import { join } from 'path';
import { APP_BASE_HREF } from '@angular/common';
import { enableProdMode } from '@angular/core';
import { ngExpressEngine } from '@nguniversal/express-engine';
@@ -53,6 +54,8 @@ import { buildAppConfig } from './src/config/config.server';
import { APP_CONFIG, AppConfig } from './src/config/app-config.interface';
import { extendEnvironmentWithAppConfig } from './src/config/config.util';
import { logStartupMessage } from './startup-message';
import { TOKENITEM } from 'src/app/core/auth/models/auth-token-info.model';
/*
* Set path for the browser application's dist folder
@@ -61,12 +64,18 @@ const DIST_FOLDER = join(process.cwd(), 'dist/browser');
// Set path fir IIIF viewer.
const IIIF_VIEWER = join(process.cwd(), 'dist/iiif');
const indexHtml = existsSync(join(DIST_FOLDER, 'index.html')) ? 'index.html' : 'index';
const indexHtml = join(DIST_FOLDER, 'index.html');
const cookieParser = require('cookie-parser');
const appConfig: AppConfig = buildAppConfig(join(DIST_FOLDER, 'assets/config.json'));
// cache of SSR pages for known bots, only enabled in production mode
let botCache: LRU<string, any>;
// cache of SSR pages for anonymous users. Disabled by default, and only available in production mode
let anonymousCache: LRU<string, any>;
// extend environment with app config for server
extendEnvironmentWithAppConfig(environment, appConfig);
@@ -87,10 +96,12 @@ export function app() {
/*
* If production mode is enabled in the environment file:
* - Enable Angular's production mode
* - Initialize caching of SSR rendered pages (if enabled in config.yml)
* - Enable compression for SSR reponses. See [compression](https://github.com/expressjs/compression)
*/
if (environment.production) {
enableProdMode();
initCache();
server.use(compression({
// only compress responses we've marked as SSR
// otherwise, this middleware may compress files we've chosen not to compress via compression-webpack-plugin
@@ -106,13 +117,13 @@ export function app() {
/*
* Add cookie parser middleware
* See [morgan](https://github.com/expressjs/cookie-parser)
* See [cookie-parser](https://github.com/expressjs/cookie-parser)
*/
server.use(cookieParser());
/*
* Add parser for request bodies
* See [morgan](https://github.com/expressjs/body-parser)
* Add JSON parser for request bodies
* See [body-parser](https://github.com/expressjs/body-parser)
*/
server.use(json());
@@ -186,7 +197,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)
*/
router.get('*.*', cacheControl, expressStaticGzip(DIST_FOLDER, {
router.get('*.*', addCacheControl, expressStaticGzip(DIST_FOLDER, {
index: false,
enableBrotli: true,
orderPreference: ['br', 'gzip'],
@@ -202,8 +213,11 @@ export function app() {
*/
server.get('/app/health', healthCheck);
// Register the ngApp callback function to handle incoming requests
router.get('*', ngApp);
/**
* Default sending all incoming requests to ngApp() function, after first checking for a cached
* copy of the page (see cacheCheck())
*/
router.get('*', cacheCheck, ngApp);
server.use(environment.ui.nameSpace, router);
@@ -215,60 +229,242 @@ export function app() {
*/
function ngApp(req, res) {
if (environment.universal.preboot) {
res.render(indexHtml, {
req,
res,
preboot: environment.universal.preboot,
async: environment.universal.async,
time: environment.universal.time,
baseUrl: environment.ui.nameSpace,
originUrl: environment.ui.baseUrl,
requestUrl: req.originalUrl,
providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }]
}, (err, data) => {
if (hasNoValue(err) && hasValue(data)) {
res.locals.ssr = true; // mark response as SSR
res.send(data);
} else if (hasValue(err) && err.code === 'ERR_HTTP_HEADERS_SENT') {
// When this error occurs we can't fall back to CSR because the response has already been
// sent. These errors occur for various reasons in universal, not all of which are in our
// control to solve.
console.warn('Warning [ERR_HTTP_HEADERS_SENT]: Tried to set headers after they were sent to the client');
} else {
console.warn('Error in SSR, serving for direct CSR.');
if (hasValue(err)) {
console.warn('Error details : ', err);
}
res.render(indexHtml, {
req,
providers: [{
provide: APP_BASE_HREF,
useValue: req.baseUrl
}]
});
}
});
// Render the page to user via SSR (server side rendering)
serverSideRender(req, res);
} else {
// If preboot is disabled, just serve the client
console.log('Universal off, serving for direct CSR');
res.render(indexHtml, {
req,
providers: [{
provide: APP_BASE_HREF,
useValue: req.baseUrl
}]
console.log('Universal off, serving for direct client-side rendering (CSR)');
clientSideRender(req, res);
}
}
/**
* Render page content on server side using Angular SSR. By default this page content is
* returned to the user.
* @param req current request
* @param res current response
* @param sendToUser if true (default), send the rendered content to the user.
* If false, then only save this rendered content to the in-memory cache (to refresh cache).
*/
function serverSideRender(req, res, sendToUser: boolean = true) {
// Render the page via SSR (server side rendering)
res.render(indexHtml, {
req,
res,
preboot: environment.universal.preboot,
async: environment.universal.async,
time: environment.universal.time,
baseUrl: environment.ui.nameSpace,
originUrl: environment.ui.baseUrl,
requestUrl: req.originalUrl,
}, (err, data) => {
if (hasNoValue(err) && hasValue(data)) {
// save server side rendered page to cache (if any are enabled)
saveToCache(req, data);
if (sendToUser) {
res.locals.ssr = true; // mark response as SSR (enables text compression)
// send rendered page to user
res.send(data);
}
} else if (hasValue(err) && err.code === 'ERR_HTTP_HEADERS_SENT') {
// When this error occurs we can't fall back to CSR because the response has already been
// sent. These errors occur for various reasons in universal, not all of which are in our
// control to solve.
console.warn('Warning [ERR_HTTP_HEADERS_SENT]: Tried to set headers after they were sent to the client');
} else {
console.warn('Error in server-side rendering (SSR)');
if (hasValue(err)) {
console.warn('Error details : ', err);
}
if (sendToUser) {
console.warn('Falling back to serving direct client-side rendering (CSR).');
clientSideRender(req, res);
}
}
});
}
/**
* Send back response to user to trigger direct client-side rendering (CSR)
* @param req current request
* @param res current response
*/
function clientSideRender(req, res) {
res.sendFile(indexHtml);
}
/*
* Adds a Cache-Control HTTP header to the response.
* The cache control value can be configured in the config.*.yml file
* Defaults to max-age=604,800 seconds (1 week)
*/
function addCacheControl(req, res, next) {
// instruct browser to revalidate
res.header('Cache-Control', environment.cache.control || 'max-age=604800');
next();
}
/*
* Initialize server-side caching of pages rendered via SSR.
*/
function initCache() {
if (botCacheEnabled()) {
// Initialize a new "least-recently-used" item cache (where least recently used pages are removed first)
// See https://www.npmjs.com/package/lru-cache
// When enabled, each page defaults to expiring after 1 day
botCache = new LRU( {
max: environment.cache.serverSide.botCache.max,
ttl: environment.cache.serverSide.botCache.timeToLive || 24 * 60 * 60 * 1000, // 1 day
allowStale: environment.cache.serverSide.botCache.allowStale ?? true // if object is stale, return stale value before deleting
});
}
if (anonymousCacheEnabled()) {
// NOTE: While caches may share SSR pages, this cache must be kept separately because the timeToLive
// may expire pages more frequently.
// When enabled, each page defaults to expiring after 10 seconds (to minimize anonymous users seeing out-of-date content)
anonymousCache = new LRU( {
max: environment.cache.serverSide.anonymousCache.max,
ttl: environment.cache.serverSide.anonymousCache.timeToLive || 10 * 1000, // 10 seconds
allowStale: environment.cache.serverSide.anonymousCache.allowStale ?? true // if object is stale, return stale value before deleting
});
}
}
/*
* Adds a cache control header to the response
* The cache control value can be configured in the environments file and defaults to max-age=60
/**
* Return whether bot-specific server side caching is enabled in configuration.
*/
function cacheControl(req, res, next) {
// instruct browser to revalidate
res.header('Cache-Control', environment.cache.control || 'max-age=60');
next();
function botCacheEnabled(): boolean {
// Caching is only enabled if SSR is enabled AND
// "max" pages to cache is greater than zero
return environment.universal.preboot && environment.cache.serverSide.botCache.max && (environment.cache.serverSide.botCache.max > 0);
}
/**
* Return whether anonymous user server side caching is enabled in configuration.
*/
function anonymousCacheEnabled(): boolean {
// Caching is only enabled if SSR is enabled AND
// "max" pages to cache is greater than zero
return environment.universal.preboot && environment.cache.serverSide.anonymousCache.max && (environment.cache.serverSide.anonymousCache.max > 0);
}
/**
* Check if the currently requested page is in our server-side, in-memory cache.
* Caching is ONLY done for SSR requests. Pages are cached base on their path (e.g. /home or /search?query=test)
*/
function cacheCheck(req, res, next) {
// Cached copy of page (if found)
let cachedCopy;
// If the bot cache is enabled and this request looks like a bot, check the bot cache for a cached page.
if (botCacheEnabled() && isbot(req.get('user-agent'))) {
cachedCopy = checkCacheForRequest('bot', botCache, req, res);
} else if (anonymousCacheEnabled() && !isUserAuthenticated(req)) {
cachedCopy = checkCacheForRequest('anonymous', anonymousCache, req, res);
}
// If cached copy exists, return it to the user.
if (cachedCopy) {
res.locals.ssr = true; // mark response as SSR-generated (enables text compression)
res.send(cachedCopy);
// Tell Express to skip all other handlers for this path
// This ensures we don't try to re-render the page since we've already returned the cached copy
next('router');
} else {
// If nothing found in cache, just continue with next handler
// (This should send the request on to the handler that rerenders the page via SSR
next();
}
}
/**
* Checks if the current request (i.e. page) is found in the given cache. If it is found,
* the cached copy is returned. When found, this method also triggers a re-render via
* SSR if the cached copy is now expired (i.e. timeToLive has passed for this cached copy).
* @param cacheName name of cache (just useful for debug logging)
* @param cache LRU cache to check
* @param req current request to look for in the cache
* @param res current response
* @returns cached copy (if found) or undefined (if not found)
*/
function checkCacheForRequest(cacheName: string, cache: LRU<string, any>, req, res): any {
// Get the cache key for this request
const key = getCacheKey(req);
// Check if this page is in our cache
let cachedCopy = cache.get(key);
if (cachedCopy) {
if (environment.cache.serverSide.debug) { console.log(`CACHE HIT FOR ${key} in ${cacheName} cache`); }
// Check if cached copy is expired (If expired, the key will now be gone from cache)
// NOTE: This will only occur when "allowStale=true", as it means the "get(key)" above returned a stale value.
if (!cache.has(key)) {
if (environment.cache.serverSide.debug) { console.log(`CACHE EXPIRED FOR ${key} in ${cacheName} cache. Re-rendering...`); }
// Update cached copy by rerendering server-side
// NOTE: In this scenario the currently cached copy will be returned to the current user.
// This re-render is peformed behind the scenes to update cached copy for next user.
serverSideRender(req, res, false);
}
} else {
if (environment.cache.serverSide.debug) { console.log(`CACHE MISS FOR ${key} in ${cacheName} cache.`); }
}
// return page from cache
return cachedCopy;
}
/**
* Create a cache key from the current request.
* The cache key is the URL path (NOTE: this key will also include any querystring params).
* E.g. "/home" or "/search?query=test"
* @param req current request
* @returns cache key to use for this page
*/
function getCacheKey(req): string {
// NOTE: this will return the URL path *without* any baseUrl
return req.url;
}
/**
* Save page to server side cache(s), if enabled. If caching is not enabled or a user is authenticated, this is a noop
* If multiple caches are enabled, the page will be saved to any caches where it does not yet exist (or is expired).
* (This minimizes the number of times we need to run SSR on the same page.)
* @param req current page request
* @param page page data to save to cache
*/
function saveToCache(req, page: any) {
// Only cache if no one is currently authenticated. This means ONLY public pages can be cached.
// NOTE: It's not safe to save page data to the cache when a user is authenticated. In that situation,
// the page may include sensitive or user-specific materials. As the cache is shared across all users, it can only contain public info.
if (!isUserAuthenticated(req)) {
const key = getCacheKey(req);
// Avoid caching "/reload/[random]" paths (these are hard refreshes after logout)
if (key.startsWith('/reload')) { return; }
// If bot cache is enabled, save it to that cache if it doesn't exist or is expired
// (NOTE: has() will return false if page is expired in cache)
if (botCacheEnabled() && !botCache.has(key)) {
botCache.set(key, page);
if (environment.cache.serverSide.debug) { console.log(`CACHE SAVE FOR ${key} in bot cache.`); }
}
// If anonymous cache is enabled, save it to that cache if it doesn't exist or is expired
if (anonymousCacheEnabled() && !anonymousCache.has(key)) {
anonymousCache.set(key, page);
if (environment.cache.serverSide.debug) { console.log(`CACHE SAVE FOR ${key} in anonymous cache.`); }
}
}
}
/**
* Whether a user is authenticated or not
*/
function isUserAuthenticated(req): boolean {
// Check whether our DSpace authentication Cookie exists or not
return req.cookies[TOKENITEM];
}
/*

View File

@@ -47,6 +47,12 @@ import { BatchImportPageComponent } from './admin-import-batch-page/batch-import
component: BatchImportPageComponent,
data: { title: 'admin.batch-import.title', breadcrumbKey: 'admin.batch-import' }
},
{
path: 'system-wide-alert',
resolve: { breadcrumb: I18nBreadcrumbResolver },
loadChildren: () => import('../system-wide-alert/system-wide-alert.module').then((m) => m.SystemWideAlertModule),
data: {title: 'admin.system-wide-alert.title', breadcrumbKey: 'admin.system-wide-alert'}
},
])
],
providers: [

View File

@@ -1,7 +1,7 @@
<div class="sidebar-section">
<a class="nav-item nav-link d-flex flex-row flex-nowrap"
[ngClass]="{ disabled: !hasLink }"
[attr.aria-disabled]="!hasLink"
[ngClass]="{ disabled: isDisabled }"
[attr.aria-disabled]="isDisabled"
[attr.aria-labelledby]="'sidebarName-' + section.id"
[title]="('menu.section.icon.' + section.id) | translate"
[routerLink]="itemModel.link"

View File

@@ -17,38 +17,86 @@ describe('AdminSidebarSectionComponent', () => {
const menuService = new MenuServiceStub();
const iconString = 'test';
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [NoopAnimationsModule, RouterTestingModule, TranslateModule.forRoot()],
declarations: [AdminSidebarSectionComponent, TestComponent],
providers: [
{ provide: 'sectionDataProvider', useValue: { model: { link: 'google.com' }, icon: iconString } },
{ provide: MenuService, useValue: menuService },
{ provide: CSSVariableService, useClass: CSSVariableServiceStub },
]
}).overrideComponent(AdminSidebarSectionComponent, {
set: {
entryComponents: [TestComponent]
}
})
.compileComponents();
}));
describe('when not disabled', () => {
beforeEach(() => {
fixture = TestBed.createComponent(AdminSidebarSectionComponent);
component = fixture.componentInstance;
spyOn(component as any, 'getMenuItemComponent').and.returnValue(TestComponent);
fixture.detectChanges();
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [NoopAnimationsModule, RouterTestingModule, TranslateModule.forRoot()],
declarations: [AdminSidebarSectionComponent, TestComponent],
providers: [
{provide: 'sectionDataProvider', useValue: {model: {link: 'google.com'}, icon: iconString}},
{provide: MenuService, useValue: menuService},
{provide: CSSVariableService, useClass: CSSVariableServiceStub},
]
}).overrideComponent(AdminSidebarSectionComponent, {
set: {
entryComponents: [TestComponent]
}
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(AdminSidebarSectionComponent);
component = fixture.componentInstance;
spyOn(component as any, 'getMenuItemComponent').and.returnValue(TestComponent);
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should set the right icon', () => {
const icon = fixture.debugElement.query(By.css('.shortcut-icon')).query(By.css('i.fas'));
expect(icon.nativeElement.getAttribute('class')).toContain('fa-' + iconString);
});
it('should not contain the disabled class', () => {
const disabled = fixture.debugElement.query(By.css('.disabled'));
expect(disabled).toBeFalsy();
});
});
describe('when disabled', () => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [NoopAnimationsModule, RouterTestingModule, TranslateModule.forRoot()],
declarations: [AdminSidebarSectionComponent, TestComponent],
providers: [
{provide: 'sectionDataProvider', useValue: {model: {link: 'google.com', disabled: true}, icon: iconString}},
{provide: MenuService, useValue: menuService},
{provide: CSSVariableService, useClass: CSSVariableServiceStub},
]
}).overrideComponent(AdminSidebarSectionComponent, {
set: {
entryComponents: [TestComponent]
}
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(AdminSidebarSectionComponent);
component = fixture.componentInstance;
spyOn(component as any, 'getMenuItemComponent').and.returnValue(TestComponent);
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should set the right icon', () => {
const icon = fixture.debugElement.query(By.css('.shortcut-icon')).query(By.css('i.fas'));
expect(icon.nativeElement.getAttribute('class')).toContain('fa-' + iconString);
});
it('should contain the disabled class', () => {
const disabled = fixture.debugElement.query(By.css('.disabled'));
expect(disabled).toBeTruthy();
});
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should set the right icon', () => {
const icon = fixture.debugElement.query(By.css('.shortcut-icon')).query(By.css('i.fas'));
expect(icon.nativeElement.getAttribute('class')).toContain('fa-' + iconString);
});
});
// declare a test component

View File

@@ -5,7 +5,7 @@ import { rendersSectionForMenu } from '../../../shared/menu/menu-section.decorat
import { LinkMenuItemModel } from '../../../shared/menu/menu-item/models/link.model';
import { MenuSection } from '../../../shared/menu/menu-section.model';
import { MenuID } from '../../../shared/menu/menu-id.model';
import { isNotEmpty } from '../../../shared/empty.util';
import { isEmpty } from '../../../shared/empty.util';
import { Router } from '@angular/router';
/**
@@ -26,7 +26,12 @@ export class AdminSidebarSectionComponent extends MenuSectionComponent implement
*/
menuID: MenuID = MenuID.ADMIN;
itemModel;
hasLink: boolean;
/**
* Boolean to indicate whether this section is disabled
*/
isDisabled: boolean;
constructor(
@Inject('sectionDataProvider') menuSection: MenuSection,
protected menuService: MenuService,
@@ -38,13 +43,13 @@ export class AdminSidebarSectionComponent extends MenuSectionComponent implement
}
ngOnInit(): void {
this.hasLink = isNotEmpty(this.itemModel?.link);
this.isDisabled = this.itemModel?.disabled || isEmpty(this.itemModel?.link);
super.ngOnInit();
}
navigate(event: any): void {
event.preventDefault();
if (this.hasLink) {
if (!this.isDisabled) {
this.router.navigate(this.itemModel.link);
}
}

View File

@@ -7,6 +7,7 @@
[attr.aria-labelledby]="'sidebarName-' + section.id"
[attr.aria-expanded]="expanded | async"
[title]="('menu.section.icon.' + section.id) | translate"
[class.disabled]="section.model?.disabled"
(click)="toggleSection($event)"
(keyup.space)="toggleSection($event)"
(keyup.enter)="toggleSection($event)"

View File

@@ -23,7 +23,7 @@ describe('ExpandableAdminSidebarSectionComponent', () => {
imports: [NoopAnimationsModule, TranslateModule.forRoot()],
declarations: [ExpandableAdminSidebarSectionComponent, TestComponent],
providers: [
{ provide: 'sectionDataProvider', useValue: { icon: iconString } },
{ provide: 'sectionDataProvider', useValue: { icon: iconString, model: {} } },
{ provide: MenuService, useValue: menuService },
{ provide: CSSVariableService, useClass: CSSVariableServiceStub },
{ provide: Router, useValue: new RouterStub() },

View File

@@ -1,7 +1,9 @@
<div class="container">
<ng-container *ngVar="(parent$ | async) as parent">
<ng-container *ngIf="parent?.payload as parentContext">
<header class="comcol-header border-bottom mb-4 pb-4">
<div class="d-flex flex-row border-bottom mb-4 pb-4">
<header class="comcol-header mr-auto">
<!-- Parent Name -->
<ds-comcol-page-header [name]="parentContext.name">
</ds-comcol-page-header>
@@ -22,6 +24,8 @@
<ds-comcol-page-content [content]="parentContext.sidebarText" [hasInnerHtml]="true" [title]="'community.page.news'">
</ds-comcol-page-content>
</header>
<ds-dso-edit-menu></ds-dso-edit-menu>
</div>
<!-- Browse-By Links -->
<ds-themed-comcol-page-browse-by [id]="parentContext.id" [contentType]="parentContext.type"></ds-themed-comcol-page-browse-by>
</ng-container></ng-container>

View File

@@ -4,13 +4,17 @@ import { BrowseByGuard } from './browse-by-guard';
import { BrowseByDSOBreadcrumbResolver } from './browse-by-dso-breadcrumb.resolver';
import { BrowseByI18nBreadcrumbResolver } from './browse-by-i18n-breadcrumb.resolver';
import { ThemedBrowseBySwitcherComponent } from './browse-by-switcher/themed-browse-by-switcher.component';
import { DSOEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver';
@NgModule({
imports: [
RouterModule.forChild([
{
path: '',
resolve: { breadcrumb: BrowseByDSOBreadcrumbResolver },
resolve: {
breadcrumb: BrowseByDSOBreadcrumbResolver,
menu: DSOEditMenuResolver
},
children: [
{
path: ':id',

View File

@@ -10,6 +10,7 @@ import { ThemedBrowseByMetadataPageComponent } from './browse-by-metadata-page/t
import { ThemedBrowseByDatePageComponent } from './browse-by-date-page/themed-browse-by-date-page.component';
import { ThemedBrowseByTitlePageComponent } from './browse-by-title-page/themed-browse-by-title-page.component';
import { SharedBrowseByModule } from '../shared/browse-by/shared-browse-by.module';
import { DsoPageModule } from '../shared/dso-page/dso-page.module';
const ENTRY_COMPONENTS = [
// put only entry components that use custom decorator
@@ -28,6 +29,7 @@ const ENTRY_COMPONENTS = [
SharedBrowseByModule,
CommonModule,
ComcolModule,
DsoPageModule
],
declarations: [
BrowseBySwitcherComponent,

View File

@@ -21,6 +21,7 @@ import { CollectionPageAdministratorGuard } from './collection-page-administrato
import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model';
import { ThemedCollectionPageComponent } from './themed-collection-page.component';
import { MenuItemType } from '../shared/menu/menu-item-type.model';
import { DSOEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver';
@NgModule({
imports: [
@@ -34,7 +35,8 @@ import { MenuItemType } from '../shared/menu/menu-item-type.model';
path: ':id',
resolve: {
dso: CollectionPageResolver,
breadcrumb: CollectionBreadcrumbResolver
breadcrumb: CollectionBreadcrumbResolver,
menu: DSOEditMenuResolver
},
runGuardsAndResolvers: 'always',
children: [
@@ -91,7 +93,7 @@ import { MenuItemType } from '../shared/menu/menu-item-type.model';
DSOBreadcrumbsService,
LinkService,
CreateCollectionPageGuard,
CollectionPageAdministratorGuard
CollectionPageAdministratorGuard,
]
})
export class CollectionPageRoutingModule {

View File

@@ -33,9 +33,7 @@
[title]="'collection.page.news'">
</ds-comcol-page-content>
</header>
<div class="pl-2 space-children-mr">
<ds-dso-page-edit-button *ngIf="isCollectionAdmin$ | async" [pageRoute]="collectionPageRoute$ | async" [dso]="collection" [tooltipMsg]="'collection.page.edit'"></ds-dso-page-edit-button>
</div>
<ds-dso-edit-menu></ds-dso-edit-menu>
</div>
<section class="comcol-page-browse-section">
<!-- Browse-By Links -->

View File

@@ -17,6 +17,7 @@ import { CollectionFormModule } from './collection-form/collection-form.module';
import { ThemedCollectionPageComponent } from './themed-collection-page.component';
import { ComcolModule } from '../shared/comcol/comcol.module';
import { DsoSharedModule } from '../dso-shared/dso-shared.module';
import { DsoPageModule } from '../shared/dso-page/dso-page.module';
@NgModule({
imports: [
@@ -28,6 +29,7 @@ import { DsoSharedModule } from '../dso-shared/dso-shared.module';
CollectionFormModule,
ComcolModule,
DsoSharedModule,
DsoPageModule,
],
declarations: [
CollectionPageComponent,

View File

@@ -14,6 +14,7 @@ import { CommunityPageAdministratorGuard } from './community-page-administrator.
import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model';
import { ThemedCommunityPageComponent } from './themed-community-page.component';
import { MenuItemType } from '../shared/menu/menu-item-type.model';
import { DSOEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver';
@NgModule({
imports: [
@@ -27,7 +28,8 @@ import { MenuItemType } from '../shared/menu/menu-item-type.model';
path: ':id',
resolve: {
dso: CommunityPageResolver,
breadcrumb: CommunityBreadcrumbResolver
breadcrumb: CommunityBreadcrumbResolver,
menu: DSOEditMenuResolver
},
runGuardsAndResolvers: 'always',
children: [
@@ -73,7 +75,7 @@ import { MenuItemType } from '../shared/menu/menu-item-type.model';
DSOBreadcrumbsService,
LinkService,
CreateCommunityPageGuard,
CommunityPageAdministratorGuard
CommunityPageAdministratorGuard,
]
})
export class CommunityPageRoutingModule {

View File

@@ -20,9 +20,7 @@
[title]="'community.page.news'">
</ds-comcol-page-content>
</header>
<div class="pl-2 space-children-mr">
<ds-dso-page-edit-button *ngIf="isCommunityAdmin$ | async" [pageRoute]="communityPageRoute$ | async" [dso]="communityPayload" [tooltipMsg]="'community.page.edit'"></ds-dso-page-edit-button>
</div>
<ds-dso-edit-menu></ds-dso-edit-menu>
</div>
<section class="comcol-page-browse-section">

View File

@@ -19,6 +19,7 @@ import {
import {
ThemedCollectionPageSubCollectionListComponent
} from './sub-collection-list/themed-community-page-sub-collection-list.component';
import { DsoPageModule } from '../shared/dso-page/dso-page.module';
const DECLARATIONS = [CommunityPageComponent,
ThemedCommunityPageComponent,
@@ -37,6 +38,7 @@ const DECLARATIONS = [CommunityPageComponent,
StatisticsModule.forRoot(),
CommunityFormModule,
ComcolModule,
DsoPageModule,
],
declarations: [
...DECLARATIONS

View File

@@ -11,6 +11,7 @@ import { HttpOptions } from '../dspace-rest/dspace-rest.service';
import objectContaining = jasmine.objectContaining;
import { AuthStatus } from './models/auth-status.model';
import { RestRequestMethod } from '../data/rest-request-method';
import { Observable, of as observableOf } from 'rxjs';
describe(`AuthRequestService`, () => {
let halService: HALEndpointService;
@@ -34,8 +35,8 @@ describe(`AuthRequestService`, () => {
super(hes, rs, rdbs);
}
protected createShortLivedTokenRequest(href: string): PostRequest {
return new PostRequest(this.requestService.generateRequestId(), href);
protected createShortLivedTokenRequest(href: string): Observable<PostRequest> {
return observableOf(new PostRequest(this.requestService.generateRequestId(), href));
}
}

View File

@@ -100,14 +100,12 @@ export abstract class AuthRequestService {
);
}
/**
* Factory function to create the request object to send. This needs to be a POST client side and
* a GET server side. Due to CSRF validation, the server isn't allowed to send a POST, so we allow
* only the server IP to send a GET to this endpoint.
* Factory function to create the request object to send.
*
* @param href The href to send the request to
* @protected
*/
protected abstract createShortLivedTokenRequest(href: string): GetRequest | PostRequest;
protected abstract createShortLivedTokenRequest(href: string): Observable<PostRequest>;
/**
* Send a request to retrieve a short-lived token which provides download access of restricted files
@@ -117,7 +115,7 @@ export abstract class AuthRequestService {
filter((href: string) => isNotEmpty(href)),
distinctUntilChanged(),
map((href: string) => new URLCombiner(href, this.shortlivedtokensEndpoint).toString()),
map((endpointURL: string) => this.createShortLivedTokenRequest(endpointURL)),
switchMap((endpointURL: string) => this.createShortLivedTokenRequest(endpointURL)),
tap((request: RestRequest) => this.requestService.send(request)),
switchMap((request: RestRequest) => this.rdbService.buildFromRequestUUID<ShortLivedToken>(request.uuid)),
getFirstCompletedRemoteData(),

View File

@@ -1,6 +1,8 @@
import { AuthRequestService } from './auth-request.service';
import { RequestService } from '../data/request.service';
import { BrowserAuthRequestService } from './browser-auth-request.service';
import { Observable } from 'rxjs';
import { PostRequest } from '../data/request.models';
describe(`BrowserAuthRequestService`, () => {
let href: string;
@@ -16,14 +18,20 @@ describe(`BrowserAuthRequestService`, () => {
});
describe(`createShortLivedTokenRequest`, () => {
it(`should return a PostRequest`, () => {
const result = (service as any).createShortLivedTokenRequest(href);
expect(result.constructor.name).toBe('PostRequest');
it(`should return a PostRequest`, (done) => {
const obs = (service as any).createShortLivedTokenRequest(href) as Observable<PostRequest>;
obs.subscribe((result: PostRequest) => {
expect(result.constructor.name).toBe('PostRequest');
done();
});
});
it(`should return a request with the given href`, () => {
const result = (service as any).createShortLivedTokenRequest(href);
expect(result.href).toBe(href) ;
it(`should return a request with the given href`, (done) => {
const obs = (service as any).createShortLivedTokenRequest(href) as Observable<PostRequest>;
obs.subscribe((result: PostRequest) => {
expect(result.href).toBe(href);
done();
});
});
});
});

View File

@@ -4,6 +4,7 @@ import { PostRequest } from '../data/request.models';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { RequestService } from '../data/request.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { Observable, of as observableOf } from 'rxjs';
/**
* Client side version of the service to send authentication requests
@@ -20,15 +21,13 @@ export class BrowserAuthRequestService extends AuthRequestService {
}
/**
* Factory function to create the request object to send. This needs to be a POST client side and
* a GET server side. Due to CSRF validation, the server isn't allowed to send a POST, so we allow
* only the server IP to send a GET to this endpoint.
* Factory function to create the request object to send.
*
* @param href The href to send the request to
* @protected
*/
protected createShortLivedTokenRequest(href: string): PostRequest {
return new PostRequest(this.requestService.generateRequestId(), href);
protected createShortLivedTokenRequest(href: string): Observable<PostRequest> {
return observableOf(new PostRequest(this.requestService.generateRequestId(), href));
}
}

View File

@@ -1,34 +1,68 @@
import { AuthRequestService } from './auth-request.service';
import { RequestService } from '../data/request.service';
import { ServerAuthRequestService } from './server-auth-request.service';
import { HttpClient, HttpHeaders, HttpResponse } from '@angular/common/http';
import { Observable, of as observableOf } from 'rxjs';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { PostRequest } from '../data/request.models';
import {
XSRF_REQUEST_HEADER,
XSRF_RESPONSE_HEADER
} from '../xsrf/xsrf.interceptor';
describe(`ServerAuthRequestService`, () => {
let href: string;
let requestService: RequestService;
let service: AuthRequestService;
let httpClient: HttpClient;
let httpResponse: HttpResponse<any>;
let halService: HALEndpointService;
const mockToken = 'mock-token';
beforeEach(() => {
href = 'https://rest.api/auth/shortlivedtokens';
requestService = jasmine.createSpyObj('requestService', {
'generateRequestId': '8bb0582d-5013-4337-af9c-763beb25aae2'
});
service = new ServerAuthRequestService(null, requestService, null);
let headers = new HttpHeaders();
headers = headers.set(XSRF_RESPONSE_HEADER, mockToken);
httpResponse = {
body: { bar: false },
headers: headers,
statusText: '200'
} as HttpResponse<any>;
httpClient = jasmine.createSpyObj('httpClient', {
get: observableOf(httpResponse),
});
halService = jasmine.createSpyObj('halService', {
'getRootHref': '/api'
});
service = new ServerAuthRequestService(halService, requestService, null, httpClient);
});
describe(`createShortLivedTokenRequest`, () => {
it(`should return a GetRequest`, () => {
const result = (service as any).createShortLivedTokenRequest(href);
expect(result.constructor.name).toBe('GetRequest');
it(`should return a PostRequest`, (done) => {
const obs = (service as any).createShortLivedTokenRequest(href) as Observable<PostRequest>;
obs.subscribe((result: PostRequest) => {
expect(result.constructor.name).toBe('PostRequest');
done();
});
});
it(`should return a request with the given href`, () => {
const result = (service as any).createShortLivedTokenRequest(href);
expect(result.href).toBe(href) ;
it(`should return a request with the given href`, (done) => {
const obs = (service as any).createShortLivedTokenRequest(href) as Observable<PostRequest>;
obs.subscribe((result: PostRequest) => {
expect(result.href).toBe(href);
done();
});
});
it(`should have a responseMsToLive of 2 seconds`, () => {
const result = (service as any).createShortLivedTokenRequest(href);
expect(result.responseMsToLive).toBe(2 * 1000) ;
it(`should return a request with a xsrf header`, (done) => {
const obs = (service as any).createShortLivedTokenRequest(href) as Observable<PostRequest>;
obs.subscribe((result: PostRequest) => {
expect(result.options.headers.get(XSRF_REQUEST_HEADER)).toBe(mockToken);
done();
});
});
});
});

View File

@@ -1,9 +1,21 @@
import { Injectable } from '@angular/core';
import { AuthRequestService } from './auth-request.service';
import { GetRequest } from '../data/request.models';
import { PostRequest } from '../data/request.models';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { RequestService } from '../data/request.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import {
HttpHeaders,
HttpClient,
HttpResponse
} from '@angular/common/http';
import {
XSRF_REQUEST_HEADER,
XSRF_RESPONSE_HEADER,
DSPACE_XSRF_COOKIE
} from '../xsrf/xsrf.interceptor';
import { map } from 'rxjs/operators';
import { Observable } from 'rxjs';
/**
* Server side version of the service to send authentication requests
@@ -14,23 +26,42 @@ export class ServerAuthRequestService extends AuthRequestService {
constructor(
halService: HALEndpointService,
requestService: RequestService,
rdbService: RemoteDataBuildService
rdbService: RemoteDataBuildService,
protected httpClient: HttpClient,
) {
super(halService, requestService, rdbService);
}
/**
* Factory function to create the request object to send. This needs to be a POST client side and
* a GET server side. Due to CSRF validation, the server isn't allowed to send a POST, so we allow
* only the server IP to send a GET to this endpoint.
* Factory function to create the request object to send.
*
* @param href The href to send the request to
* @protected
*/
protected createShortLivedTokenRequest(href: string): GetRequest {
return Object.assign(new GetRequest(this.requestService.generateRequestId(), href), {
responseMsToLive: 2 * 1000 // A short lived token is only valid for 2 seconds.
});
protected createShortLivedTokenRequest(href: string): Observable<PostRequest> {
// First do a call to the root endpoint in order to get an XSRF token
return this.httpClient.get(this.halService.getRootHref(), { observe: 'response' }).pipe(
// retrieve the XSRF token from the response header
map((response: HttpResponse<any>) => response.headers.get(XSRF_RESPONSE_HEADER)),
// Use that token to create an HttpHeaders object
map((xsrfToken: string) => new HttpHeaders()
.set('Content-Type', 'application/json; charset=utf-8')
// set the token as the XSRF header
.set(XSRF_REQUEST_HEADER, xsrfToken)
// and as the DSPACE-XSRF-COOKIE
.set('Cookie', `${DSPACE_XSRF_COOKIE}=${xsrfToken}`)),
map((headers: HttpHeaders) =>
// Create a new PostRequest using those headers and the given href
new PostRequest(
this.requestService.generateRequestId(),
href,
{},
{
headers: headers,
},
)
)
);
}
}

View File

@@ -2,27 +2,66 @@ import { BrowseDefinitionDataService } from './browse-definition-data.service';
import { followLink } from '../../shared/utils/follow-link-config.model';
import { EMPTY } from 'rxjs';
import { FindListOptions } from '../data/find-list-options.model';
import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock';
import { RequestService } from '../data/request.service';
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub';
import { getMockObjectCacheService } from '../../shared/mocks/object-cache.service.mock';
describe(`BrowseDefinitionDataService`, () => {
let requestService: RequestService;
let service: BrowseDefinitionDataService;
const findAllDataSpy = jasmine.createSpyObj('findAllData', {
findAll: EMPTY,
});
let findAllDataSpy;
let searchDataSpy;
const browsesEndpointURL = 'https://rest.api/browses';
const halService: any = new HALEndpointServiceStub(browsesEndpointURL);
const options = new FindListOptions();
const linksToFollow = [
followLink('entries'),
followLink('items')
];
function initTestService() {
return new BrowseDefinitionDataService(
requestService,
getMockRemoteDataBuildService(),
getMockObjectCacheService(),
halService,
);
}
beforeEach(() => {
service = new BrowseDefinitionDataService(null, null, null, null);
service = initTestService();
findAllDataSpy = jasmine.createSpyObj('findAllData', {
findAll: EMPTY,
});
searchDataSpy = jasmine.createSpyObj('searchData', {
searchBy: EMPTY,
getSearchByHref: EMPTY,
});
(service as any).findAllData = findAllDataSpy;
(service as any).searchData = searchDataSpy;
});
describe('findByFields', () => {
it(`should call searchByHref on searchData`, () => {
service.findByFields(['test'], true, false, ...linksToFollow);
expect(searchDataSpy.getSearchByHref).toHaveBeenCalled();
});
});
describe('searchBy', () => {
it(`should call searchBy on searchData`, () => {
service.searchBy('test', options, true, false, ...linksToFollow);
expect(searchDataSpy.searchBy).toHaveBeenCalledWith('test', options, true, false, ...linksToFollow);
});
});
describe(`findAll`, () => {
it(`should call findAll on findAllData`, () => {
service.findAll(options, true, false, ...linksToFollow);
expect(findAllDataSpy.findAll).toHaveBeenCalledWith(options, true, false, ...linksToFollow);
});
});
});

View File

@@ -13,6 +13,8 @@ import { FindListOptions } from '../data/find-list-options.model';
import { IdentifiableDataService } from '../data/base/identifiable-data.service';
import { FindAllData, FindAllDataImpl } from '../data/base/find-all-data';
import { dataService } from '../data/base/data-service.decorator';
import { RequestParam } from '../cache/models/request-param.model';
import { SearchData, SearchDataImpl } from '../data/base/search-data';
/**
* Data service responsible for retrieving browse definitions from the REST server
@@ -21,8 +23,9 @@ import { dataService } from '../data/base/data-service.decorator';
providedIn: 'root',
})
@dataService(BROWSE_DEFINITION)
export class BrowseDefinitionDataService extends IdentifiableDataService<BrowseDefinition> implements FindAllData<BrowseDefinition> {
export class BrowseDefinitionDataService extends IdentifiableDataService<BrowseDefinition> implements FindAllData<BrowseDefinition>, SearchData<BrowseDefinition> {
private findAllData: FindAllDataImpl<BrowseDefinition>;
private searchData: SearchDataImpl<BrowseDefinition>;
constructor(
protected requestService: RequestService,
@@ -31,7 +34,7 @@ export class BrowseDefinitionDataService extends IdentifiableDataService<BrowseD
protected halService: HALEndpointService,
) {
super('browses', requestService, rdbService, objectCache, halService);
this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
}
@@ -52,5 +55,71 @@ export class BrowseDefinitionDataService extends IdentifiableDataService<BrowseD
findAll(options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<BrowseDefinition>[]): Observable<RemoteData<PaginatedList<BrowseDefinition>>> {
return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
/**
* Make a new FindListRequest with given search method
*
* @param searchMethod The search method for the object
* @param options The [[FindListOptions]] 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
* @return {Observable<RemoteData<PaginatedList<T>>}
* Return an observable that emits response from the server
*/
public searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig<BrowseDefinition>[]): Observable<RemoteData<PaginatedList<BrowseDefinition>>> {
return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
/**
* Create the HREF for a specific object's search method with given options object
*
* @param searchMethod The search method for the object
* @param options The [[FindListOptions]] object
* @return {Observable<string>}
* Return an observable that emits created HREF
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
public getSearchByHref(searchMethod: string, options?: FindListOptions, ...linksToFollow: FollowLinkConfig<BrowseDefinition>[]): Observable<string> {
return this.searchData.getSearchByHref(searchMethod, options, ...linksToFollow);
}
/**
* Get the browse URL by providing a list of metadata keys. The first matching browse index definition
* for any of the fields is returned. This is used in eg. item page field component, which can be configured
* with several fields for a component like 'Author', and needs to know if and how to link the values
* to configured browse indices.
*
* @param fields an array of field strings, eg. ['dc.contributor.author', 'dc.creator']
* @param useCachedVersionIfAvailable Override the data service useCachedVersionIfAvailable parameter (default: true)
* @param reRequestOnStale Override the data service reRequestOnStale parameter (default: true)
* @param linksToFollow Override the data service linksToFollow parameter (default: empty array)
*/
findByFields(
fields: string[],
useCachedVersionIfAvailable = true,
reRequestOnStale = true,
...linksToFollow: FollowLinkConfig<BrowseDefinition>[]
): Observable<RemoteData<BrowseDefinition>> {
const searchParams = [];
searchParams.push(new RequestParam('fields', fields));
const hrefObs = this.getSearchByHref(
'byFields',
{ searchParams },
...linksToFollow
);
return this.findByHref(
hrefObs,
useCachedVersionIfAvailable,
reRequestOnStale,
...linksToFollow,
);
}
}

View File

@@ -19,9 +19,9 @@ import {
} from '../shared/operators';
import { URLCombiner } from '../url-combiner/url-combiner';
import { BrowseEntrySearchOptions } from './browse-entry-search-options.model';
import { BrowseDefinitionDataService } from './browse-definition-data.service';
import { HrefOnlyDataService } from '../data/href-only-data.service';
import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { BrowseDefinitionDataService } from './browse-definition-data.service';
export const BROWSE_LINKS_TO_FOLLOW: FollowLinkConfig<BrowseEntry | Item>[] = [
@@ -35,7 +35,7 @@ export const BROWSE_LINKS_TO_FOLLOW: FollowLinkConfig<BrowseEntry | Item>[] = [
export class BrowseService {
protected linkPath = 'browses';
private static toSearchKeyArray(metadataKey: string): string[] {
public static toSearchKeyArray(metadataKey: string): string[] {
const keyParts = metadataKey.split('.');
const searchFor = [];
searchFor.push('*');

View File

@@ -7,7 +7,6 @@ import { ServerSyncBufferEffects } from './cache/server-sync-buffer.effects';
import { ObjectUpdatesEffects } from './data/object-updates/object-updates.effects';
import { RouteEffects } from './services/route.effects';
import { RouterEffects } from './router/router.effects';
import { MenuEffects } from '../shared/menu/menu.effects';
export const coreEffects = [
RequestEffects,
@@ -19,5 +18,4 @@ export const coreEffects = [
ObjectUpdatesEffects,
RouteEffects,
RouterEffects,
MenuEffects
];

View File

@@ -170,6 +170,7 @@ import { OrcidHistory } from './orcid/model/orcid-history.model';
import { OrcidAuthService } from './orcid/orcid-auth.service';
import { VocabularyDataService } from './submission/vocabularies/vocabulary.data.service';
import { VocabularyEntryDetailsDataService } from './submission/vocabularies/vocabulary-entry-details.data.service';
import { IdentifierData } from '../shared/object-list/identifier-data/identifier-data.model';
import { SupervisionOrderDataService } from './supervision-order/supervision-order-data.service';
/**
@@ -358,7 +359,8 @@ export const models =
ResearcherProfile,
OrcidQueue,
OrcidHistory,
AccessStatusObject
AccessStatusObject,
IdentifierData,
];
@NgModule({

View File

@@ -14,6 +14,7 @@ import { RemoteData } from './remote-data';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { HttpOptions } from '../dspace-rest/dspace-rest.service';
import { HttpHeaders } from '@angular/common/http';
import { HttpParams } from '@angular/common/http';
@Injectable({
providedIn: 'root',
@@ -55,7 +56,7 @@ export class EpersonRegistrationService {
* @param email
* @param captchaToken the value of x-recaptcha-token header
*/
registerEmail(email: string, captchaToken: string = null): Observable<RemoteData<Registration>> {
registerEmail(email: string, captchaToken: string = null, type?: string): Observable<RemoteData<Registration>> {
const registration = new Registration();
registration.email = email;
@@ -70,6 +71,11 @@ export class EpersonRegistrationService {
}
options.headers = headers;
if (hasValue(type)) {
options.params = type ?
new HttpParams({ fromString: 'accountRequestType=' + type }) : new HttpParams();
}
href$.pipe(
find((href: string) => hasValue(href)),
map((href: string) => {

View File

@@ -32,4 +32,5 @@ export enum FeatureID {
CanSynchronizeWithORCID = 'canSynchronizeWithORCID',
CanSubmit = 'canSubmit',
CanEditItem = 'canEditItem',
CanRegisterDOI = 'canRegisterDOI',
}

View File

@@ -0,0 +1,85 @@
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { dataService } from './base/data-service.decorator';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../cache/object-cache.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { BaseDataService } from './base/base-data.service';
import { RequestService } from './request.service';
import { DefaultChangeAnalyzer } from './default-change-analyzer.service';
import { CoreState } from '../core-state.model';
import { Observable } from 'rxjs';
import { RemoteData } from './remote-data';
import { Item } from '../shared/item.model';
import { IDENTIFIERS } from '../../shared/object-list/identifier-data/identifier-data.resource-type';
import { IdentifierData } from '../../shared/object-list/identifier-data/identifier-data.model';
import { getFirstCompletedRemoteData } from '../shared/operators';
import { map, switchMap } from 'rxjs/operators';
import {ConfigurationProperty} from '../shared/configuration-property.model';
import {ConfigurationDataService} from './configuration-data.service';
import { HttpOptions } from '../dspace-rest/dspace-rest.service';
import { PostRequest } from './request.models';
import { sendRequest } from '../shared/request.operators';
import { RestRequest } from './rest-request.model';
/**
* The service handling all REST requests to get item identifiers like handles and DOIs
* from the /identifiers endpoint, as well as the backend configuration that controls whether a 'Register DOI'
* button appears for admins in the item status page
*/
@Injectable()
@dataService(IDENTIFIERS)
export class IdentifierDataService extends BaseDataService<IdentifierData> {
constructor(
protected comparator: DefaultChangeAnalyzer<IdentifierData>,
protected halService: HALEndpointService,
protected http: HttpClient,
protected notificationsService: NotificationsService,
protected objectCache: ObjectCacheService,
protected rdbService: RemoteDataBuildService,
protected requestService: RequestService,
protected store: Store<CoreState>,
private configurationService: ConfigurationDataService,
) {
super('identifiers', requestService, rdbService, objectCache, halService);
}
/**
* Returns {@link RemoteData} of {@link IdentifierData} representing identifiers for this item
* @param item Item we are querying
*/
getIdentifierDataFor(item: Item): Observable<RemoteData<IdentifierData>> {
return this.findByHref(item._links.identifiers.href, false, true);
}
/**
* Should we allow registration of new DOIs via the item status page?
*/
public getIdentifierRegistrationConfiguration(): Observable<string[]> {
return this.configurationService.findByPropertyName('identifiers.item-status.register-doi').pipe(
getFirstCompletedRemoteData(),
map((propertyRD: RemoteData<ConfigurationProperty>) => propertyRD.hasSucceeded ? propertyRD.payload.values : [])
);
}
public registerIdentifier(item: Item, type: string): Observable<RemoteData<any>> {
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;
let params = new HttpParams();
params = params.append('type', type);
options.params = params;
return new PostRequest(requestId, endpointURL, item._links.self.href, options);
}),
sendRequest(this.requestService),
switchMap((request: RestRequest) => this.rdbService.buildFromRequestUUID(request.uuid) as Observable<RemoteData<any>>)
);
}
}

View File

@@ -232,6 +232,16 @@ export abstract class BaseItemDataService extends IdentifiableDataService<Item>
return this.rdbService.buildFromRequestUUID(requestId);
}
/**
* Get the endpoint for an item's identifiers
* @param itemId
*/
public getIdentifiersEndpoint(itemId: string): Observable<string> {
return this.halService.getEndpoint(this.linkPath).pipe(
switchMap((url: string) => this.halService.getEndpoint('identifiers', `${url}/${itemId}`))
);
}
/**
* Get the endpoint to move the item
* @param itemId

View File

@@ -0,0 +1,13 @@
import { SystemWideAlertDataService } from './system-wide-alert-data.service';
import { testFindAllDataImplementation } from './base/find-all-data.spec';
import { testPutDataImplementation } from './base/put-data.spec';
import { testCreateDataImplementation } from './base/create-data.spec';
describe('SystemWideAlertDataService', () => {
describe('composition', () => {
const initService = () => new SystemWideAlertDataService(null, null, null, null, null);
testFindAllDataImplementation(initService);
testPutDataImplementation(initService);
testCreateDataImplementation(initService);
});
});

View File

@@ -0,0 +1,104 @@
import { Injectable } from '@angular/core';
import { RequestService } from './request.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 { Observable } from 'rxjs';
import { PaginatedList } from './paginated-list.model';
import { RemoteData } from './remote-data';
import { IdentifiableDataService } from './base/identifiable-data.service';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { FindAllData, FindAllDataImpl } from './base/find-all-data';
import { FindListOptions } from './find-list-options.model';
import { dataService } from './base/data-service.decorator';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { CreateData, CreateDataImpl } from './base/create-data';
import { SYSTEMWIDEALERT } from '../../system-wide-alert/system-wide-alert.resource-type';
import { SystemWideAlert } from '../../system-wide-alert/system-wide-alert.model';
import { PutData, PutDataImpl } from './base/put-data';
import { RequestParam } from '../cache/models/request-param.model';
import { SearchData, SearchDataImpl } from './base/search-data';
/**
* Dataservice representing a system-wide alert
*/
@Injectable()
@dataService(SYSTEMWIDEALERT)
export class SystemWideAlertDataService extends IdentifiableDataService<SystemWideAlert> implements FindAllData<SystemWideAlert>, CreateData<SystemWideAlert>, PutData<SystemWideAlert>, SearchData<SystemWideAlert> {
private findAllData: FindAllDataImpl<SystemWideAlert>;
private createData: CreateDataImpl<SystemWideAlert>;
private putData: PutDataImpl<SystemWideAlert>;
private searchData: SearchData<SystemWideAlert>;
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
) {
super('systemwidealerts', requestService, rdbService, objectCache, halService);
this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
this.createData = new CreateDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive);
this.putData = new PutDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
}
/**
* Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded
* info should be added to the objects
*
* @param options 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
* @return {Observable<RemoteData<PaginatedList<T>>>}
* Return an observable that emits object list
*/
findAll(options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig<SystemWideAlert>[]): Observable<RemoteData<PaginatedList<SystemWideAlert>>> {
return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
/**
* Create a new object on the server, and store the response in the object cache
*
* @param object The object to create
* @param params Array with additional params to combine with query string
*/
create(object: SystemWideAlert, ...params: RequestParam[]): Observable<RemoteData<SystemWideAlert>> {
return this.createData.create(object, ...params);
}
/**
* Send a PUT request for the specified object
*
* @param object The object to send a put request for.
*/
put(object: SystemWideAlert): Observable<RemoteData<SystemWideAlert>> {
return this.putData.put(object);
}
/**
* Make a new FindListRequest with given search method
*
* @param searchMethod The search method for the object
* @param options The [[FindListOptions]] 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
* @return {Observable<RemoteData<PaginatedList<T>>}
* Return an observable that emits response from the server
*/
searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig<SystemWideAlert>[]): Observable<RemoteData<PaginatedList<SystemWideAlert>>> {
return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
}

View File

@@ -3,8 +3,8 @@ import { Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { Operation, ReplaceOperation } from 'fast-json-patch';
import { Observable } from 'rxjs';
import { find, map } from 'rxjs/operators';
import { Observable, of as observableOf } from 'rxjs';
import { find, map, mergeMap } from 'rxjs/operators';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../cache/object-cache.service';
@@ -130,6 +130,24 @@ export class ResearcherProfileDataService extends IdentifiableDataService<Resear
return this.rdbService.buildFromRequestUUID(requestId, followLink('item'));
}
/**
* Creates a researcher profile starting from an external source URI and returns the related item's ID
* Emits null if the researcher profile doesn't exist after sending out the request
* @param sourceUri
*/
createFromExternalSourceAndReturnRelatedItemId(sourceUri: string): Observable<string> {
return this.createFromExternalSource(sourceUri).pipe(
getFirstCompletedRemoteData(),
mergeMap((rd: RemoteData<ResearcherProfile>) => {
if (rd.hasSucceeded) {
return this.findRelatedItemId(rd.payload);
} else {
return observableOf(null);
}
}),
);
}
/**
* Create a new object on the server, and store the response in the object cache

View File

@@ -0,0 +1,16 @@
import { XhrFactory } from '@angular/common';
import { Injectable } from '@angular/core';
import { prototype, XMLHttpRequest } from 'xhr2';
/**
* Overrides the default XhrFactory server side, to allow us to set cookies in requests to the
* backend. This was added to be able to perform a working XSRF request from the node server, as it
* needs to set a cookie for the XSRF token
*/
@Injectable()
export class ServerXhrService implements XhrFactory {
build(): XMLHttpRequest {
prototype._restrictedHeaders.cookie = false;
return new XMLHttpRequest();
}
}

View File

@@ -24,6 +24,8 @@ import { Bitstream } from './bitstream.model';
import { ACCESS_STATUS } from 'src/app/shared/object-list/access-status-badge/access-status.resource-type';
import { AccessStatusObject } from 'src/app/shared/object-list/access-status-badge/access-status.model';
import { HandleObject } from './handle-object.model';
import { IDENTIFIERS } from '../../shared/object-list/identifier-data/identifier-data.resource-type';
import { IdentifierData } from '../../shared/object-list/identifier-data/identifier-data.model';
/**
* Class representing a DSpace Item
@@ -76,6 +78,7 @@ export class Item extends DSpaceObject implements ChildHALResource, HandleObject
version: HALLink;
thumbnail: HALLink;
accessStatus: HALLink;
identifiers: HALLink;
self: HALLink;
};
@@ -121,6 +124,13 @@ export class Item extends DSpaceObject implements ChildHALResource, HandleObject
@link(ACCESS_STATUS)
accessStatus?: Observable<RemoteData<AccessStatusObject>>;
/**
* The identifier data for this Item
* Will be undefined unless the identifiers {@link HALLink} has been resolved.
*/
@link(IDENTIFIERS, false, 'identifiers')
identifiers?: Observable<RemoteData<IdentifierData>>;
/**
* Method that returns as which type of object this object should be rendered
*/

View File

@@ -1,11 +1,14 @@
/**
* An Enum defining the representation type of metadata
*/
import { BrowseDefinition } from '../browse-definition.model';
export enum MetadataRepresentationType {
None = 'none',
Item = 'item',
AuthorityControlled = 'authority_controlled',
PlainText = 'plain_text'
PlainText = 'plain_text',
BrowseLink = 'browse_link'
}
/**
@@ -24,8 +27,14 @@ export interface MetadataRepresentation {
*/
representationType: MetadataRepresentationType;
/**
* The browse definition (optional)
*/
browseDefinition?: BrowseDefinition;
/**
* Fetches the value to be displayed
*/
getValue(): string;
}

View File

@@ -1,6 +1,7 @@
import { MetadataRepresentation, MetadataRepresentationType } from '../metadata-representation.model';
import { hasValue } from '../../../../shared/empty.util';
import { MetadataValue } from '../../metadata.models';
import { BrowseDefinition } from '../../browse-definition.model';
/**
* This class defines the way the metadatum it extends should be represented
@@ -12,9 +13,15 @@ export class MetadatumRepresentation extends MetadataValue implements MetadataRe
*/
itemType: string;
constructor(itemType: string) {
/**
* The browse definition ID passed in with the metadatum, if any
*/
browseDefinition?: BrowseDefinition;
constructor(itemType: string, browseDefinition?: BrowseDefinition) {
super();
this.itemType = itemType;
this.browseDefinition = browseDefinition;
}
/**
@@ -23,6 +30,8 @@ export class MetadatumRepresentation extends MetadataValue implements MetadataRe
get representationType(): MetadataRepresentationType {
if (hasValue(this.authority)) {
return MetadataRepresentationType.AuthorityControlled;
} else if (hasValue(this.browseDefinition)) {
return MetadataRepresentationType.BrowseLink;
} else {
return MetadataRepresentationType.PlainText;
}

View File

@@ -0,0 +1,9 @@
/*
* Object model for the data returned by the REST API to present minted identifiers in a submission section
*/
import { Identifier } from '../../../shared/object-list/identifier-data/identifier.model';
export interface WorkspaceitemSectionIdentifiersObject {
identifiers?: Identifier[]
displayTypes?: string[]
}

View File

@@ -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 {WorkspaceitemSectionIdentifiersObject} from './workspaceitem-section-identifiers.model';
import { WorkspaceitemSectionSherpaPoliciesObject } from './workspaceitem-section-sherpa-policies.model';
/**
@@ -23,4 +24,5 @@ export type WorkspaceitemSectionDataType
| WorkspaceitemSectionCcLicenseObject
| WorkspaceitemSectionAccessesObject
| WorkspaceitemSectionSherpaPoliciesObject
| WorkspaceitemSectionIdentifiersObject
| string;

View File

@@ -19,6 +19,8 @@ export const XSRF_REQUEST_HEADER = 'X-XSRF-TOKEN';
export const XSRF_RESPONSE_HEADER = 'DSPACE-XSRF-TOKEN';
// Name of cookie where we store the XSRF token
export const XSRF_COOKIE = 'XSRF-TOKEN';
// Name of cookie the backend expects the XSRF token to be in
export const DSPACE_XSRF_COOKIE = 'DSPACE-XSRF-COOKIE';
/**
* Custom Http Interceptor intercepting Http Requests & Responses to

View File

@@ -2,12 +2,7 @@
<div class="d-flex flex-row">
<ds-item-page-title-field [item]="object" class="mr-auto">
</ds-item-page-title-field>
<div class="pl-2 space-children-mr">
<ds-dso-page-version-button (newVersionEvent)="onCreateNewVersion()" [dso]="object"
[tooltipMsgCreate]="'item.page.version.create'"
[tooltipMsgHasDraft]="'item.page.version.hasDraft'"></ds-dso-page-version-button>
<ds-dso-page-edit-button [pageRoute]="itemPageRoute" [dso]="object" [tooltipMsg]="'journalissue.page.edit'"></ds-dso-page-edit-button>
</div>
<ds-dso-edit-menu></ds-dso-edit-menu>
</div>
<div class="row">
<div class="col-xs-12 col-md-4">

View File

@@ -1,7 +1,7 @@
import { Component } from '@angular/core';
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 { ItemComponent } from '../../../../item-page/simple/item-types/shared/item.component';
@listableObjectComponent('JournalIssue', ViewMode.StandalonePage)
@Component({
@@ -12,5 +12,5 @@ import { VersionedItemComponent } from '../../../../item-page/simple/item-types/
/**
* The component for displaying metadata and relations of an item of the type Journal Issue
*/
export class JournalIssueComponent extends VersionedItemComponent {
export class JournalIssueComponent extends ItemComponent {
}

View File

@@ -2,12 +2,7 @@
<div class="d-flex flex-row">
<ds-item-page-title-field [item]="object" class="mr-auto">
</ds-item-page-title-field>
<div class="pl-2 space-children-mr">
<ds-dso-page-version-button (newVersionEvent)="onCreateNewVersion()" [dso]="object"
[tooltipMsgCreate]="'item.page.version.create'"
[tooltipMsgHasDraft]="'item.page.version.hasDraft'"></ds-dso-page-version-button>
<ds-dso-page-edit-button [pageRoute]="itemPageRoute" [dso]="object" [tooltipMsg]="'journalvolume.page.edit'"></ds-dso-page-edit-button>
</div>
<ds-dso-edit-menu></ds-dso-edit-menu>
</div>
<div class="row">
<div class="col-xs-12 col-md-4">

View File

@@ -1,7 +1,7 @@
import { Component } from '@angular/core';
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 { ItemComponent } from '../../../../item-page/simple/item-types/shared/item.component';
@listableObjectComponent('JournalVolume', ViewMode.StandalonePage)
@Component({
@@ -12,5 +12,5 @@ import { VersionedItemComponent } from '../../../../item-page/simple/item-types/
/**
* The component for displaying metadata and relations of an item of the type Journal Volume
*/
export class JournalVolumeComponent extends VersionedItemComponent {
export class JournalVolumeComponent extends ItemComponent {
}

View File

@@ -2,12 +2,7 @@
<div class="d-flex flex-row">
<ds-item-page-title-field [item]="object" class="mr-auto">
</ds-item-page-title-field>
<div class="pl-2 space-children-mr">
<ds-dso-page-version-button (newVersionEvent)="onCreateNewVersion()" [dso]="object"
[tooltipMsgCreate]="'item.page.version.create'"
[tooltipMsgHasDraft]="'item.page.version.hasDraft'"></ds-dso-page-version-button>
<ds-dso-page-edit-button [pageRoute]="itemPageRoute" [dso]="object" [tooltipMsg]="'journal.page.edit'"></ds-dso-page-edit-button>
</div>
<ds-dso-edit-menu></ds-dso-edit-menu>
</div>
<div class="row">
<div class="col-xs-12 col-md-4">

View File

@@ -35,6 +35,10 @@ import { VersionDataService } from '../../../../core/data/version-data.service';
import { WorkspaceitemDataService } from '../../../../core/submission/workspaceitem-data.service';
import { SearchService } from '../../../../core/shared/search/search.service';
import { mockRouteService } from '../../../../item-page/simple/item-types/shared/item.component.spec';
import {
BrowseDefinitionDataServiceStub
} from '../../../../shared/testing/browse-definition-data-service.stub';
import { BrowseDefinitionDataService } from '../../../../core/browse/browse-definition-data.service';
let comp: JournalComponent;
let fixture: ComponentFixture<JournalComponent>;
@@ -100,7 +104,8 @@ describe('JournalComponent', () => {
{ provide: BitstreamDataService, useValue: mockBitstreamDataService },
{ provide: WorkspaceitemDataService, useValue: {} },
{ provide: SearchService, useValue: {} },
{ provide: RouteService, useValue: mockRouteService }
{ provide: RouteService, useValue: mockRouteService },
{ provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub }
],
schemas: [NO_ERRORS_SCHEMA]

View File

@@ -1,7 +1,7 @@
import { Component } from '@angular/core';
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 { ItemComponent } from '../../../../item-page/simple/item-types/shared/item.component';
@listableObjectComponent('Journal', ViewMode.StandalonePage)
@Component({
@@ -12,5 +12,5 @@ import { VersionedItemComponent } from '../../../../item-page/simple/item-types/
/**
* The component for displaying metadata and relations of an item of the type Journal
*/
export class JournalComponent extends VersionedItemComponent {
export class JournalComponent extends ItemComponent {
}

View File

@@ -21,6 +21,7 @@ import { JournalIssueSidebarSearchListElementComponent } from './item-list-eleme
import { JournalSidebarSearchListElementComponent } from './item-list-elements/sidebar-search-list-elements/journal/journal-sidebar-search-list-element.component';
import { ItemSharedModule } from '../../item-page/item-shared.module';
import { ResultsBackButtonModule } from '../../shared/results-back-button/results-back-button.module';
import { DsoPageModule } from '../../shared/dso-page/dso-page.module';
const ENTRY_COMPONENTS = [
// put only entry components that use custom decorator
@@ -49,7 +50,8 @@ const ENTRY_COMPONENTS = [
CommonModule,
ItemSharedModule,
SharedModule,
ResultsBackButtonModule
ResultsBackButtonModule,
DsoPageModule
],
declarations: [
...ENTRY_COMPONENTS

View File

@@ -2,12 +2,7 @@
<div class="d-flex flex-row">
<ds-item-page-title-field [item]="object" class="mr-auto">
</ds-item-page-title-field>
<div class="pl-2 space-children-mr">
<ds-dso-page-version-button (newVersionEvent)="onCreateNewVersion()" [dso]="object"
[tooltipMsgCreate]="'item.page.version.create'"
[tooltipMsgHasDraft]="'item.page.version.hasDraft'"></ds-dso-page-version-button>
<ds-dso-page-edit-button [pageRoute]="itemPageRoute" [dso]="object" [tooltipMsg]="'orgunit.page.edit'"></ds-dso-page-edit-button>
</div>
<ds-dso-edit-menu></ds-dso-edit-menu>
</div>
<div class="row">
<div class="col-xs-12 col-md-4">

View File

@@ -1,7 +1,7 @@
import { Component } from '@angular/core';
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 { ItemComponent } from '../../../../item-page/simple/item-types/shared/item.component';
@listableObjectComponent('OrgUnit', ViewMode.StandalonePage)
@Component({
@@ -12,5 +12,5 @@ import { VersionedItemComponent } from '../../../../item-page/simple/item-types/
/**
* The component for displaying metadata and relations of an item of the type Organisation Unit
*/
export class OrgUnitComponent extends VersionedItemComponent {
export class OrgUnitComponent extends ItemComponent {
}

View File

@@ -2,14 +2,7 @@
<div class="d-flex flex-row">
<ds-item-page-title-field class="mr-auto" [item]="object">
</ds-item-page-title-field>
<div class="pl-2 space-children-mr">
<ds-dso-page-orcid-button [pageRoute]="itemPageRoute" [dso]="object" class="mr-2"></ds-dso-page-orcid-button>
<ds-dso-page-version-button (newVersionEvent)="onCreateNewVersion()" [dso]="object"
[tooltipMsgCreate]="'item.page.version.create'"
[tooltipMsgHasDraft]="'item.page.version.hasDraft'"></ds-dso-page-version-button>
<ds-dso-page-edit-button [pageRoute]="itemPageRoute" [dso]="object" [tooltipMsg]="'person.page.edit'"></ds-dso-page-edit-button>
<ds-person-page-claim-button [object]="object"></ds-person-page-claim-button>
</div>
<ds-dso-edit-menu></ds-dso-edit-menu>
</div>
<div class="row">
<div class="col-xs-12 col-md-4">

View File

@@ -1,7 +1,7 @@
import { Component } from '@angular/core';
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 { ItemComponent } from '../../../../item-page/simple/item-types/shared/item.component';
@listableObjectComponent('Person', ViewMode.StandalonePage)
@Component({
@@ -12,5 +12,5 @@ import { VersionedItemComponent } from '../../../../item-page/simple/item-types/
/**
* The component for displaying metadata and relations of an item of the type Person
*/
export class PersonComponent extends VersionedItemComponent {
export class PersonComponent extends ItemComponent {
}

View File

@@ -2,12 +2,7 @@
<div class="d-flex flex-row">
<ds-item-page-title-field [item]="object" class="mr-auto">
</ds-item-page-title-field>
<div class="pl-2 space-children-mr">
<ds-dso-page-version-button (newVersionEvent)="onCreateNewVersion()" [dso]="object"
[tooltipMsgCreate]="'item.page.version.create'"
[tooltipMsgHasDraft]="'item.page.version.hasDraft'"></ds-dso-page-version-button>
<ds-dso-page-edit-button [pageRoute]="itemPageRoute" [dso]="object" [tooltipMsg]="'project.page.edit'"></ds-dso-page-edit-button>
</div>
<ds-dso-edit-menu></ds-dso-edit-menu>
</div>
<div class="row">
<div class="col-xs-12 col-md-4">

View File

@@ -1,7 +1,7 @@
import { Component } from '@angular/core';
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 { ItemComponent } from '../../../../item-page/simple/item-types/shared/item.component';
@listableObjectComponent('Project', ViewMode.StandalonePage)
@Component({
@@ -12,5 +12,5 @@ import { VersionedItemComponent } from '../../../../item-page/simple/item-types/
/**
* The component for displaying metadata and relations of an item of the type Project
*/
export class ProjectComponent extends VersionedItemComponent {
export class ProjectComponent extends ItemComponent {
}

View File

@@ -30,6 +30,7 @@ import { PersonSidebarSearchListElementComponent } from './item-list-elements/si
import { ProjectSidebarSearchListElementComponent } from './item-list-elements/sidebar-search-list-elements/project/project-sidebar-search-list-element.component';
import { ItemSharedModule } from '../../item-page/item-shared.module';
import { ResultsBackButtonModule } from '../../shared/results-back-button/results-back-button.module';
import { DsoPageModule } from '../../shared/dso-page/dso-page.module';
const ENTRY_COMPONENTS = [
// put only entry components that use custom decorator
@@ -71,7 +72,8 @@ const COMPONENTS = [
ItemSharedModule,
SharedModule,
NgbTooltipModule,
ResultsBackButtonModule
ResultsBackButtonModule,
DsoPageModule,
],
declarations: [
...COMPONENTS,

View File

@@ -1,3 +1,3 @@
<ds-register-email-form
[MESSAGE_PREFIX]="'forgot-email.form'">
[MESSAGE_PREFIX]="'forgot-email.form'" [typeRequest]="typeRequest">
</ds-register-email-form>

View File

@@ -1,4 +1,5 @@
import { Component } from '@angular/core';
import { TYPE_REQUEST_FORGOT } from '../../register-email-form/register-email-form.component';
@Component({
selector: 'ds-forgot-email',
@@ -9,5 +10,5 @@ import { Component } from '@angular/core';
* Component responsible the forgot password email step
*/
export class ForgotEmailComponent {
typeRequest = TYPE_REQUEST_FORGOT;
}

View File

@@ -1,6 +1,6 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Component, OnInit } from '@angular/core';
import { ContextHelpService } from '../../shared/context-help.service';
import { Observable, Subscription } from 'rxjs';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
/**
@@ -12,22 +12,15 @@ import { map } from 'rxjs/operators';
templateUrl: './context-help-toggle.component.html',
styleUrls: ['./context-help-toggle.component.scss']
})
export class ContextHelpToggleComponent implements OnInit, OnDestroy {
export class ContextHelpToggleComponent implements OnInit {
buttonVisible$: Observable<boolean>;
constructor(
private contextHelpService: ContextHelpService,
) { }
private subs: Subscription[];
ngOnInit(): void {
this.buttonVisible$ = this.contextHelpService.tooltipCount$().pipe(map(x => x > 0));
this.subs = [this.buttonVisible$.subscribe()];
}
ngOnDestroy() {
this.subs.forEach(sub => sub.unsubscribe());
}
onClick() {

View File

@@ -17,6 +17,7 @@ import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-con
import { APP_CONFIG, AppConfig } from '../../../config/app-config.interface';
import { isPlatformBrowser } from '@angular/common';
import { setPlaceHolderAttributes } from '../../shared/utils/object-list-utils';
import { DSpaceObjectType } from '../../core/shared/dspace-object-type.model';
@Component({
selector: 'ds-recent-item-list',
@@ -67,6 +68,7 @@ export class RecentItemListComponent implements OnInit {
this.itemRD$ = this.searchService.search(
new PaginatedSearchOptions({
pagination: this.paginationConfig,
dsoTypes: [DSpaceObjectType.ITEM],
sort: this.sortConfig,
}),
undefined,

View File

@@ -22,7 +22,6 @@ import { provideMockStore } from '@ngrx/store/testing';
import { AppComponent } from './app.component';
import { RouteService } from './core/services/route.service';
import { getMockLocaleService } from './app.component.spec';
import { MenuServiceStub } from './shared/testing/menu-service.stub';
import { CorrelationIdService } from './correlation-id/correlation-id.service';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { TranslateLoaderMock } from './shared/mocks/translate-loader.mock';
@@ -124,6 +123,7 @@ describe('InitService', () => {
let transferStateSpy;
let metadataServiceSpy;
let breadcrumbsServiceSpy;
let menuServiceSpy;
const BLOCKING = {
t: { core: { auth: { blocking: true } } },
@@ -150,6 +150,9 @@ describe('InitService', () => {
metadataServiceSpy = jasmine.createSpyObj('metadataService', [
'listenForRouteChange',
]);
menuServiceSpy = jasmine.createSpyObj('menuServiceSpy', [
'listenForRouteChanges',
]);
TestBed.resetTestingModule();
@@ -175,7 +178,7 @@ describe('InitService', () => {
{ provide: AuthService, useValue: new AuthServiceMock() },
{ provide: Router, useValue: new RouterMock() },
{ provide: ActivatedRoute, useValue: new MockActivatedRoute() },
{ provide: MenuService, useValue: new MenuServiceStub() },
{ provide: MenuService, useValue: menuServiceSpy },
{ provide: ThemeService, useValue: getMockThemeService() },
provideMockStore({ initialState }),
AppComponent,
@@ -190,6 +193,7 @@ describe('InitService', () => {
service.initRouteListeners();
expect(metadataServiceSpy.listenForRouteChange).toHaveBeenCalledTimes(1);
expect(breadcrumbsServiceSpy.listenForRouteChanges).toHaveBeenCalledTimes(1);
expect(breadcrumbsServiceSpy.listenForRouteChanges).toHaveBeenCalledTimes(1);
}));
});

View File

@@ -23,6 +23,7 @@ import { ThemeService } from './shared/theme-support/theme.service';
import { isAuthenticationBlocking } from './core/auth/selectors';
import { distinctUntilChanged, find } from 'rxjs/operators';
import { Observable } from 'rxjs';
import { MenuService } from './shared/menu/menu.service';
/**
* Performs the initialization of the app.
@@ -51,6 +52,8 @@ export abstract class InitService {
protected metadata: MetadataService,
protected breadcrumbsService: BreadcrumbsService,
protected themeService: ThemeService,
protected menuService: MenuService,
) {
}
@@ -184,6 +187,7 @@ export abstract class InitService {
this.metadata.listenForRouteChange();
this.breadcrumbsService.listenForRouteChanges();
this.themeService.listenForRouteChanges();
this.menuService.listenForRouteChanges();
}
/**

View File

@@ -3,8 +3,8 @@
<div class="col-12">
<h2 class="border-bottom">{{'item.edit.head' | translate}}</h2>
<div class="pt-2">
<ul class="nav nav-tabs justify-content-start">
<li *ngFor="let page of pages" class="nav-item">
<ul class="nav nav-tabs justify-content-start" role="tablist">
<li *ngFor="let page of pages" class="nav-item" [attr.aria-selected]="page.page === currentPage" role="tab">
<a *ngIf="(page.enabled | async)"
class="nav-link"
[ngClass]="{'active' : page.page === currentPage}"

View File

@@ -34,6 +34,9 @@ import { ItemAuthorizationsComponent } from './item-authorizations/item-authoriz
import { ObjectValuesPipe } from '../../shared/utils/object-values-pipe';
import { ResourcePoliciesModule } from '../../shared/resource-policies/resource-policies.module';
import { ItemVersionsModule } from '../versions/item-versions.module';
import { IdentifierDataService } from '../../core/data/identifier-data.service';
import { IdentifierDataComponent } from '../../shared/object-list/identifier-data/identifier-data.component';
import { ItemRegisterDoiComponent } from './item-register-doi/item-register-doi.component';
import { DsoSharedModule } from '../../dso-shared/dso-shared.module';
@@ -76,10 +79,13 @@ import { DsoSharedModule } from '../../dso-shared/dso-shared.module';
ItemMoveComponent,
ItemEditBitstreamDragHandleComponent,
VirtualMetadataComponent,
ItemAuthorizationsComponent
ItemAuthorizationsComponent,
IdentifierDataComponent,
ItemRegisterDoiComponent
],
providers: [
BundleDataService,
IdentifierDataService,
ObjectValuesPipe
],
})

View File

@@ -5,3 +5,4 @@ export const ITEM_EDIT_PUBLIC_PATH = 'public';
export const ITEM_EDIT_DELETE_PATH = 'delete';
export const ITEM_EDIT_MOVE_PATH = 'move';
export const ITEM_EDIT_AUTHORIZATIONS_PATH = 'authorizations';
export const ITEM_EDIT_REGISTER_DOI_PATH = 'register-doi';

View File

@@ -10,6 +10,7 @@ import { ItemStatusComponent } from './item-status/item-status.component';
import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component';
import { ItemCollectionMapperComponent } from './item-collection-mapper/item-collection-mapper.component';
import { ItemMoveComponent } from './item-move/item-move.component';
import { ItemRegisterDoiComponent } from './item-register-doi/item-register-doi.component';
import { ItemRelationshipsComponent } from './item-relationships/item-relationships.component';
import { I18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver';
import { ItemVersionHistoryComponent } from './item-version-history/item-version-history.component';
@@ -26,7 +27,8 @@ import {
ITEM_EDIT_PRIVATE_PATH,
ITEM_EDIT_PUBLIC_PATH,
ITEM_EDIT_REINSTATE_PATH,
ITEM_EDIT_WITHDRAW_PATH
ITEM_EDIT_WITHDRAW_PATH,
ITEM_EDIT_REGISTER_DOI_PATH
} from './edit-item-page.routing-paths';
import { ItemPageReinstateGuard } from './item-page-reinstate.guard';
import { ItemPageWithdrawGuard } from './item-page-withdraw.guard';
@@ -38,6 +40,7 @@ import { ItemPageRelationshipsGuard } from './item-page-relationships.guard';
import { ItemPageVersionHistoryGuard } from './item-page-version-history.guard';
import { ItemPageCollectionMapperGuard } from './item-page-collection-mapper.guard';
import { ThemedDsoEditMetadataComponent } from '../../dso-shared/dso-edit-metadata/themed-dso-edit-metadata.component';
import { ItemPageRegisterDoiGuard } from './item-page-register-doi.guard';
/**
* Routing module that handles the routing for the Edit Item page administrator functionality
@@ -142,6 +145,12 @@ import { ThemedDsoEditMetadataComponent } from '../../dso-shared/dso-edit-metada
component: ItemMoveComponent,
data: { title: 'item.edit.move.title' },
},
{
path: ITEM_EDIT_REGISTER_DOI_PATH,
component: ItemRegisterDoiComponent,
canActivate: [ItemPageRegisterDoiGuard],
data: { title: 'item.edit.register-doi.title' },
},
{
path: ITEM_EDIT_AUTHORIZATIONS_PATH,
children: [
@@ -186,6 +195,7 @@ import { ThemedDsoEditMetadataComponent } from '../../dso-shared/dso-edit-metada
ItemPageRelationshipsGuard,
ItemPageVersionHistoryGuard,
ItemPageCollectionMapperGuard,
ItemPageRegisterDoiGuard,
]
})
export class EditItemPageRoutingModule {

View File

@@ -0,0 +1,31 @@
import { Injectable } from '@angular/core';
import { DsoPageSingleFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard';
import { Item } from '../../core/shared/item.model';
import { ItemPageResolver } from '../item-page.resolver';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { Observable, of as observableOf } from 'rxjs';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { AuthService } from '../../core/auth/auth.service';
@Injectable({
providedIn: 'root'
})
/**
* Guard for preventing unauthorized access to certain {@link Item} pages requiring DOI registration rights
*/
export class ItemPageRegisterDoiGuard extends DsoPageSingleFeatureGuard<Item> {
constructor(protected resolver: ItemPageResolver,
protected authorizationService: AuthorizationDataService,
protected router: Router,
protected authService: AuthService) {
super(resolver, authorizationService, router, authService);
}
/**
* Check DOI registration authorization rights
*/
getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(FeatureID.CanRegisterDOI);
}
}

View File

@@ -27,6 +27,6 @@ export class ItemPageStatusGuard extends DsoPageSomeFeatureGuard<Item> {
* Check authorization rights
*/
getFeatureIDs(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID[]> {
return observableOf([FeatureID.CanManageMappings, FeatureID.WithdrawItem, FeatureID.ReinstateItem, FeatureID.CanManagePolicies, FeatureID.CanMakePrivate, FeatureID.CanDelete, FeatureID.CanMove]);
return observableOf([FeatureID.CanManageMappings, FeatureID.WithdrawItem, FeatureID.ReinstateItem, FeatureID.CanManagePolicies, FeatureID.CanMakePrivate, FeatureID.CanDelete, FeatureID.CanMove, FeatureID.CanRegisterDOI]);
}
}

View File

@@ -0,0 +1,24 @@
<div class="container">
<div class="row">
<div class="col-12">
<h2>{{headerMessage | translate: {id: item.handle} }}</h2>
<p>{{descriptionMessage | translate}}</p>
<div *ngFor="let identifier of (identifiers$ | async)" class="w-100 p">
<div *ngIf="(identifier.identifierType=='doi')">
<p class="float-left">{{doiToUpdateMessage | translate}}: {{identifier.value}}
({{"item.edit.identifiers.doi.status."+identifier.identifierStatus|translate}})
</p>
</div>
</div>
<ds-modify-item-overview [item]="item"></ds-modify-item-overview>
<div class="space-children-mr">
<button (click)="performAction()" class="btn btn-outline-secondary perform-action">{{confirmMessage | translate}}
</button>
<button [routerLink]="[itemPageRoute, 'edit']" class="btn btn-outline-secondary cancel">
{{cancelMessage| translate}}
</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,106 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { Item } from '../../../core/shared/item.model';
import { RouterStub } from '../../../shared/testing/router.stub';
import { of as observableOf } from 'rxjs';
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterTestingModule } from '@angular/router/testing';
import { TranslateModule } from '@ngx-translate/core';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { ActivatedRoute, Router } from '@angular/router';
import { ItemDataService } from '../../../core/data/item-data.service';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { By } from '@angular/platform-browser';
import { ItemRegisterDoiComponent } from './item-register-doi.component';
import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { IdentifierDataService } from '../../../core/data/identifier-data.service';
let comp: ItemRegisterDoiComponent;
let fixture: ComponentFixture<ItemRegisterDoiComponent>;
let mockItem;
let itemPageUrl;
let routerStub;
let mockItemDataService: ItemDataService;
let mockIdentifierDataService: IdentifierDataService;
let routeStub;
let notificationsServiceStub;
describe('ItemRegisterDoiComponent', () => {
beforeEach(waitForAsync(() => {
mockItem = Object.assign(new Item(), {
id: 'fake-id',
handle: 'fake/handle',
lastModified: '2018',
isWithdrawn: true
});
itemPageUrl = `fake-url/${mockItem.id}`;
routerStub = Object.assign(new RouterStub(), {
url: `${itemPageUrl}/edit`
});
mockIdentifierDataService = jasmine.createSpyObj('mockIdentifierDataService', {
getIdentifierDataFor: createSuccessfulRemoteDataObject$({'identifiers': []}),
getIdentifierRegistrationConfiguration: createSuccessfulRemoteDataObject$('true'),
registerIdentifier: createSuccessfulRemoteDataObject$({'identifiers': []}),
});
mockItemDataService = jasmine.createSpyObj('mockItemDataService', {
registerDOI: createSuccessfulRemoteDataObject$(mockItem)
});
routeStub = {
data: observableOf({
dso: createSuccessfulRemoteDataObject(Object.assign(new Item(), {
id: 'fake-id'
}))
})
};
notificationsServiceStub = new NotificationsServiceStub();
TestBed.configureTestingModule({
imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule],
declarations: [ItemRegisterDoiComponent],
providers: [
{ provide: ActivatedRoute, useValue: routeStub },
{ provide: Router, useValue: routerStub },
{ provide: ItemDataService, useValue: mockItemDataService },
{ provide: IdentifierDataService, useValue: mockIdentifierDataService},
{ provide: NotificationsService, useValue: notificationsServiceStub }
], schemas: [
CUSTOM_ELEMENTS_SCHEMA
]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ItemRegisterDoiComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
});
it('should render a page with messages based on the \'register-doi\' messageKey', () => {
const header = fixture.debugElement.query(By.css('h2')).nativeElement;
expect(header.innerHTML).toContain('item.edit.register-doi.header');
const description = fixture.debugElement.query(By.css('p')).nativeElement;
expect(description.innerHTML).toContain('item.edit.register-doi.description');
const confirmButton = fixture.debugElement.query(By.css('button.perform-action')).nativeElement;
expect(confirmButton.innerHTML).toContain('item.edit.register-doi.confirm');
const cancelButton = fixture.debugElement.query(By.css('button.cancel')).nativeElement;
expect(cancelButton.innerHTML).toContain('item.edit.register-doi.cancel');
});
describe('performAction', () => {
it('should call registerDOI function from the ItemDataService', () => {
spyOn(comp, 'processRestResponse');
comp.performAction();
expect(mockIdentifierDataService.registerIdentifier).toHaveBeenCalledWith(comp.item, 'doi');
expect(comp.processRestResponse).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,95 @@
import { Component } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { AbstractSimpleItemActionComponent } from '../simple-item-action/abstract-simple-item-action.component';
import { RemoteData } from '../../../core/data/remote-data';
import { Item } from '../../../core/shared/item.model';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { ItemDataService } from '../../../core/data/item-data.service';
import { getFirstSucceededRemoteData } from '../../../core/shared/operators';
import { first, map } from 'rxjs/operators';
import { hasValue } from '../../../shared/empty.util';
import { Observable } from 'rxjs';
import { getItemPageRoute } from '../../item-page-routing-paths';
import { IdentifierDataService } from '../../../core/data/identifier-data.service';
import { Identifier } from '../../../shared/object-list/identifier-data/identifier.model';
@Component({
selector: 'ds-item-register-doi',
templateUrl: './item-register-doi-component.html'
})
/**
* Component responsible for rendering the Item Register DOI page
*/
export class ItemRegisterDoiComponent extends AbstractSimpleItemActionComponent {
protected messageKey = 'register-doi';
doiToUpdateMessage = 'item.edit.' + this.messageKey + '.to-update';
identifiers$: Observable<Identifier[]>;
processing = false;
constructor(protected route: ActivatedRoute,
protected router: Router,
protected notificationsService: NotificationsService,
protected itemDataService: ItemDataService,
protected translateService: TranslateService,
protected identifierDataService: IdentifierDataService) {
super(route, router, notificationsService, itemDataService, translateService);
}
/**
* Initialise component
*/
ngOnInit(): void {
this.itemRD$ = this.route.data.pipe(
map((data) => data.dso),
getFirstSucceededRemoteData()
)as Observable<RemoteData<Item>>;
this.itemRD$.pipe(first()).subscribe((rd) => {
this.item = rd.payload;
this.itemPageRoute = getItemPageRoute(this.item);
this.identifiers$ = this.identifierDataService.getIdentifierDataFor(this.item).pipe(
map((identifierRD) => {
if (identifierRD.statusCode !== 401 && hasValue(identifierRD.payload)) {
return identifierRD.payload.identifiers;
} else {
return null;
}
}),
);
}
);
this.confirmMessage = 'item.edit.' + this.messageKey + '.confirm';
this.cancelMessage = 'item.edit.' + this.messageKey + '.cancel';
this.headerMessage = 'item.edit.' + this.messageKey + '.header';
this.descriptionMessage = 'item.edit.' + this.messageKey + '.description';
}
/**
* Perform the register DOI action to the item
*/
performAction() {
this.registerDoi();
}
/**
* Request that a pending, minted or null DOI be queued for registration
*/
registerDoi() {
this.processing = true;
this.identifierDataService.registerIdentifier(this.item, 'doi').subscribe(
(response: RemoteData<Item>) => {
if (response.hasCompleted) {
this.processing = false;
this.processRestResponse(response);
}
}
);
}
}

View File

@@ -8,6 +8,17 @@
{{statusData[statusKey]}}
</div>
</div>
<div *ngFor="let identifier of (identifiers$ | async)" class="w-100">
<div *ngIf="(identifier.identifierType=='doi')">
<div class="col-3 float-left status-label">
{{identifier.identifierType.toLocaleUpperCase()}}
</div>
<div class="col-9 float-left status-label">{{identifier.value}}
({{"item.edit.identifiers.doi.status."+identifier.identifierStatus|translate}})</div>
</div>
</div>
<div class="col-3 float-left status-label">
{{'item.edit.tabs.status.labels.itemPage' | translate}}:
</div>
@@ -18,4 +29,5 @@
<div *ngFor="let operation of (operations$ | async)" class="w-100" [ngClass]="{'pt-3': operation}">
<ds-item-operation *ngIf="operation" [operation]="operation"></ds-item-operation>
</div>
</div>

View File

@@ -11,8 +11,14 @@ import { Item } from '../../../core/shared/item.model';
import { By } from '@angular/platform-browser';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { of as observableOf } from 'rxjs';
import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils';
import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { IdentifierDataService } from '../../../core/data/identifier-data.service';
import { ConfigurationDataService } from '../../../core/data/configuration-data.service';
import { ConfigurationProperty } from '../../../core/shared/configuration-property.model';
let mockIdentifierDataService: IdentifierDataService;
let mockConfigurationDataService: ConfigurationDataService;
describe('ItemStatusComponent', () => {
let comp: ItemStatusComponent;
@@ -28,6 +34,20 @@ describe('ItemStatusComponent', () => {
}
});
mockIdentifierDataService = jasmine.createSpyObj('mockIdentifierDataService', {
getIdentifierDataFor: createSuccessfulRemoteDataObject$({'identifiers': []}),
getIdentifierRegistrationConfiguration: createSuccessfulRemoteDataObject$('true')
});
mockConfigurationDataService = jasmine.createSpyObj('configurationDataService', {
findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), {
name: 'identifiers.item-status.register-doi',
values: [
'true'
]
}))
});
const itemPageUrl = `/items/${mockItem.uuid}`;
const routeStub = {
@@ -50,6 +70,8 @@ describe('ItemStatusComponent', () => {
{ provide: ActivatedRoute, useValue: routeStub },
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) },
{ provide: AuthorizationDataService, useValue: authorizationService },
{ provide: IdentifierDataService, useValue: mockIdentifierDataService },
{ provide: ConfigurationDataService, useValue: mockConfigurationDataService }
], schemas: [CUSTOM_ELEMENTS_SCHEMA]
}).compileComponents();
}));

View File

@@ -3,14 +3,21 @@ import { fadeIn, fadeInOut } from '../../../shared/animations/fade';
import { Item } from '../../../core/shared/item.model';
import { ActivatedRoute } from '@angular/router';
import { ItemOperation } from '../item-operation/itemOperation.model';
import { distinctUntilChanged, first, map, mergeMap, toArray } from 'rxjs/operators';
import { BehaviorSubject, Observable, from as observableFrom } from 'rxjs';
import { distinctUntilChanged, first, map, mergeMap, switchMap, toArray } from 'rxjs/operators';
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import { RemoteData } from '../../../core/data/remote-data';
import { getItemEditRoute, getItemPageRoute } from '../../item-page-routing-paths';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
import { hasValue } from '../../../shared/empty.util';
import { getAllSucceededRemoteDataPayload } from '../../../core/shared/operators';
import {
getAllSucceededRemoteDataPayload, getFirstSucceededRemoteData, getRemoteDataPayload,
} from '../../../core/shared/operators';
import { IdentifierDataService } from '../../../core/data/identifier-data.service';
import { Identifier } from '../../../shared/object-list/identifier-data/identifier.model';
import { ConfigurationProperty } from '../../../core/shared/configuration-property.model';
import { ConfigurationDataService } from '../../../core/data/configuration-data.service';
import { IdentifierData } from '../../../shared/object-list/identifier-data/identifier-data.model';
@Component({
selector: 'ds-item-status',
@@ -47,9 +54,15 @@ export class ItemStatusComponent implements OnInit {
operations$: BehaviorSubject<ItemOperation[]> = new BehaviorSubject<ItemOperation[]>([]);
/**
* The keys of the actions (to loop over)
* Identifiers (handles, DOIs)
*/
actionsKeys;
identifiers$: Observable<Identifier[]>;
/**
* Configuration and state variables regarding DOIs
*/
public subs: Subscription[] = [];
/**
* Route to the item's page
@@ -57,9 +70,15 @@ export class ItemStatusComponent implements OnInit {
itemPageRoute$: Observable<string>;
constructor(private route: ActivatedRoute,
private authorizationService: AuthorizationDataService) {
private authorizationService: AuthorizationDataService,
private identifierDataService: IdentifierDataService,
private configurationService: ConfigurationDataService,
) {
}
/**
* Initialise component
*/
ngOnInit(): void {
this.itemRD$ = this.route.parent.data.pipe(map((data) => data.dso));
this.itemRD$.pipe(
@@ -72,12 +91,37 @@ export class ItemStatusComponent implements OnInit {
lastModified: item.lastModified
});
this.statusDataKeys = Object.keys(this.statusData);
// Observable for item identifiers (retrieved from embedded link)
this.identifiers$ = this.identifierDataService.getIdentifierDataFor(item).pipe(
map((identifierRD) => {
if (identifierRD.statusCode !== 401 && hasValue(identifierRD.payload)) {
return identifierRD.payload.identifiers;
} else {
return null;
}
}),
);
// Observable for configuration determining whether the Register DOI feature is enabled
let registerConfigEnabled$: Observable<boolean> = this.configurationService.findByPropertyName('identifiers.item-status.register-doi').pipe(
getFirstSucceededRemoteData(),
getRemoteDataPayload(),
map((enabled: ConfigurationProperty) => {
if (enabled !== undefined && enabled.values) {
return true;
}
return false;
})
);
/*
Construct a base list of operations.
The key is used to build messages
i18n example: 'item.edit.tabs.status.buttons.<key>.label'
The value is supposed to be a href for the button
*/
const operations = [];
const operations: ItemOperation[] = [];
operations.push(new ItemOperation('authorizations', this.getCurrentUrl(item) + '/authorizations', FeatureID.CanManagePolicies, true));
operations.push(new ItemOperation('mappedCollections', this.getCurrentUrl(item) + '/mapper', FeatureID.CanManageMappings, true));
if (item.isWithdrawn) {
@@ -92,27 +136,74 @@ export class ItemStatusComponent implements OnInit {
}
operations.push(new ItemOperation('delete', this.getCurrentUrl(item) + '/delete', FeatureID.CanDelete, true));
operations.push(new ItemOperation('move', this.getCurrentUrl(item) + '/move', FeatureID.CanMove, true));
this.operations$.next(operations);
observableFrom(operations).pipe(
mergeMap((operation) => {
if (hasValue(operation.featureID)) {
return this.authorizationService.isAuthorized(operation.featureID, item.self).pipe(
/*
When the identifier data stream changes, determine whether the register DOI button should be shown or not.
This is based on whether the DOI is in the right state (minted or pending, not already queued for registration
or registered) and whether the configuration property identifiers.item-status.register-doi is true
*/
this.identifierDataService.getIdentifierDataFor(item).pipe(
getFirstSucceededRemoteData(),
getRemoteDataPayload(),
mergeMap((data: IdentifierData) => {
let identifiers = data.identifiers;
let no_doi = true;
let pending = false;
if (identifiers !== undefined && identifiers !== null) {
identifiers.forEach((identifier: Identifier) => {
if (hasValue(identifier) && identifier.identifierType === 'doi') {
// The item has some kind of DOI
no_doi = false;
if (identifier.identifierStatus === 'PENDING' || identifier.identifierStatus === 'MINTED'
|| identifier.identifierStatus == null) {
// The item's DOI is pending, minted or null.
// It isn't registered, reserved, queued for registration or reservation or update, deleted
// or queued for deletion.
pending = true;
}
}
});
}
// If there is no DOI, or a pending/minted/null DOI, and the config is enabled, return true
return registerConfigEnabled$.pipe(
map((enabled: boolean) => {
return enabled && (pending || no_doi);
}
));
}),
// Switch map pushes the register DOI operation onto a copy of the base array then returns to the pipe
switchMap((showDoi: boolean) => {
let ops = [...operations];
if (showDoi) {
ops.push(new ItemOperation('register-doi', this.getCurrentUrl(item) + '/register-doi', FeatureID.CanRegisterDOI, true));
}
return ops;
}),
// Merge map checks and transforms each operation in the array based on whether it is authorized or not (disabled)
mergeMap((op: ItemOperation) => {
if (hasValue(op.featureID)) {
return this.authorizationService.isAuthorized(op.featureID, item.self).pipe(
distinctUntilChanged(),
map((authorized) => new ItemOperation(operation.operationKey, operation.operationUrl, operation.featureID, !authorized, authorized))
map((authorized) => new ItemOperation(op.operationKey, op.operationUrl, op.featureID, !authorized, authorized))
);
} else {
return [operation];
return [op];
}
}),
toArray()
).subscribe((ops) => this.operations$.next(ops));
// Wait for all operations to be emitted and return as an array
toArray(),
).subscribe((data) => {
// Update the operations$ subject that draws the administrative buttons on the status page
this.operations$.next(data);
});
});
this.itemPageRoute$ = this.itemRD$.pipe(
getAllSucceededRemoteDataPayload(),
map((item) => getItemPageRoute(item))
);
}
/**
@@ -127,4 +218,10 @@ export class ItemStatusComponent implements OnInit {
return hasValue(operation) ? operation.operationKey : undefined;
}
ngOnDestroy(): void {
this.subs
.filter((subscription) => hasValue(subscription))
.forEach((subscription) => subscription.unsubscribe());
}
}

View File

@@ -1,16 +1,38 @@
<ds-metadata-field-wrapper [label]="label | translate">
<ng-container *ngFor="let mdValue of mdValues; let last=last;">
<ng-container *ngTemplateOutlet="(renderMarkdown ? markdown : simple); context: {value: mdValue.value}">
<!--
Choose a template. Priority: markdown, link, browse link.
-->
<ng-container *ngTemplateOutlet="(renderMarkdown ? markdown : (hasLink(mdValue) ? link : (hasBrowseDefinition() ? browselink : simple)));
context: {value: mdValue.value}">
</ng-container>
<span class="separator" *ngIf="!last" [innerHTML]="separator"></span>
</ng-container>
</ds-metadata-field-wrapper>
<!-- Render value as markdown -->
<ng-template #markdown let-value="value">
<span class="dont-break-out" [innerHTML]="value | dsMarkdown | async">
</span>
</ng-template>
<!-- Render value as a link (href and label) -->
<ng-template #link let-value="value">
<a class="dont-break-out ds-simple-metadata-link" target="_blank" [href]="value">
{{value}}
</a>
</ng-template>
<!-- Render simple value in a span -->
<ng-template #simple let-value="value">
<span class="dont-break-out preserve-line-breaks">{{value}}</span>
</ng-template>
<!-- Render value as a link to browse index -->
<ng-template #browselink let-value="value">
<a class="dont-break-out preserve-line-breaks ds-browse-link"
[routerLink]="['/browse', browseDefinition.id]"
[queryParams]="getQueryParams(value)">
{{value}}
</a>
</ng-template>

View File

@@ -52,6 +52,7 @@ describe('MetadataValuesComponent', () => {
comp.mdValues = mockMetadata;
comp.separator = mockSeperator;
comp.label = mockLabel;
comp.urlRegex = /^.*test.*$/;
fixture.detectChanges();
}));
@@ -67,4 +68,9 @@ describe('MetadataValuesComponent', () => {
expect(separators.length).toBe(mockMetadata.length - 1);
});
it('should correctly detect a pattern on string containing "test"', () => {
const mdValue = {value: 'This is a test value'} as MetadataValue;
expect(comp.hasLink(mdValue)).toBe(true);
});
});

View File

@@ -1,6 +1,8 @@
import { Component, Inject, Input, OnChanges, SimpleChanges } from '@angular/core';
import { MetadataValue } from '../../../core/shared/metadata.models';
import { APP_CONFIG, AppConfig } from '../../../../config/app-config.interface';
import { BrowseDefinition } from '../../../core/shared/browse-definition.model';
import { hasValue } from '../../../shared/empty.util';
/**
* This component renders the configured 'values' into the ds-metadata-field-wrapper component.
@@ -40,12 +42,51 @@ export class MetadataValuesComponent implements OnChanges {
*/
@Input() enableMarkdown = false;
/**
* Whether any valid HTTP(S) URL should be rendered as a link
*/
@Input() urlRegex?;
/**
* This variable will be true if both {@link environment.markdown.enabled} and {@link enableMarkdown} are true.
*/
renderMarkdown;
@Input() browseDefinition?: BrowseDefinition;
ngOnChanges(changes: SimpleChanges): void {
this.renderMarkdown = !!this.appConfig.markdown.enabled && this.enableMarkdown;
}
/**
* Does this metadata value have a configured link to a browse definition?
*/
hasBrowseDefinition(): boolean {
return hasValue(this.browseDefinition);
}
/**
* Does this metadata value have a valid URL that should be rendered as a link?
* @param value A MetadataValue being displayed
*/
hasLink(value: MetadataValue): boolean {
if (hasValue(this.urlRegex)) {
const pattern = new RegExp(this.urlRegex);
return pattern.test(value.value);
}
return false;
}
/**
* Return a queryparams object for use in a link, with the key dependent on whether this browse
* definition is metadata browse, or item browse
* @param value the specific metadata value being linked
*/
getQueryParams(value) {
let queryParams = {startsWith: value};
if (this.browseDefinition.metadataBrowse) {
return {value: value};
}
return queryParams;
}
}

View File

@@ -7,10 +7,8 @@
<div *ngIf="!item.isWithdrawn || (isAdmin$|async)" class="full-item-info">
<div class="d-flex flex-row">
<ds-item-page-title-field class="mr-auto" [item]="item"></ds-item-page-title-field>
<div class="pl-2 space-children-mr">
<ds-dso-page-edit-button [pageRoute]="itemPageRoute$ | async" [dso]="item"
[tooltipMsg]="'item.page.edit'"></ds-dso-page-edit-button>
</div>
<ds-dso-edit-menu></ds-dso-edit-menu>
</div>
<div class="simple-view-link my-3" *ngIf="!fromSubmissionObject">
<a class="btn btn-outline-primary" [routerLink]="[(itemPageRoute$ | async)]">

View File

@@ -18,6 +18,7 @@ import { BitstreamRequestACopyPageComponent } from './bitstreams/request-a-copy/
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';
import { DSOEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver';
@NgModule({
imports: [
@@ -26,7 +27,8 @@ import { OrcidPageGuard } from './orcid-page/orcid-page.guard';
path: ':id',
resolve: {
dso: ItemPageResolver,
breadcrumb: ItemBreadcrumbResolver
breadcrumb: ItemBreadcrumbResolver,
menu: DSOEditMenuResolver
},
runGuardsAndResolvers: 'always',
children: [

View File

@@ -39,7 +39,6 @@ import { MediaViewerImageComponent } from './media-viewer/media-viewer-image/med
import { NgxGalleryModule } from '@kolkov/ngx-gallery';
import { MiradorViewerComponent } from './mirador-viewer/mirador-viewer.component';
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';
@@ -53,6 +52,7 @@ import { ItemVersionsModule } from './versions/item-versions.module';
import { BitstreamRequestACopyPageComponent } from './bitstreams/request-a-copy/bitstream-request-a-copy-page.component';
import { FileSectionComponent } from './simple/field-components/file-section/file-section.component';
import { ItemSharedModule } from './item-shared.module';
import { DsoPageModule } from '../shared/dso-page/dso-page.module';
const ENTRY_COMPONENTS = [
@@ -91,7 +91,6 @@ const DECLARATIONS = [
OrcidSyncSettingsComponent,
OrcidQueueComponent,
ItemAlertsComponent,
VersionedItemComponent,
BitstreamRequestACopyPageComponent,
];
@@ -109,7 +108,8 @@ const DECLARATIONS = [
NgxGalleryModule,
NgbAccordionModule,
ResultsBackButtonModule,
UploadModule
UploadModule,
DsoPageModule,
],
declarations: [
...DECLARATIONS,

View File

@@ -10,16 +10,14 @@ import { TabbedRelatedEntitiesSearchComponent } from './simple/related-entities/
import { ItemVersionsDeleteModalComponent } from './versions/item-versions-delete-modal/item-versions-delete-modal.component';
import { ItemVersionsSummaryModalComponent } from './versions/item-versions-summary-modal/item-versions-summary-modal.component';
import { MetadataValuesComponent } from './field-components/metadata-values/metadata-values.component';
import { DsoPageVersionButtonComponent } from '../shared/dso-page/dso-page-version-button/dso-page-version-button.component';
import { PersonPageClaimButtonComponent } from '../shared/dso-page/person-page-claim-button/person-page-claim-button.component';
import { GenericItemPageFieldComponent } from './simple/field-components/specific-field/generic/generic-item-page-field.component';
import { MetadataRepresentationListComponent } from './simple/metadata-representation-list/metadata-representation-list.component';
import { RelatedItemsComponent } from './simple/related-items/related-items-component';
import { DsoPageOrcidButtonComponent } from '../shared/dso-page/dso-page-orcid-button/dso-page-orcid-button.component';
const ENTRY_COMPONENTS = [
ItemVersionsDeleteModalComponent,
ItemVersionsSummaryModalComponent,
];
const COMPONENTS = [
@@ -27,12 +25,9 @@ const COMPONENTS = [
RelatedEntitiesSearchComponent,
TabbedRelatedEntitiesSearchComponent,
MetadataValuesComponent,
DsoPageVersionButtonComponent,
PersonPageClaimButtonComponent,
GenericItemPageFieldComponent,
MetadataRepresentationListComponent,
RelatedItemsComponent,
DsoPageOrcidButtonComponent
];
@NgModule({

View File

@@ -7,6 +7,8 @@ import { SharedModule } from '../../../../../shared/shared.module';
import { APP_CONFIG } from '../../../../../../config/app-config.interface';
import { environment } from '../../../../../../environments/environment';
import { By } from '@angular/platform-browser';
import { BrowseDefinitionDataService } from '../../../../../core/browse/browse-definition-data.service';
import { BrowseDefinitionDataServiceStub } from '../../../../../shared/testing/browse-definition-data-service.stub';
let comp: ItemPageAbstractFieldComponent;
let fixture: ComponentFixture<ItemPageAbstractFieldComponent>;
@@ -25,6 +27,7 @@ describe('ItemPageAbstractFieldComponent', () => {
],
providers: [
{ provide: APP_CONFIG, useValue: environment },
{ provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub }
],
declarations: [ItemPageAbstractFieldComponent],
schemas: [NO_ERRORS_SCHEMA]

View File

@@ -3,10 +3,12 @@ import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock';
import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component';
import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec';
import { mockItemWithMetadataFieldsAndValue } from '../item-page-field.component.spec';
import { ItemPageAuthorFieldComponent } from './item-page-author-field.component';
import { APP_CONFIG } from '../../../../../../config/app-config.interface';
import { environment } from '../../../../../../environments/environment';
import { BrowseDefinitionDataService } from '../../../../../core/browse/browse-definition-data.service';
import { BrowseDefinitionDataServiceStub } from '../../../../../shared/testing/browse-definition-data-service.stub';
let comp: ItemPageAuthorFieldComponent;
let fixture: ComponentFixture<ItemPageAuthorFieldComponent>;
@@ -25,6 +27,7 @@ describe('ItemPageAuthorFieldComponent', () => {
})],
providers: [
{ provide: APP_CONFIG, useValue: environment },
{ provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub }
],
declarations: [ItemPageAuthorFieldComponent, MetadataValuesComponent],
schemas: [NO_ERRORS_SCHEMA]
@@ -37,7 +40,7 @@ describe('ItemPageAuthorFieldComponent', () => {
beforeEach(waitForAsync(() => {
fixture = TestBed.createComponent(ItemPageAuthorFieldComponent);
comp = fixture.componentInstance;
comp.item = mockItemWithMetadataFieldAndValue(field, mockValue);
comp.item = mockItemWithMetadataFieldsAndValue([field], mockValue);
fixture.detectChanges();
}));

View File

@@ -3,10 +3,12 @@ import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock';
import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component';
import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec';
import { mockItemWithMetadataFieldsAndValue } from '../item-page-field.component.spec';
import { ItemPageDateFieldComponent } from './item-page-date-field.component';
import { APP_CONFIG } from '../../../../../../config/app-config.interface';
import { environment } from '../../../../../../environments/environment';
import { BrowseDefinitionDataService } from '../../../../../core/browse/browse-definition-data.service';
import { BrowseDefinitionDataServiceStub } from '../../../../../shared/testing/browse-definition-data-service.stub';
let comp: ItemPageDateFieldComponent;
let fixture: ComponentFixture<ItemPageDateFieldComponent>;
@@ -25,6 +27,7 @@ describe('ItemPageDateFieldComponent', () => {
})],
providers: [
{ provide: APP_CONFIG, useValue: environment },
{ provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub }
],
declarations: [ItemPageDateFieldComponent, MetadataValuesComponent],
schemas: [NO_ERRORS_SCHEMA]
@@ -36,7 +39,7 @@ describe('ItemPageDateFieldComponent', () => {
beforeEach(waitForAsync(() => {
fixture = TestBed.createComponent(ItemPageDateFieldComponent);
comp = fixture.componentInstance;
comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue);
comp.item = mockItemWithMetadataFieldsAndValue([mockField], mockValue);
fixture.detectChanges();
}));

View File

@@ -3,10 +3,12 @@ import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock';
import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component';
import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec';
import { mockItemWithMetadataFieldsAndValue } from '../item-page-field.component.spec';
import { GenericItemPageFieldComponent } from './generic-item-page-field.component';
import { APP_CONFIG } from '../../../../../../config/app-config.interface';
import { environment } from '../../../../../../environments/environment';
import { BrowseDefinitionDataService } from '../../../../../core/browse/browse-definition-data.service';
import { BrowseDefinitionDataServiceStub } from '../../../../../shared/testing/browse-definition-data-service.stub';
let comp: GenericItemPageFieldComponent;
let fixture: ComponentFixture<GenericItemPageFieldComponent>;
@@ -27,6 +29,7 @@ describe('GenericItemPageFieldComponent', () => {
})],
providers: [
{ provide: APP_CONFIG, useValue: environment },
{ provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub }
],
declarations: [GenericItemPageFieldComponent, MetadataValuesComponent],
schemas: [NO_ERRORS_SCHEMA]
@@ -38,7 +41,7 @@ describe('GenericItemPageFieldComponent', () => {
beforeEach(waitForAsync(() => {
fixture = TestBed.createComponent(GenericItemPageFieldComponent);
comp = fixture.componentInstance;
comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue);
comp.item = mockItemWithMetadataFieldsAndValue([mockField], mockValue);
comp.fields = mockFields;
comp.label = mockLabel;
fixture.detectChanges();

View File

@@ -40,5 +40,10 @@ export class GenericItemPageFieldComponent extends ItemPageFieldComponent {
*/
@Input() enableMarkdown = false;
/**
* Whether any valid HTTP(S) URL should be rendered as a link
*/
@Input() urlRegex?: string;
}

View File

@@ -4,5 +4,7 @@
[separator]="separator"
[label]="label"
[enableMarkdown]="enableMarkdown"
[urlRegex]="urlRegex"
[browseDefinition]="browseDefinition|async"
></ds-metadata-values>
</div>

View File

@@ -12,6 +12,10 @@ import { environment } from '../../../../../environments/environment';
import { MarkdownPipe } from '../../../../shared/utils/markdown.pipe';
import { SharedModule } from '../../../../shared/shared.module';
import { APP_CONFIG } from '../../../../../config/app-config.interface';
import { By } from '@angular/platform-browser';
import { BrowseDefinitionDataService } from '../../../../core/browse/browse-definition-data.service';
import { BrowseDefinitionDataServiceStub } from '../../../../shared/testing/browse-definition-data-service.stub';
import { RouterTestingModule } from '@angular/router/testing';
let comp: ItemPageFieldComponent;
let fixture: ComponentFixture<ItemPageFieldComponent>;
@@ -20,7 +24,9 @@ let markdownSpy;
const mockValue = 'test value';
const mockField = 'dc.test';
const mockLabel = 'test label';
const mockFields = [mockField];
const mockAuthorField = 'dc.contributor.author';
const mockDateIssuedField = 'dc.date.issued';
const mockFields = [mockField, mockAuthorField, mockDateIssuedField];
describe('ItemPageFieldComponent', () => {
@@ -34,6 +40,7 @@ describe('ItemPageFieldComponent', () => {
const buildTestEnvironment = async () => {
await TestBed.configureTestingModule({
imports: [
RouterTestingModule.withRoutes([]),
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
@@ -44,6 +51,7 @@ describe('ItemPageFieldComponent', () => {
],
providers: [
{ provide: APP_CONFIG, useValue: appConfig },
{ provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub }
],
declarations: [ItemPageFieldComponent, MetadataValuesComponent],
schemas: [NO_ERRORS_SCHEMA]
@@ -53,7 +61,7 @@ describe('ItemPageFieldComponent', () => {
markdownSpy = spyOn(MarkdownPipe.prototype, 'transform');
fixture = TestBed.createComponent(ItemPageFieldComponent);
comp = fixture.componentInstance;
comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue);
comp.item = mockItemWithMetadataFieldsAndValue(mockFields, mockValue);
comp.fields = mockFields;
comp.label = mockLabel;
fixture.detectChanges();
@@ -126,17 +134,57 @@ describe('ItemPageFieldComponent', () => {
expect(markdownSpy).toHaveBeenCalled();
});
});
});
describe('test rendering of configured browse links', () => {
beforeEach(() => {
fixture.detectChanges();
});
waitForAsync(() => {
it('should have a browse link', () => {
expect(fixture.debugElement.query(By.css('a.ds-browse-link')).nativeElement.innerHTML).toContain(mockValue);
});
});
});
describe('test rendering of configured regex-based links', () => {
beforeEach(() => {
comp.urlRegex = '^test';
fixture.detectChanges();
});
waitForAsync(() => {
it('should have a rendered (non-browse) link since the value matches ^test', () => {
expect(fixture.debugElement.query(By.css('a.ds-simple-metadata-link')).nativeElement.innerHTML).toContain(mockValue);
});
});
});
describe('test skipping of configured links that do NOT match regex', () => {
beforeEach(() => {
comp.urlRegex = '^nope';
fixture.detectChanges();
});
beforeEach(waitForAsync(() => {
it('should NOT have a rendered (non-browse) link since the value matches ^test', () => {
expect(fixture.debugElement.query(By.css('a.ds-simple-metadata-link'))).toBeNull();
});
}));
});
});
export function mockItemWithMetadataFieldAndValue(field: string, value: string): Item {
export function mockItemWithMetadataFieldsAndValue(fields: string[], value: string): Item {
const item = Object.assign(new Item(), {
bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])),
metadata: new MetadataMap()
});
item.metadata[field] = [{
language: 'en_US',
value: value
}] as MetadataValue[];
fields.forEach((field: string) => {
item.metadata[field] = [{
language: 'en_US',
value: value
}] as MetadataValue[];
});
return item;
}

View File

@@ -1,5 +1,10 @@
import { Component, Input } from '@angular/core';
import { Item } from '../../../../core/shared/item.model';
import { map } from 'rxjs/operators';
import { Observable } from 'rxjs';
import { BrowseDefinition } from '../../../../core/shared/browse-definition.model';
import { BrowseDefinitionDataService } from '../../../../core/browse/browse-definition-data.service';
import { getRemoteDataPayload } from '../../../../core/shared/operators';
/**
* This component can be used to represent metadata on a simple item page.
@@ -12,6 +17,9 @@ import { Item } from '../../../../core/shared/item.model';
})
export class ItemPageFieldComponent {
constructor(protected browseDefinitionDataService: BrowseDefinitionDataService) {
}
/**
* The item to display metadata for
*/
@@ -38,4 +46,19 @@ export class ItemPageFieldComponent {
*/
separator = '<br/>';
/**
* Whether any valid HTTP(S) URL should be rendered as a link
*/
urlRegex?: string;
/**
* Return browse definition that matches any field used in this component if it is configured as a browse
* link in dspace.cfg (webui.browse.link.<n>)
*/
get browseDefinition(): Observable<BrowseDefinition> {
return this.browseDefinitionDataService.findByFields(this.fields).pipe(
getRemoteDataPayload(),
map((def) => def)
);
}
}

View File

@@ -3,7 +3,7 @@ import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock';
import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component';
import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec';
import { mockItemWithMetadataFieldsAndValue } from '../item-page-field.component.spec';
import { ItemPageTitleFieldComponent } from './item-page-title-field.component';
let comp: ItemPageTitleFieldComponent;
@@ -31,7 +31,7 @@ describe('ItemPageTitleFieldComponent', () => {
beforeEach(waitForAsync(() => {
fixture = TestBed.createComponent(ItemPageTitleFieldComponent);
comp = fixture.componentInstance;
comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue);
comp.item = mockItemWithMetadataFieldsAndValue([mockField], mockValue);
fixture.detectChanges();
}));

View File

@@ -2,11 +2,13 @@ import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock';
import { mockItemWithMetadataFieldAndValue } from '../item-page-field.component.spec';
import { mockItemWithMetadataFieldsAndValue } from '../item-page-field.component.spec';
import { ItemPageUriFieldComponent } from './item-page-uri-field.component';
import { MetadataUriValuesComponent } from '../../../../field-components/metadata-uri-values/metadata-uri-values.component';
import { environment } from '../../../../../../environments/environment';
import { APP_CONFIG } from '../../../../../../config/app-config.interface';
import { BrowseDefinitionDataService } from '../../../../../core/browse/browse-definition-data.service';
import { BrowseDefinitionDataServiceStub } from '../../../../../shared/testing/browse-definition-data-service.stub';
let comp: ItemPageUriFieldComponent;
let fixture: ComponentFixture<ItemPageUriFieldComponent>;
@@ -26,6 +28,7 @@ describe('ItemPageUriFieldComponent', () => {
})],
providers: [
{ provide: APP_CONFIG, useValue: environment },
{ provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub }
],
declarations: [ItemPageUriFieldComponent, MetadataUriValuesComponent],
schemas: [NO_ERRORS_SCHEMA]
@@ -37,7 +40,7 @@ describe('ItemPageUriFieldComponent', () => {
beforeEach(waitForAsync(() => {
fixture = TestBed.createComponent(ItemPageUriFieldComponent);
comp = fixture.componentInstance;
comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue);
comp.item = mockItemWithMetadataFieldsAndValue([mockField], mockValue);
comp.fields = [mockField];
comp.label = mockLabel;
fixture.detectChanges();

View File

@@ -11,12 +11,7 @@
<div class="d-flex flex-row">
<ds-item-page-title-field [item]="object" class="mr-auto">
</ds-item-page-title-field>
<div class="pl-2 space-children-mr">
<ds-dso-page-version-button (newVersionEvent)="onCreateNewVersion()" [dso]="object"
[tooltipMsgCreate]="'item.page.version.create'"
[tooltipMsgHasDraft]="'item.page.version.hasDraft'"></ds-dso-page-version-button>
<ds-dso-page-edit-button [pageRoute]="itemPageRoute" [dso]="object" [tooltipMsg]="'publication.page.edit'"></ds-dso-page-edit-button>
</div>
<ds-dso-edit-menu></ds-dso-edit-menu>
</div>
<div class="row">
<div class="col-xs-12 col-md-4">

View File

@@ -36,6 +36,10 @@ 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';
import { BrowseDefinitionDataService } from '../../../../core/browse/browse-definition-data.service';
import {
BrowseDefinitionDataServiceStub
} from '../../../../shared/testing/browse-definition-data-service.stub';
const noMetadata = new MetadataMap();
@@ -87,7 +91,8 @@ describe('PublicationComponent', () => {
{ provide: BitstreamDataService, useValue: mockBitstreamDataService },
{ provide: WorkspaceitemDataService, useValue: {} },
{ provide: SearchService, useValue: {} },
{ provide: RouteService, useValue: mockRouteService }
{ provide: RouteService, useValue: mockRouteService },
{ provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub },
],
schemas: [NO_ERRORS_SCHEMA]

View File

@@ -1,7 +1,7 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
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';
import { ItemComponent } from '../shared/item.component';
/**
* Component that represents a publication Item page
@@ -14,6 +14,6 @@ import { VersionedItemComponent } from '../versioned-item/versioned-item.compone
templateUrl: './publication.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PublicationComponent extends VersionedItemComponent {
export class PublicationComponent extends ItemComponent {
}

View File

@@ -23,7 +23,9 @@ import { UUIDService } from '../../../../core/shared/uuid.service';
import { isNotEmpty } from '../../../../shared/empty.util';
import { TranslateLoaderMock } from '../../../../shared/mocks/translate-loader.mock';
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils';
import {
createSuccessfulRemoteDataObject$
} from '../../../../shared/remote-data.utils';
import { TruncatableService } from '../../../../shared/truncatable/truncatable.service';
import { TruncatePipe } from '../../../../shared/utils/truncate.pipe';
import { GenericItemPageFieldComponent } from '../../field-components/specific-field/generic/generic-item-page-field.component';
@@ -38,6 +40,10 @@ import { VersionHistoryDataService } from '../../../../core/data/version-history
import { RouterTestingModule } from '@angular/router/testing';
import { AuthorizationDataService } from '../../../../core/data/feature-authorization/authorization-data.service';
import { ResearcherProfileDataService } from '../../../../core/profile/researcher-profile-data.service';
import { BrowseDefinitionDataService } from '../../../../core/browse/browse-definition-data.service';
import {
BrowseDefinitionDataServiceStub
} from '../../../../shared/testing/browse-definition-data-service.stub';
import { buildPaginatedList } from '../../../../core/data/paginated-list.model';
import { PageInfo } from '../../../../core/shared/page-info.model';
@@ -125,7 +131,8 @@ export function getItemPageFieldsTest(mockItem: Item, component) {
{ provide: SearchService, useValue: {} },
{ provide: RouteService, useValue: mockRouteService },
{ provide: AuthorizationDataService, useValue: authorizationService },
{ provide: ResearcherProfileDataService, useValue: {} }
{ provide: ResearcherProfileDataService, useValue: {} },
{ provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub },
],
schemas: [NO_ERRORS_SCHEMA]
@@ -444,7 +451,7 @@ describe('ItemComponent', () => {
{ provide: SearchService, useValue: {} },
{ provide: RouteService, useValue: mockRouteService },
{ provide: AuthorizationDataService, useValue: {} },
{ provide: ResearcherProfileDataService, useValue: {} }
{ provide: ResearcherProfileDataService, useValue: {} },
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(ItemComponent, {

View File

@@ -12,12 +12,7 @@
<div class="d-flex flex-row">
<ds-item-page-title-field [item]="object" class="mr-auto">
</ds-item-page-title-field>
<div class="pl-2 space-children-mr">
<ds-dso-page-version-button (newVersionEvent)="onCreateNewVersion()" [dso]="object"
[tooltipMsgCreate]="'item.page.version.create'"
[tooltipMsgHasDraft]="'item.page.version.hasDraft'"></ds-dso-page-version-button>
<ds-dso-page-edit-button [pageRoute]="itemPageRoute" [dso]="object" [tooltipMsg]="'item.page.edit'"></ds-dso-page-edit-button>
</div>
<ds-dso-edit-menu></ds-dso-edit-menu>
</div>
<div class="row">
<div class="col-xs-12 col-md-4">

View File

@@ -37,6 +37,10 @@ import { RouterTestingModule } from '@angular/router/testing';
import { WorkspaceitemDataService } from '../../../../core/submission/workspaceitem-data.service';
import { SearchService } from '../../../../core/shared/search/search.service';
import { ItemVersionsSharedService } from '../../../versions/item-versions-shared.service';
import { BrowseDefinitionDataService } from '../../../../core/browse/browse-definition-data.service';
import {
BrowseDefinitionDataServiceStub
} from '../../../../shared/testing/browse-definition-data-service.stub';
const noMetadata = new MetadataMap();
@@ -90,7 +94,8 @@ describe('UntypedItemComponent', () => {
{ provide: SearchService, useValue: {} },
{ provide: ItemDataService, useValue: {} },
{ provide: ItemVersionsSharedService, useValue: {} },
{ provide: RouteService, useValue: mockRouteService }
{ provide: RouteService, useValue: mockRouteService },
{ provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub },
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(UntypedItemComponent, {

View File

@@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component } from '@angular/core';
import { Item } from '../../../../core/shared/item.model';
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';
import { ItemComponent } from '../shared/item.component';
/**
* Component that represents a publication Item page
@@ -15,6 +15,6 @@ import { VersionedItemComponent } from '../versioned-item/versioned-item.compone
templateUrl: './untyped-item.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UntypedItemComponent extends VersionedItemComponent {
export class UntypedItemComponent extends ItemComponent {
}

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