Merge remote-tracking branch 'upstream/main' into w2p-98855_themeable-file-download-link_contribute-main

# Conflicts:
#	src/themes/custom/eager-theme.module.ts
This commit is contained in:
Alexandre Vryghem
2023-02-12 23:22:00 +01:00
303 changed files with 8561 additions and 1747 deletions

View File

@@ -32,12 +32,60 @@ cache:
# NOTE: how long should objects be cached for by default # NOTE: how long should objects be cached for by default
msToLive: msToLive:
default: 900000 # 15 minutes 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: autoSync:
defaultTime: 0 defaultTime: 0
maxBufferSize: 100 maxBufferSize: 100
timePerMethod: timePerMethod:
PATCH: 3 # time in seconds 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 # Authentication settings
auth: auth:

View File

@@ -99,6 +99,7 @@
"fast-json-patch": "^3.0.0-1", "fast-json-patch": "^3.0.0-1",
"filesize": "^6.1.0", "filesize": "^6.1.0",
"http-proxy-middleware": "^1.0.5", "http-proxy-middleware": "^1.0.5",
"isbot": "^3.6.5",
"js-cookie": "2.2.1", "js-cookie": "2.2.1",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"json5": "^2.2.2", "json5": "^2.2.2",
@@ -106,6 +107,7 @@
"jwt-decode": "^3.1.2", "jwt-decode": "^3.1.2",
"klaro": "^0.7.18", "klaro": "^0.7.18",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lru-cache": "^7.14.1",
"markdown-it": "^13.0.1", "markdown-it": "^13.0.1",
"markdown-it-mathjax3": "^4.3.1", "markdown-it-mathjax3": "^4.3.1",
"mirador": "^3.3.0", "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 */ /* eslint-enable import/no-namespace */
import axios from 'axios'; import axios from 'axios';
import LRU from 'lru-cache';
import isbot from 'isbot';
import { createCertificate } from 'pem'; import { createCertificate } from 'pem';
import { createServer } from 'https'; import { createServer } from 'https';
import { json } from 'body-parser'; import { json } from 'body-parser';
@@ -35,7 +37,6 @@ import { json } from 'body-parser';
import { existsSync, readFileSync } from 'fs'; import { existsSync, readFileSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { APP_BASE_HREF } from '@angular/common';
import { enableProdMode } from '@angular/core'; import { enableProdMode } from '@angular/core';
import { ngExpressEngine } from '@nguniversal/express-engine'; 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 { APP_CONFIG, AppConfig } from './src/config/app-config.interface';
import { extendEnvironmentWithAppConfig } from './src/config/config.util'; import { extendEnvironmentWithAppConfig } from './src/config/config.util';
import { logStartupMessage } from './startup-message'; 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 * 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. // Set path fir IIIF viewer.
const IIIF_VIEWER = join(process.cwd(), 'dist/iiif'); 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 cookieParser = require('cookie-parser');
const appConfig: AppConfig = buildAppConfig(join(DIST_FOLDER, 'assets/config.json')); 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 // extend environment with app config for server
extendEnvironmentWithAppConfig(environment, appConfig); extendEnvironmentWithAppConfig(environment, appConfig);
@@ -87,10 +96,12 @@ export function app() {
/* /*
* If production mode is enabled in the environment file: * If production mode is enabled in the environment file:
* - Enable Angular's production mode * - 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) * - Enable compression for SSR reponses. See [compression](https://github.com/expressjs/compression)
*/ */
if (environment.production) { if (environment.production) {
enableProdMode(); enableProdMode();
initCache();
server.use(compression({ server.use(compression({
// only compress responses we've marked as SSR // only compress responses we've marked as SSR
// otherwise, this middleware may compress files we've chosen not to compress via compression-webpack-plugin // 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 * Add cookie parser middleware
* See [morgan](https://github.com/expressjs/cookie-parser) * See [cookie-parser](https://github.com/expressjs/cookie-parser)
*/ */
server.use(cookieParser()); server.use(cookieParser());
/* /*
* Add parser for request bodies * Add JSON parser for request bodies
* See [morgan](https://github.com/expressjs/body-parser) * See [body-parser](https://github.com/expressjs/body-parser)
*/ */
server.use(json()); server.use(json());
@@ -186,7 +197,7 @@ export function app() {
* Serve static resources (images, i18n messages, …) * Serve static resources (images, i18n messages, …)
* Handle pre-compressed files with [express-static-gzip](https://github.com/tkoenig89/express-static-gzip) * 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, index: false,
enableBrotli: true, enableBrotli: true,
orderPreference: ['br', 'gzip'], orderPreference: ['br', 'gzip'],
@@ -202,8 +213,11 @@ export function app() {
*/ */
server.get('/app/health', healthCheck); 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); server.use(environment.ui.nameSpace, router);
@@ -215,60 +229,242 @@ export function app() {
*/ */
function ngApp(req, res) { function ngApp(req, res) {
if (environment.universal.preboot) { if (environment.universal.preboot) {
res.render(indexHtml, { // Render the page to user via SSR (server side rendering)
req, serverSideRender(req, res);
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
}]
});
}
});
} else { } else {
// If preboot is disabled, just serve the client // If preboot is disabled, just serve the client
console.log('Universal off, serving for direct CSR'); console.log('Universal off, serving for direct client-side rendering (CSR)');
res.render(indexHtml, { clientSideRender(req, res);
req, }
providers: [{ }
provide: APP_BASE_HREF,
useValue: req.baseUrl /**
}] * 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 * Return whether bot-specific server side caching is enabled in configuration.
* The cache control value can be configured in the environments file and defaults to max-age=60
*/ */
function cacheControl(req, res, next) { function botCacheEnabled(): boolean {
// instruct browser to revalidate // Caching is only enabled if SSR is enabled AND
res.header('Cache-Control', environment.cache.control || 'max-age=60'); // "max" pages to cache is greater than zero
next(); 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

@@ -27,7 +27,10 @@ export const ValidateEmailErrorStateMatcher: DynamicErrorMessagesMatcher =
SharedModule, SharedModule,
RouterModule, RouterModule,
AccessControlRoutingModule, AccessControlRoutingModule,
FormModule FormModule,
],
exports: [
MembersListComponent,
], ],
declarations: [ declarations: [
EPeopleRegistryComponent, EPeopleRegistryComponent,
@@ -35,7 +38,7 @@ export const ValidateEmailErrorStateMatcher: DynamicErrorMessagesMatcher =
GroupsRegistryComponent, GroupsRegistryComponent,
GroupFormComponent, GroupFormComponent,
SubgroupsListComponent, SubgroupsListComponent,
MembersListComponent MembersListComponent,
], ],
providers: [ providers: [
{ {

View File

@@ -65,18 +65,20 @@
</td> </td>
<td class="align-middle"> <td class="align-middle">
<div class="btn-group edit-field"> <div class="btn-group edit-field">
<button *ngIf="(ePerson.memberOfGroup)" <button *ngIf="ePerson.memberOfGroup"
(click)="deleteMemberFromGroup(ePerson)" (click)="deleteMemberFromGroup(ePerson)"
class="btn btn-outline-danger btn-sm" [disabled]="actionConfig.remove.disabled"
[ngClass]="['btn btn-sm', actionConfig.remove.css]"
title="{{messagePrefix + '.table.edit.buttons.remove' | translate: {name: ePerson.eperson.name} }}"> title="{{messagePrefix + '.table.edit.buttons.remove' | translate: {name: ePerson.eperson.name} }}">
<i class="fas fa-trash-alt fa-fw"></i> <i [ngClass]="actionConfig.remove.icon"></i>
</button> </button>
<button *ngIf="!(ePerson.memberOfGroup)" <button *ngIf="!ePerson.memberOfGroup"
(click)="addMemberToGroup(ePerson)" (click)="addMemberToGroup(ePerson)"
class="btn btn-outline-primary btn-sm" [disabled]="actionConfig.add.disabled"
[ngClass]="['btn btn-sm', actionConfig.add.css]"
title="{{messagePrefix + '.table.edit.buttons.add' | translate: {name: ePerson.eperson.name} }}"> title="{{messagePrefix + '.table.edit.buttons.add' | translate: {name: ePerson.eperson.name} }}">
<i class="fas fa-plus fa-fw"></i> <i [ngClass]="actionConfig.add.icon"></i>
</button> </button>
</div> </div>
</td> </td>
@@ -123,10 +125,19 @@
</td> </td>
<td class="align-middle"> <td class="align-middle">
<div class="btn-group edit-field"> <div class="btn-group edit-field">
<button (click)="deleteMemberFromGroup(ePerson)" <button *ngIf="ePerson.memberOfGroup"
class="btn btn-outline-danger btn-sm" (click)="deleteMemberFromGroup(ePerson)"
[disabled]="actionConfig.remove.disabled"
[ngClass]="['btn btn-sm', actionConfig.remove.css]"
title="{{messagePrefix + '.table.edit.buttons.remove' | translate: {name: ePerson.eperson.name} }}"> title="{{messagePrefix + '.table.edit.buttons.remove' | translate: {name: ePerson.eperson.name} }}">
<i class="fas fa-trash-alt fa-fw"></i> <i [ngClass]="actionConfig.remove.icon"></i>
</button>
<button *ngIf="!ePerson.memberOfGroup"
(click)="addMemberToGroup(ePerson)"
[disabled]="actionConfig.add.disabled"
[ngClass]="['btn btn-sm', actionConfig.add.css]"
title="{{messagePrefix + '.table.edit.buttons.add' | translate: {name: ePerson.eperson.name} }}">
<i [ngClass]="actionConfig.add.icon"></i>
</button> </button>
</div> </div>
</td> </td>

View File

@@ -149,6 +149,7 @@ describe('MembersListComponent', () => {
fixture.destroy(); fixture.destroy();
flush(); flush();
component = null; component = null;
fixture.debugElement.nativeElement.remove();
})); }));
it('should create MembersListComponent', inject([MembersListComponent], (comp: MembersListComponent) => { it('should create MembersListComponent', inject([MembersListComponent], (comp: MembersListComponent) => {

View File

@@ -11,7 +11,7 @@ import {
ObservedValueOf, ObservedValueOf,
} from 'rxjs'; } from 'rxjs';
import { defaultIfEmpty, map, mergeMap, switchMap, take } from 'rxjs/operators'; import { defaultIfEmpty, map, mergeMap, switchMap, take } from 'rxjs/operators';
import {buildPaginatedList, PaginatedList} from '../../../../core/data/paginated-list.model'; import { buildPaginatedList, PaginatedList } from '../../../../core/data/paginated-list.model';
import { RemoteData } from '../../../../core/data/remote-data'; import { RemoteData } from '../../../../core/data/remote-data';
import { EPersonDataService } from '../../../../core/eperson/eperson-data.service'; import { EPersonDataService } from '../../../../core/eperson/eperson-data.service';
import { GroupDataService } from '../../../../core/eperson/group-data.service'; import { GroupDataService } from '../../../../core/eperson/group-data.service';
@@ -19,11 +19,13 @@ import { EPerson } from '../../../../core/eperson/models/eperson.model';
import { Group } from '../../../../core/eperson/models/group.model'; import { Group } from '../../../../core/eperson/models/group.model';
import { import {
getFirstSucceededRemoteData, getFirstSucceededRemoteData,
getFirstCompletedRemoteData, getAllCompletedRemoteData, getRemoteDataPayload getFirstCompletedRemoteData,
getAllCompletedRemoteData,
getRemoteDataPayload
} from '../../../../core/shared/operators'; } from '../../../../core/shared/operators';
import { NotificationsService } from '../../../../shared/notifications/notifications.service'; import { NotificationsService } from '../../../../shared/notifications/notifications.service';
import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model'; import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model';
import {EpersonDtoModel} from '../../../../core/eperson/models/eperson-dto.model'; import { EpersonDtoModel } from '../../../../core/eperson/models/eperson-dto.model';
import { PaginationService } from '../../../../core/pagination/pagination.service'; import { PaginationService } from '../../../../core/pagination/pagination.service';
/** /**
@@ -35,6 +37,35 @@ enum SubKey {
SearchResultsDTO, SearchResultsDTO,
} }
/**
* The layout config of the buttons in the last column
*/
export interface EPersonActionConfig {
/**
* The css classes that should be added to the button
*/
css?: string;
/**
* Whether the button should be disabled
*/
disabled: boolean;
/**
* The Font Awesome icon that should be used
*/
icon: string;
}
/**
* The {@link EPersonActionConfig} that should be used to display the button. The remove config will be used when the
* {@link EPerson} is already a member of the {@link Group} and the remove config will be used otherwise.
*
* *See {@link actionConfig} for an example*
*/
export interface EPersonListActionConfig {
add: EPersonActionConfig;
remove: EPersonActionConfig;
}
@Component({ @Component({
selector: 'ds-members-list', selector: 'ds-members-list',
templateUrl: './members-list.component.html' templateUrl: './members-list.component.html'
@@ -47,6 +78,20 @@ export class MembersListComponent implements OnInit, OnDestroy {
@Input() @Input()
messagePrefix: string; messagePrefix: string;
@Input()
actionConfig: EPersonListActionConfig = {
add: {
css: 'btn-outline-primary',
disabled: false,
icon: 'fas fa-plus fa-fw',
},
remove: {
css: 'btn-outline-danger',
disabled: false,
icon: 'fas fa-trash-alt fa-fw'
},
};
/** /**
* EPeople being displayed in search result, initially all members, after search result of search * EPeople being displayed in search result, initially all members, after search result of search
*/ */
@@ -91,21 +136,20 @@ export class MembersListComponent implements OnInit, OnDestroy {
// current active group being edited // current active group being edited
groupBeingEdited: Group; groupBeingEdited: Group;
paginationSub: Subscription; constructor(
protected groupDataService: GroupDataService,
public ePersonDataService: EPersonDataService,
constructor(private groupDataService: GroupDataService, protected translateService: TranslateService,
public ePersonDataService: EPersonDataService, protected notificationsService: NotificationsService,
private translateService: TranslateService, protected formBuilder: FormBuilder,
private notificationsService: NotificationsService, protected paginationService: PaginationService,
private formBuilder: FormBuilder, private router: Router
private paginationService: PaginationService, ) {
private router: Router) {
this.currentSearchQuery = ''; this.currentSearchQuery = '';
this.currentSearchScope = 'metadata'; this.currentSearchScope = 'metadata';
} }
ngOnInit() { ngOnInit(): void {
this.searchForm = this.formBuilder.group(({ this.searchForm = this.formBuilder.group(({
scope: 'metadata', scope: 'metadata',
query: '', query: '',
@@ -124,7 +168,7 @@ export class MembersListComponent implements OnInit, OnDestroy {
* @param page the number of the page to retrieve * @param page the number of the page to retrieve
* @private * @private
*/ */
private retrieveMembers(page: number) { retrieveMembers(page: number): void {
this.unsubFrom(SubKey.MembersDTO); this.unsubFrom(SubKey.MembersDTO);
this.subs.set(SubKey.MembersDTO, this.subs.set(SubKey.MembersDTO,
this.paginationService.getCurrentPagination(this.config.id, this.config).pipe( this.paginationService.getCurrentPagination(this.config.id, this.config).pipe(
@@ -135,36 +179,36 @@ export class MembersListComponent implements OnInit, OnDestroy {
} }
); );
}), }),
getAllCompletedRemoteData(), getAllCompletedRemoteData(),
map((rd: RemoteData<any>) => { map((rd: RemoteData<any>) => {
if (rd.hasFailed) { if (rd.hasFailed) {
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure', {cause: rd.errorMessage})); this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure', { cause: rd.errorMessage }));
} else { } else {
return rd; return rd;
} }
}), }),
switchMap((epersonListRD: RemoteData<PaginatedList<EPerson>>) => { switchMap((epersonListRD: RemoteData<PaginatedList<EPerson>>) => {
const dtos$ = observableCombineLatest([...epersonListRD.payload.page.map((member: EPerson) => { const dtos$ = observableCombineLatest([...epersonListRD.payload.page.map((member: EPerson) => {
const dto$: Observable<EpersonDtoModel> = observableCombineLatest( const dto$: Observable<EpersonDtoModel> = observableCombineLatest(
this.isMemberOfGroup(member), (isMember: ObservedValueOf<Observable<boolean>>) => { this.isMemberOfGroup(member), (isMember: ObservedValueOf<Observable<boolean>>) => {
const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel(); const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel();
epersonDtoModel.eperson = member; epersonDtoModel.eperson = member;
epersonDtoModel.memberOfGroup = isMember; epersonDtoModel.memberOfGroup = isMember;
return epersonDtoModel; return epersonDtoModel;
}); });
return dto$; return dto$;
})]); })]);
return dtos$.pipe(defaultIfEmpty([]), map((dtos: EpersonDtoModel[]) => { return dtos$.pipe(defaultIfEmpty([]), map((dtos: EpersonDtoModel[]) => {
return buildPaginatedList(epersonListRD.payload.pageInfo, dtos); return buildPaginatedList(epersonListRD.payload.pageInfo, dtos);
}));
}))
.subscribe((paginatedListOfDTOs: PaginatedList<EpersonDtoModel>) => {
this.ePeopleMembersOfGroupDtos.next(paginatedListOfDTOs);
})); }));
}))
.subscribe((paginatedListOfDTOs: PaginatedList<EpersonDtoModel>) => {
this.ePeopleMembersOfGroupDtos.next(paginatedListOfDTOs);
}));
} }
/** /**
* Whether or not the given ePerson is a member of the group currently being edited * Whether the given ePerson is a member of the group currently being edited
* @param possibleMember EPerson that is a possible member (being tested) of the group currently being edited * @param possibleMember EPerson that is a possible member (being tested) of the group currently being edited
*/ */
isMemberOfGroup(possibleMember: EPerson): Observable<boolean> { isMemberOfGroup(possibleMember: EPerson): Observable<boolean> {
@@ -193,7 +237,7 @@ export class MembersListComponent implements OnInit, OnDestroy {
* @param key The key of the subscription to unsubscribe from * @param key The key of the subscription to unsubscribe from
* @private * @private
*/ */
private unsubFrom(key: SubKey) { protected unsubFrom(key: SubKey) {
if (this.subs.has(key)) { if (this.subs.has(key)) {
this.subs.get(key).unsubscribe(); this.subs.get(key).unsubscribe();
this.subs.delete(key); this.subs.delete(key);
@@ -267,7 +311,7 @@ export class MembersListComponent implements OnInit, OnDestroy {
getAllCompletedRemoteData(), getAllCompletedRemoteData(),
map((rd: RemoteData<any>) => { map((rd: RemoteData<any>) => {
if (rd.hasFailed) { if (rd.hasFailed) {
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure', {cause: rd.errorMessage})); this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure', { cause: rd.errorMessage }));
} else { } else {
return rd; return rd;
} }

View File

@@ -47,6 +47,12 @@ import { BatchImportPageComponent } from './admin-import-batch-page/batch-import
component: BatchImportPageComponent, component: BatchImportPageComponent,
data: { title: 'admin.batch-import.title', breadcrumbKey: 'admin.batch-import' } 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: [ providers: [

View File

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

View File

@@ -17,38 +17,86 @@ describe('AdminSidebarSectionComponent', () => {
const menuService = new MenuServiceStub(); const menuService = new MenuServiceStub();
const iconString = 'test'; const iconString = 'test';
beforeEach(waitForAsync(() => { describe('when not disabled', () => {
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(() => { beforeEach(waitForAsync(() => {
fixture = TestBed.createComponent(AdminSidebarSectionComponent); TestBed.configureTestingModule({
component = fixture.componentInstance; imports: [NoopAnimationsModule, RouterTestingModule, TranslateModule.forRoot()],
spyOn(component as any, 'getMenuItemComponent').and.returnValue(TestComponent); declarations: [AdminSidebarSectionComponent, TestComponent],
fixture.detectChanges(); 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 // 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 { LinkMenuItemModel } from '../../../shared/menu/menu-item/models/link.model';
import { MenuSection } from '../../../shared/menu/menu-section.model'; import { MenuSection } from '../../../shared/menu/menu-section.model';
import { MenuID } from '../../../shared/menu/menu-id.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'; import { Router } from '@angular/router';
/** /**
@@ -26,7 +26,12 @@ export class AdminSidebarSectionComponent extends MenuSectionComponent implement
*/ */
menuID: MenuID = MenuID.ADMIN; menuID: MenuID = MenuID.ADMIN;
itemModel; itemModel;
hasLink: boolean;
/**
* Boolean to indicate whether this section is disabled
*/
isDisabled: boolean;
constructor( constructor(
@Inject('sectionDataProvider') menuSection: MenuSection, @Inject('sectionDataProvider') menuSection: MenuSection,
protected menuService: MenuService, protected menuService: MenuService,
@@ -38,13 +43,13 @@ export class AdminSidebarSectionComponent extends MenuSectionComponent implement
} }
ngOnInit(): void { ngOnInit(): void {
this.hasLink = isNotEmpty(this.itemModel?.link); this.isDisabled = this.itemModel?.disabled || isEmpty(this.itemModel?.link);
super.ngOnInit(); super.ngOnInit();
} }
navigate(event: any): void { navigate(event: any): void {
event.preventDefault(); event.preventDefault();
if (this.hasLink) { if (!this.isDisabled) {
this.router.navigate(this.itemModel.link); this.router.navigate(this.itemModel.link);
} }
} }

View File

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

View File

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

View File

@@ -1,7 +1,9 @@
<div class="container"> <div class="container">
<ng-container *ngVar="(parent$ | async) as parent"> <ng-container *ngVar="(parent$ | async) as parent">
<ng-container *ngIf="parent?.payload as parentContext"> <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 --> <!-- Parent Name -->
<ds-comcol-page-header [name]="parentContext.name"> <ds-comcol-page-header [name]="parentContext.name">
</ds-comcol-page-header> </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 [content]="parentContext.sidebarText" [hasInnerHtml]="true" [title]="'community.page.news'">
</ds-comcol-page-content> </ds-comcol-page-content>
</header> </header>
<ds-dso-edit-menu></ds-dso-edit-menu>
</div>
<!-- Browse-By Links --> <!-- Browse-By Links -->
<ds-themed-comcol-page-browse-by [id]="parentContext.id" [contentType]="parentContext.type"></ds-themed-comcol-page-browse-by> <ds-themed-comcol-page-browse-by [id]="parentContext.id" [contentType]="parentContext.type"></ds-themed-comcol-page-browse-by>
</ng-container></ng-container> </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 { BrowseByDSOBreadcrumbResolver } from './browse-by-dso-breadcrumb.resolver';
import { BrowseByI18nBreadcrumbResolver } from './browse-by-i18n-breadcrumb.resolver'; import { BrowseByI18nBreadcrumbResolver } from './browse-by-i18n-breadcrumb.resolver';
import { ThemedBrowseBySwitcherComponent } from './browse-by-switcher/themed-browse-by-switcher.component'; import { ThemedBrowseBySwitcherComponent } from './browse-by-switcher/themed-browse-by-switcher.component';
import { DSOEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver';
@NgModule({ @NgModule({
imports: [ imports: [
RouterModule.forChild([ RouterModule.forChild([
{ {
path: '', path: '',
resolve: { breadcrumb: BrowseByDSOBreadcrumbResolver }, resolve: {
breadcrumb: BrowseByDSOBreadcrumbResolver,
menu: DSOEditMenuResolver
},
children: [ children: [
{ {
path: ':id', 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 { 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 { ThemedBrowseByTitlePageComponent } from './browse-by-title-page/themed-browse-by-title-page.component';
import { SharedBrowseByModule } from '../shared/browse-by/shared-browse-by.module'; import { SharedBrowseByModule } from '../shared/browse-by/shared-browse-by.module';
import { DsoPageModule } from '../shared/dso-page/dso-page.module';
const ENTRY_COMPONENTS = [ const ENTRY_COMPONENTS = [
// put only entry components that use custom decorator // put only entry components that use custom decorator
@@ -28,6 +29,7 @@ const ENTRY_COMPONENTS = [
SharedBrowseByModule, SharedBrowseByModule,
CommonModule, CommonModule,
ComcolModule, ComcolModule,
DsoPageModule
], ],
declarations: [ declarations: [
BrowseBySwitcherComponent, BrowseBySwitcherComponent,

View File

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

View File

@@ -33,9 +33,7 @@
[title]="'collection.page.news'"> [title]="'collection.page.news'">
</ds-comcol-page-content> </ds-comcol-page-content>
</header> </header>
<div class="pl-2 space-children-mr"> <ds-dso-edit-menu></ds-dso-edit-menu>
<ds-dso-page-edit-button *ngIf="isCollectionAdmin$ | async" [pageRoute]="collectionPageRoute$ | async" [dso]="collection" [tooltipMsg]="'collection.page.edit'"></ds-dso-page-edit-button>
</div>
</div> </div>
<section class="comcol-page-browse-section"> <section class="comcol-page-browse-section">
<!-- Browse-By Links --> <!-- 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 { ThemedCollectionPageComponent } from './themed-collection-page.component';
import { ComcolModule } from '../shared/comcol/comcol.module'; import { ComcolModule } from '../shared/comcol/comcol.module';
import { DsoSharedModule } from '../dso-shared/dso-shared.module'; import { DsoSharedModule } from '../dso-shared/dso-shared.module';
import { DsoPageModule } from '../shared/dso-page/dso-page.module';
@NgModule({ @NgModule({
imports: [ imports: [
@@ -28,6 +29,7 @@ import { DsoSharedModule } from '../dso-shared/dso-shared.module';
CollectionFormModule, CollectionFormModule,
ComcolModule, ComcolModule,
DsoSharedModule, DsoSharedModule,
DsoPageModule,
], ],
declarations: [ declarations: [
CollectionPageComponent, CollectionPageComponent,

View File

@@ -6,6 +6,7 @@ import { RemoteData } from '../../../core/data/remote-data';
import { Collection } from '../../../core/shared/collection.model'; import { Collection } from '../../../core/shared/collection.model';
import { getRemoteDataPayload, getFirstSucceededRemoteData } from '../../../core/shared/operators'; import { getRemoteDataPayload, getFirstSucceededRemoteData } from '../../../core/shared/operators';
import { HALLink } from '../../../core/shared/hal-link.model'; import { HALLink } from '../../../core/shared/hal-link.model';
import { hasValue } from '../../../shared/empty.util';
/** /**
* Component for managing a collection's roles * Component for managing a collection's roles
@@ -45,25 +46,31 @@ export class CollectionRolesComponent implements OnInit {
); );
this.comcolRoles$ = this.collection$.pipe( this.comcolRoles$ = this.collection$.pipe(
map((collection) => [ map((collection) => {
{ let workflowGroups: HALLink[] | HALLink = hasValue(collection._links.workflowGroups) ? collection._links.workflowGroups : [];
name: 'collection-admin', if (!Array.isArray(workflowGroups)) {
href: collection._links.adminGroup.href, workflowGroups = [workflowGroups];
}, }
{ return [
name: 'submitters', {
href: collection._links.submittersGroup.href, name: 'collection-admin',
}, href: collection._links.adminGroup.href,
{ },
name: 'item_read', {
href: collection._links.itemReadGroup.href, name: 'submitters',
}, href: collection._links.submittersGroup.href,
{ },
name: 'bitstream_read', {
href: collection._links.bitstreamReadGroup.href, name: 'item_read',
}, href: collection._links.itemReadGroup.href,
...collection._links.workflowGroups, },
]), {
name: 'bitstream_read',
href: collection._links.bitstreamReadGroup.href,
},
...workflowGroups,
];
}),
); );
} }
} }

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ import { HttpOptions } from '../dspace-rest/dspace-rest.service';
import objectContaining = jasmine.objectContaining; import objectContaining = jasmine.objectContaining;
import { AuthStatus } from './models/auth-status.model'; import { AuthStatus } from './models/auth-status.model';
import { RestRequestMethod } from '../data/rest-request-method'; import { RestRequestMethod } from '../data/rest-request-method';
import { Observable, of as observableOf } from 'rxjs';
describe(`AuthRequestService`, () => { describe(`AuthRequestService`, () => {
let halService: HALEndpointService; let halService: HALEndpointService;
@@ -34,8 +35,8 @@ describe(`AuthRequestService`, () => {
super(hes, rs, rdbs); super(hes, rs, rdbs);
} }
protected createShortLivedTokenRequest(href: string): PostRequest { protected createShortLivedTokenRequest(href: string): Observable<PostRequest> {
return new PostRequest(this.requestService.generateRequestId(), href); 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 * Factory function to create the request object to send.
* 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.
* *
* @param href The href to send the request to * @param href The href to send the request to
* @protected * @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 * 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)), filter((href: string) => isNotEmpty(href)),
distinctUntilChanged(), distinctUntilChanged(),
map((href: string) => new URLCombiner(href, this.shortlivedtokensEndpoint).toString()), 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)), tap((request: RestRequest) => this.requestService.send(request)),
switchMap((request: RestRequest) => this.rdbService.buildFromRequestUUID<ShortLivedToken>(request.uuid)), switchMap((request: RestRequest) => this.rdbService.buildFromRequestUUID<ShortLivedToken>(request.uuid)),
getFirstCompletedRemoteData(), getFirstCompletedRemoteData(),

View File

@@ -1,6 +1,8 @@
import { AuthRequestService } from './auth-request.service'; import { AuthRequestService } from './auth-request.service';
import { RequestService } from '../data/request.service'; import { RequestService } from '../data/request.service';
import { BrowserAuthRequestService } from './browser-auth-request.service'; import { BrowserAuthRequestService } from './browser-auth-request.service';
import { Observable } from 'rxjs';
import { PostRequest } from '../data/request.models';
describe(`BrowserAuthRequestService`, () => { describe(`BrowserAuthRequestService`, () => {
let href: string; let href: string;
@@ -16,14 +18,20 @@ describe(`BrowserAuthRequestService`, () => {
}); });
describe(`createShortLivedTokenRequest`, () => { describe(`createShortLivedTokenRequest`, () => {
it(`should return a PostRequest`, () => { it(`should return a PostRequest`, (done) => {
const result = (service as any).createShortLivedTokenRequest(href); const obs = (service as any).createShortLivedTokenRequest(href) as Observable<PostRequest>;
expect(result.constructor.name).toBe('PostRequest'); obs.subscribe((result: PostRequest) => {
expect(result.constructor.name).toBe('PostRequest');
done();
});
}); });
it(`should return a request with the given href`, () => { it(`should return a request with the given href`, (done) => {
const result = (service as any).createShortLivedTokenRequest(href); const obs = (service as any).createShortLivedTokenRequest(href) as Observable<PostRequest>;
expect(result.href).toBe(href) ; 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 { HALEndpointService } from '../shared/hal-endpoint.service';
import { RequestService } from '../data/request.service'; import { RequestService } from '../data/request.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.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 * 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 * Factory function to create the request object to send.
* 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.
* *
* @param href The href to send the request to * @param href The href to send the request to
* @protected * @protected
*/ */
protected createShortLivedTokenRequest(href: string): PostRequest { protected createShortLivedTokenRequest(href: string): Observable<PostRequest> {
return new PostRequest(this.requestService.generateRequestId(), href); return observableOf(new PostRequest(this.requestService.generateRequestId(), href));
} }
} }

View File

@@ -1,34 +1,68 @@
import { AuthRequestService } from './auth-request.service'; import { AuthRequestService } from './auth-request.service';
import { RequestService } from '../data/request.service'; import { RequestService } from '../data/request.service';
import { ServerAuthRequestService } from './server-auth-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`, () => { describe(`ServerAuthRequestService`, () => {
let href: string; let href: string;
let requestService: RequestService; let requestService: RequestService;
let service: AuthRequestService; let service: AuthRequestService;
let httpClient: HttpClient;
let httpResponse: HttpResponse<any>;
let halService: HALEndpointService;
const mockToken = 'mock-token';
beforeEach(() => { beforeEach(() => {
href = 'https://rest.api/auth/shortlivedtokens'; href = 'https://rest.api/auth/shortlivedtokens';
requestService = jasmine.createSpyObj('requestService', { requestService = jasmine.createSpyObj('requestService', {
'generateRequestId': '8bb0582d-5013-4337-af9c-763beb25aae2' '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`, () => { describe(`createShortLivedTokenRequest`, () => {
it(`should return a GetRequest`, () => { it(`should return a PostRequest`, (done) => {
const result = (service as any).createShortLivedTokenRequest(href); const obs = (service as any).createShortLivedTokenRequest(href) as Observable<PostRequest>;
expect(result.constructor.name).toBe('GetRequest'); obs.subscribe((result: PostRequest) => {
expect(result.constructor.name).toBe('PostRequest');
done();
});
}); });
it(`should return a request with the given href`, () => { it(`should return a request with the given href`, (done) => {
const result = (service as any).createShortLivedTokenRequest(href); const obs = (service as any).createShortLivedTokenRequest(href) as Observable<PostRequest>;
expect(result.href).toBe(href) ; obs.subscribe((result: PostRequest) => {
expect(result.href).toBe(href);
done();
});
}); });
it(`should have a responseMsToLive of 2 seconds`, () => { it(`should return a request with a xsrf header`, (done) => {
const result = (service as any).createShortLivedTokenRequest(href); const obs = (service as any).createShortLivedTokenRequest(href) as Observable<PostRequest>;
expect(result.responseMsToLive).toBe(2 * 1000) ; 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 { Injectable } from '@angular/core';
import { AuthRequestService } from './auth-request.service'; 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 { HALEndpointService } from '../shared/hal-endpoint.service';
import { RequestService } from '../data/request.service'; import { RequestService } from '../data/request.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.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 * Server side version of the service to send authentication requests
@@ -14,23 +26,42 @@ export class ServerAuthRequestService extends AuthRequestService {
constructor( constructor(
halService: HALEndpointService, halService: HALEndpointService,
requestService: RequestService, requestService: RequestService,
rdbService: RemoteDataBuildService rdbService: RemoteDataBuildService,
protected httpClient: HttpClient,
) { ) {
super(halService, requestService, rdbService); super(halService, requestService, rdbService);
} }
/** /**
* Factory function to create the request object to send. This needs to be a POST client side and * Factory function to create the request object to send.
* 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.
* *
* @param href The href to send the request to * @param href The href to send the request to
* @protected * @protected
*/ */
protected createShortLivedTokenRequest(href: string): GetRequest { protected createShortLivedTokenRequest(href: string): Observable<PostRequest> {
return Object.assign(new GetRequest(this.requestService.generateRequestId(), href), { // First do a call to the root endpoint in order to get an XSRF token
responseMsToLive: 2 * 1000 // A short lived token is only valid for 2 seconds. 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 { followLink } from '../../shared/utils/follow-link-config.model';
import { EMPTY } from 'rxjs'; import { EMPTY } from 'rxjs';
import { FindListOptions } from '../data/find-list-options.model'; 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`, () => { describe(`BrowseDefinitionDataService`, () => {
let requestService: RequestService;
let service: BrowseDefinitionDataService; let service: BrowseDefinitionDataService;
const findAllDataSpy = jasmine.createSpyObj('findAllData', { let findAllDataSpy;
findAll: EMPTY, let searchDataSpy;
}); const browsesEndpointURL = 'https://rest.api/browses';
const halService: any = new HALEndpointServiceStub(browsesEndpointURL);
const options = new FindListOptions(); const options = new FindListOptions();
const linksToFollow = [ const linksToFollow = [
followLink('entries'), followLink('entries'),
followLink('items') followLink('items')
]; ];
function initTestService() {
return new BrowseDefinitionDataService(
requestService,
getMockRemoteDataBuildService(),
getMockObjectCacheService(),
halService,
);
}
beforeEach(() => { 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).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`, () => { describe(`findAll`, () => {
it(`should call findAll on findAllData`, () => { it(`should call findAll on findAllData`, () => {
service.findAll(options, true, false, ...linksToFollow); service.findAll(options, true, false, ...linksToFollow);
expect(findAllDataSpy.findAll).toHaveBeenCalledWith(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 { IdentifiableDataService } from '../data/base/identifiable-data.service';
import { FindAllData, FindAllDataImpl } from '../data/base/find-all-data'; import { FindAllData, FindAllDataImpl } from '../data/base/find-all-data';
import { dataService } from '../data/base/data-service.decorator'; 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 * 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', providedIn: 'root',
}) })
@dataService(BROWSE_DEFINITION) @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 findAllData: FindAllDataImpl<BrowseDefinition>;
private searchData: SearchDataImpl<BrowseDefinition>;
constructor( constructor(
protected requestService: RequestService, protected requestService: RequestService,
@@ -31,7 +34,7 @@ export class BrowseDefinitionDataService extends IdentifiableDataService<BrowseD
protected halService: HALEndpointService, protected halService: HALEndpointService,
) { ) {
super('browses', requestService, rdbService, objectCache, halService); 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); 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>>> { findAll(options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<BrowseDefinition>[]): Observable<RemoteData<PaginatedList<BrowseDefinition>>> {
return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); 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'; } from '../shared/operators';
import { URLCombiner } from '../url-combiner/url-combiner'; import { URLCombiner } from '../url-combiner/url-combiner';
import { BrowseEntrySearchOptions } from './browse-entry-search-options.model'; import { BrowseEntrySearchOptions } from './browse-entry-search-options.model';
import { BrowseDefinitionDataService } from './browse-definition-data.service';
import { HrefOnlyDataService } from '../data/href-only-data.service'; import { HrefOnlyDataService } from '../data/href-only-data.service';
import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; 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>[] = [ 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 { export class BrowseService {
protected linkPath = 'browses'; protected linkPath = 'browses';
private static toSearchKeyArray(metadataKey: string): string[] { public static toSearchKeyArray(metadataKey: string): string[] {
const keyParts = metadataKey.split('.'); const keyParts = metadataKey.split('.');
const searchFor = []; const searchFor = [];
searchFor.push('*'); 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 { ObjectUpdatesEffects } from './data/object-updates/object-updates.effects';
import { RouteEffects } from './services/route.effects'; import { RouteEffects } from './services/route.effects';
import { RouterEffects } from './router/router.effects'; import { RouterEffects } from './router/router.effects';
import { MenuEffects } from '../shared/menu/menu.effects';
export const coreEffects = [ export const coreEffects = [
RequestEffects, RequestEffects,
@@ -19,5 +18,4 @@ export const coreEffects = [
ObjectUpdatesEffects, ObjectUpdatesEffects,
RouteEffects, RouteEffects,
RouterEffects, RouterEffects,
MenuEffects
]; ];

View File

@@ -157,6 +157,9 @@ import { SequenceService } from './shared/sequence.service';
import { CoreState } from './core-state.model'; import { CoreState } from './core-state.model';
import { GroupDataService } from './eperson/group-data.service'; import { GroupDataService } from './eperson/group-data.service';
import { SubmissionAccessesModel } from './config/models/config-submission-accesses.model'; import { SubmissionAccessesModel } from './config/models/config-submission-accesses.model';
import { RatingAdvancedWorkflowInfo } from './tasks/models/rating-advanced-workflow-info.model';
import { AdvancedWorkflowInfo } from './tasks/models/advanced-workflow-info.model';
import { SelectReviewerAdvancedWorkflowInfo } from './tasks/models/select-reviewer-advanced-workflow-info.model';
import { AccessStatusObject } from '../shared/object-list/access-status-badge/access-status.model'; import { AccessStatusObject } from '../shared/object-list/access-status-badge/access-status.model';
import { AccessStatusDataService } from './data/access-status-data.service'; import { AccessStatusDataService } from './data/access-status-data.service';
import { LinkHeadService } from './services/link-head.service'; import { LinkHeadService } from './services/link-head.service';
@@ -170,6 +173,7 @@ import { OrcidHistory } from './orcid/model/orcid-history.model';
import { OrcidAuthService } from './orcid/orcid-auth.service'; import { OrcidAuthService } from './orcid/orcid-auth.service';
import { VocabularyDataService } from './submission/vocabularies/vocabulary.data.service'; import { VocabularyDataService } from './submission/vocabularies/vocabulary.data.service';
import { VocabularyEntryDetailsDataService } from './submission/vocabularies/vocabulary-entry-details.data.service'; import { VocabularyEntryDetailsDataService } from './submission/vocabularies/vocabulary-entry-details.data.service';
import { IdentifierData } from '../shared/object-list/identifier-data/identifier-data.model';
/** /**
* When not in production, endpoint responses can be mocked for testing purposes * When not in production, endpoint responses can be mocked for testing purposes
@@ -338,6 +342,9 @@ export const models =
Version, Version,
VersionHistory, VersionHistory,
WorkflowAction, WorkflowAction,
AdvancedWorkflowInfo,
RatingAdvancedWorkflowInfo,
SelectReviewerAdvancedWorkflowInfo,
TemplateItem, TemplateItem,
Feature, Feature,
Authorization, Authorization,
@@ -356,7 +363,8 @@ export const models =
ResearcherProfile, ResearcherProfile,
OrcidQueue, OrcidQueue,
OrcidHistory, OrcidHistory,
AccessStatusObject AccessStatusObject,
IdentifierData,
]; ];
@NgModule({ @NgModule({

View File

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

View File

@@ -32,4 +32,5 @@ export enum FeatureID {
CanSynchronizeWithORCID = 'canSynchronizeWithORCID', CanSynchronizeWithORCID = 'canSynchronizeWithORCID',
CanSubmit = 'canSubmit', CanSubmit = 'canSubmit',
CanEditItem = 'canEditItem', 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); 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 * Get the endpoint to move the item
* @param itemId * @param itemId

View File

@@ -594,6 +594,19 @@ describe('RequestService', () => {
'property1=multiple%0Alines%0Ato%0Asend&property2=sp%26ci%40l%20characters&sp%26ci%40l-chars%20in%20prop=test123' 'property1=multiple%0Alines%0Ato%0Asend&property2=sp%26ci%40l%20characters&sp%26ci%40l-chars%20in%20prop=test123'
); );
}); });
it('should properly encode the body with an array', () => {
const body = {
'property1': 'multiple\nlines\nto\nsend',
'property2': 'sp&ci@l characters',
'sp&ci@l-chars in prop': 'test123',
'arrayParam': ['arrayValue1', 'arrayValue2'],
};
const queryParams = service.uriEncodeBody(body);
expect(queryParams).toEqual(
'property1=multiple%0Alines%0Ato%0Asend&property2=sp%26ci%40l%20characters&sp%26ci%40l-chars%20in%20prop=test123&arrayParam=arrayValue1&arrayParam=arrayValue2'
);
});
}); });
describe('setStaleByUUID', () => { describe('setStaleByUUID', () => {

View File

@@ -255,8 +255,8 @@ export class RequestService {
/** /**
* Convert request Payload to a URL-encoded string * Convert request Payload to a URL-encoded string
* *
* e.g. uriEncodeBody({param: value, param1: value1}) * e.g. uriEncodeBody({param: value, param1: value1, param2: [value3, value4]})
* returns: param=value&param1=value1 * returns: param=value&param1=value1&param2=value3&param2=value4
* *
* @param body * @param body
* The request Payload to convert * The request Payload to convert
@@ -267,11 +267,19 @@ export class RequestService {
let queryParams = ''; let queryParams = '';
if (isNotEmpty(body) && typeof body === 'object') { if (isNotEmpty(body) && typeof body === 'object') {
Object.keys(body) Object.keys(body)
.forEach((param) => { .forEach((param: string) => {
const encodedParam = encodeURIComponent(param); const encodedParam = encodeURIComponent(param);
const encodedBody = encodeURIComponent(body[param]); if (Array.isArray(body[param])) {
const paramValue = `${encodedParam}=${encodedBody}`; for (const element of body[param]) {
queryParams = isEmpty(queryParams) ? queryParams.concat(paramValue) : queryParams.concat('&', paramValue); const encodedBody = encodeURIComponent(element);
const paramValue = `${encodedParam}=${encodedBody}`;
queryParams = isEmpty(queryParams) ? queryParams.concat(paramValue) : queryParams.concat('&', paramValue);
}
} else {
const encodedBody = encodeURIComponent(body[param]);
const paramValue = `${encodedParam}=${encodedBody}`;
queryParams = isEmpty(queryParams) ? queryParams.concat(paramValue) : queryParams.concat('&', paramValue);
}
}); });
} }
return queryParams; return queryParams;

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

@@ -5,15 +5,15 @@ import { ObjectCacheService } from '../cache/object-cache.service';
import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointService } from '../shared/hal-endpoint.service';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { WORKFLOW_ACTION } from '../tasks/models/workflow-action-object.resource-type'; import { WORKFLOW_ACTION } from '../tasks/models/workflow-action-object.resource-type';
import { BaseDataService } from './base/base-data.service';
import { dataService } from './base/data-service.decorator'; import { dataService } from './base/data-service.decorator';
import { IdentifiableDataService } from './base/identifiable-data.service';
/** /**
* A service responsible for fetching/sending data from/to the REST API on the workflowactions endpoint * A service responsible for fetching/sending data from/to the REST API on the workflowactions endpoint
*/ */
@Injectable() @Injectable()
@dataService(WORKFLOW_ACTION) @dataService(WORKFLOW_ACTION)
export class WorkflowActionDataService extends BaseDataService<WorkflowAction> { export class WorkflowActionDataService extends IdentifiableDataService<WorkflowAction> {
protected linkPath = 'workflowactions'; protected linkPath = 'workflowactions';
constructor( constructor(

View File

@@ -3,8 +3,8 @@ import { Injectable } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Operation, ReplaceOperation } from 'fast-json-patch'; import { Operation, ReplaceOperation } from 'fast-json-patch';
import { Observable } from 'rxjs'; import { Observable, of as observableOf } from 'rxjs';
import { find, map } from 'rxjs/operators'; import { find, map, mergeMap } from 'rxjs/operators';
import { NotificationsService } from '../../shared/notifications/notifications.service'; import { NotificationsService } from '../../shared/notifications/notifications.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../cache/object-cache.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')); 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 * 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 { 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 { AccessStatusObject } from 'src/app/shared/object-list/access-status-badge/access-status.model';
import { HandleObject } from './handle-object.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 * Class representing a DSpace Item
@@ -76,6 +78,7 @@ export class Item extends DSpaceObject implements ChildHALResource, HandleObject
version: HALLink; version: HALLink;
thumbnail: HALLink; thumbnail: HALLink;
accessStatus: HALLink; accessStatus: HALLink;
identifiers: HALLink;
self: HALLink; self: HALLink;
}; };
@@ -121,6 +124,13 @@ export class Item extends DSpaceObject implements ChildHALResource, HandleObject
@link(ACCESS_STATUS) @link(ACCESS_STATUS)
accessStatus?: Observable<RemoteData<AccessStatusObject>>; 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 * 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 * An Enum defining the representation type of metadata
*/ */
import { BrowseDefinition } from '../browse-definition.model';
export enum MetadataRepresentationType { export enum MetadataRepresentationType {
None = 'none', None = 'none',
Item = 'item', Item = 'item',
AuthorityControlled = 'authority_controlled', AuthorityControlled = 'authority_controlled',
PlainText = 'plain_text' PlainText = 'plain_text',
BrowseLink = 'browse_link'
} }
/** /**
@@ -24,8 +27,14 @@ export interface MetadataRepresentation {
*/ */
representationType: MetadataRepresentationType; representationType: MetadataRepresentationType;
/**
* The browse definition (optional)
*/
browseDefinition?: BrowseDefinition;
/** /**
* Fetches the value to be displayed * Fetches the value to be displayed
*/ */
getValue(): string; getValue(): string;
} }

View File

@@ -1,6 +1,7 @@
import { MetadataRepresentation, MetadataRepresentationType } from '../metadata-representation.model'; import { MetadataRepresentation, MetadataRepresentationType } from '../metadata-representation.model';
import { hasValue } from '../../../../shared/empty.util'; import { hasValue } from '../../../../shared/empty.util';
import { MetadataValue } from '../../metadata.models'; import { MetadataValue } from '../../metadata.models';
import { BrowseDefinition } from '../../browse-definition.model';
/** /**
* This class defines the way the metadatum it extends should be represented * 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; itemType: string;
constructor(itemType: string) { /**
* The browse definition ID passed in with the metadatum, if any
*/
browseDefinition?: BrowseDefinition;
constructor(itemType: string, browseDefinition?: BrowseDefinition) {
super(); super();
this.itemType = itemType; this.itemType = itemType;
this.browseDefinition = browseDefinition;
} }
/** /**
@@ -23,6 +30,8 @@ export class MetadatumRepresentation extends MetadataValue implements MetadataRe
get representationType(): MetadataRepresentationType { get representationType(): MetadataRepresentationType {
if (hasValue(this.authority)) { if (hasValue(this.authority)) {
return MetadataRepresentationType.AuthorityControlled; return MetadataRepresentationType.AuthorityControlled;
} else if (hasValue(this.browseDefinition)) {
return MetadataRepresentationType.BrowseLink;
} else { } else {
return MetadataRepresentationType.PlainText; 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 { WorkspaceitemSectionLicenseObject } from './workspaceitem-section-license.model';
import { WorkspaceitemSectionUploadObject } from './workspaceitem-section-upload.model'; import { WorkspaceitemSectionUploadObject } from './workspaceitem-section-upload.model';
import { WorkspaceitemSectionCcLicenseObject } from './workspaceitem-section-cc-license.model'; import { WorkspaceitemSectionCcLicenseObject } from './workspaceitem-section-cc-license.model';
import {WorkspaceitemSectionIdentifiersObject} from './workspaceitem-section-identifiers.model';
import { WorkspaceitemSectionSherpaPoliciesObject } from './workspaceitem-section-sherpa-policies.model'; import { WorkspaceitemSectionSherpaPoliciesObject } from './workspaceitem-section-sherpa-policies.model';
/** /**
@@ -23,4 +24,5 @@ export type WorkspaceitemSectionDataType
| WorkspaceitemSectionCcLicenseObject | WorkspaceitemSectionCcLicenseObject
| WorkspaceitemSectionAccessesObject | WorkspaceitemSectionAccessesObject
| WorkspaceitemSectionSherpaPoliciesObject | WorkspaceitemSectionSherpaPoliciesObject
| WorkspaceitemSectionIdentifiersObject
| string; | string;

View File

@@ -0,0 +1,11 @@
import { autoserialize } from 'cerialize';
/**
* An abstract model class for a {@link AdvancedWorkflowInfo}
*/
export abstract class AdvancedWorkflowInfo {
@autoserialize
id: string;
}

View File

@@ -0,0 +1,17 @@
import { ResourceType } from '../../shared/resource-type';
/**
* The resource type for {@link RatingAdvancedWorkflowInfo}
*
* Needs to be in a separate file to prevent circular
* dependencies in webpack.
*/
export const RATING_ADVANCED_WORKFLOW_INFO = new ResourceType('ratingrevieweraction');
/**
* The resource type for {@link SelectReviewerAdvancedWorkflowInfo}
*
* Needs to be in a separate file to prevent circular
* dependencies in webpack.
*/
export const SELECT_REVIEWER_ADVANCED_WORKFLOW_INFO = new ResourceType('selectrevieweraction');

View File

@@ -0,0 +1,28 @@
import { typedObject } from '../../cache/builders/build-decorators';
import { inheritSerialization, autoserialize } from 'cerialize';
import { RATING_ADVANCED_WORKFLOW_INFO } from './advanced-workflow-info.resource-type';
import { AdvancedWorkflowInfo } from './advanced-workflow-info.model';
import { ResourceType } from '../../shared/resource-type';
/**
* A model class for a {@link RatingAdvancedWorkflowInfo}
*/
@typedObject
@inheritSerialization(AdvancedWorkflowInfo)
export class RatingAdvancedWorkflowInfo extends AdvancedWorkflowInfo {
static type: ResourceType = RATING_ADVANCED_WORKFLOW_INFO;
/**
* Whether the description is required.
*/
@autoserialize
descriptionRequired: boolean;
/**
* The maximum value.
*/
@autoserialize
maxValue: number;
}

View File

@@ -0,0 +1,19 @@
import { typedObject } from '../../cache/builders/build-decorators';
import { inheritSerialization, autoserialize } from 'cerialize';
import { SELECT_REVIEWER_ADVANCED_WORKFLOW_INFO } from './advanced-workflow-info.resource-type';
import { AdvancedWorkflowInfo } from './advanced-workflow-info.model';
import { ResourceType } from '../../shared/resource-type';
/**
* A model class for a {@link SelectReviewerAdvancedWorkflowInfo}
*/
@typedObject
@inheritSerialization(AdvancedWorkflowInfo)
export class SelectReviewerAdvancedWorkflowInfo extends AdvancedWorkflowInfo {
static type: ResourceType = SELECT_REVIEWER_ADVANCED_WORKFLOW_INFO;
@autoserialize
group: string;
}

View File

@@ -2,6 +2,7 @@ import { inheritSerialization, autoserialize } from 'cerialize';
import { typedObject } from '../../cache/builders/build-decorators'; import { typedObject } from '../../cache/builders/build-decorators';
import { DSpaceObject } from '../../shared/dspace-object.model'; import { DSpaceObject } from '../../shared/dspace-object.model';
import { WORKFLOW_ACTION } from './workflow-action-object.resource-type'; import { WORKFLOW_ACTION } from './workflow-action-object.resource-type';
import { AdvancedWorkflowInfo } from './advanced-workflow-info.model';
/** /**
* A model class for a WorkflowAction * A model class for a WorkflowAction
@@ -22,4 +23,23 @@ export class WorkflowAction extends DSpaceObject {
*/ */
@autoserialize @autoserialize
options: string[]; options: string[];
/**
* Whether this action has advanced options
*/
@autoserialize
advanced: boolean;
/**
* The advanced options that the user can select at this action
*/
@autoserialize
advancedOptions: string[];
/**
* The advanced info required by the advanced options
*/
@autoserialize
advancedInfo: AdvancedWorkflowInfo[];
} }

View File

@@ -19,6 +19,8 @@ export const XSRF_REQUEST_HEADER = 'X-XSRF-TOKEN';
export const XSRF_RESPONSE_HEADER = 'DSPACE-XSRF-TOKEN'; export const XSRF_RESPONSE_HEADER = 'DSPACE-XSRF-TOKEN';
// Name of cookie where we store the XSRF token // Name of cookie where we store the XSRF token
export const XSRF_COOKIE = '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 * Custom Http Interceptor intercepting Http Requests & Responses to

View File

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

View File

@@ -1,7 +1,7 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { ViewMode } from '../../../../core/shared/view-mode.model'; import { ViewMode } from '../../../../core/shared/view-mode.model';
import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator';
import { VersionedItemComponent } from '../../../../item-page/simple/item-types/versioned-item/versioned-item.component'; import { ItemComponent } from '../../../../item-page/simple/item-types/shared/item.component';
@listableObjectComponent('JournalIssue', ViewMode.StandalonePage) @listableObjectComponent('JournalIssue', ViewMode.StandalonePage)
@Component({ @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 * 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"> <div class="d-flex flex-row">
<ds-item-page-title-field [item]="object" class="mr-auto"> <ds-item-page-title-field [item]="object" class="mr-auto">
</ds-item-page-title-field> </ds-item-page-title-field>
<div class="pl-2 space-children-mr"> <ds-dso-edit-menu></ds-dso-edit-menu>
<ds-dso-page-version-button (newVersionEvent)="onCreateNewVersion()" [dso]="object"
[tooltipMsgCreate]="'item.page.version.create'"
[tooltipMsgHasDraft]="'item.page.version.hasDraft'"></ds-dso-page-version-button>
<ds-dso-page-edit-button [pageRoute]="itemPageRoute" [dso]="object" [tooltipMsg]="'journalvolume.page.edit'"></ds-dso-page-edit-button>
</div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-4"> <div class="col-xs-12 col-md-4">

View File

@@ -1,7 +1,7 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { ViewMode } from '../../../../core/shared/view-mode.model'; import { ViewMode } from '../../../../core/shared/view-mode.model';
import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator';
import { VersionedItemComponent } from '../../../../item-page/simple/item-types/versioned-item/versioned-item.component'; import { ItemComponent } from '../../../../item-page/simple/item-types/shared/item.component';
@listableObjectComponent('JournalVolume', ViewMode.StandalonePage) @listableObjectComponent('JournalVolume', ViewMode.StandalonePage)
@Component({ @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 * 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"> <div class="d-flex flex-row">
<ds-item-page-title-field [item]="object" class="mr-auto"> <ds-item-page-title-field [item]="object" class="mr-auto">
</ds-item-page-title-field> </ds-item-page-title-field>
<div class="pl-2 space-children-mr"> <ds-dso-edit-menu></ds-dso-edit-menu>
<ds-dso-page-version-button (newVersionEvent)="onCreateNewVersion()" [dso]="object"
[tooltipMsgCreate]="'item.page.version.create'"
[tooltipMsgHasDraft]="'item.page.version.hasDraft'"></ds-dso-page-version-button>
<ds-dso-page-edit-button [pageRoute]="itemPageRoute" [dso]="object" [tooltipMsg]="'journal.page.edit'"></ds-dso-page-edit-button>
</div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-4"> <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 { WorkspaceitemDataService } from '../../../../core/submission/workspaceitem-data.service';
import { SearchService } from '../../../../core/shared/search/search.service'; import { SearchService } from '../../../../core/shared/search/search.service';
import { mockRouteService } from '../../../../item-page/simple/item-types/shared/item.component.spec'; 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 comp: JournalComponent;
let fixture: ComponentFixture<JournalComponent>; let fixture: ComponentFixture<JournalComponent>;
@@ -100,7 +104,8 @@ describe('JournalComponent', () => {
{ provide: BitstreamDataService, useValue: mockBitstreamDataService }, { provide: BitstreamDataService, useValue: mockBitstreamDataService },
{ provide: WorkspaceitemDataService, useValue: {} }, { provide: WorkspaceitemDataService, useValue: {} },
{ provide: SearchService, useValue: {} }, { provide: SearchService, useValue: {} },
{ provide: RouteService, useValue: mockRouteService } { provide: RouteService, useValue: mockRouteService },
{ provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub }
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]

View File

@@ -1,7 +1,7 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { ViewMode } from '../../../../core/shared/view-mode.model'; import { ViewMode } from '../../../../core/shared/view-mode.model';
import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator';
import { VersionedItemComponent } from '../../../../item-page/simple/item-types/versioned-item/versioned-item.component'; import { ItemComponent } from '../../../../item-page/simple/item-types/shared/item.component';
@listableObjectComponent('Journal', ViewMode.StandalonePage) @listableObjectComponent('Journal', ViewMode.StandalonePage)
@Component({ @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 * 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 { 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 { ItemSharedModule } from '../../item-page/item-shared.module';
import { ResultsBackButtonModule } from '../../shared/results-back-button/results-back-button.module'; import { ResultsBackButtonModule } from '../../shared/results-back-button/results-back-button.module';
import { DsoPageModule } from '../../shared/dso-page/dso-page.module';
const ENTRY_COMPONENTS = [ const ENTRY_COMPONENTS = [
// put only entry components that use custom decorator // put only entry components that use custom decorator
@@ -49,7 +50,8 @@ const ENTRY_COMPONENTS = [
CommonModule, CommonModule,
ItemSharedModule, ItemSharedModule,
SharedModule, SharedModule,
ResultsBackButtonModule ResultsBackButtonModule,
DsoPageModule
], ],
declarations: [ declarations: [
...ENTRY_COMPONENTS ...ENTRY_COMPONENTS

View File

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

View File

@@ -1,7 +1,7 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { ViewMode } from '../../../../core/shared/view-mode.model'; import { ViewMode } from '../../../../core/shared/view-mode.model';
import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator';
import { VersionedItemComponent } from '../../../../item-page/simple/item-types/versioned-item/versioned-item.component'; import { ItemComponent } from '../../../../item-page/simple/item-types/shared/item.component';
@listableObjectComponent('OrgUnit', ViewMode.StandalonePage) @listableObjectComponent('OrgUnit', ViewMode.StandalonePage)
@Component({ @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 * 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"> <div class="d-flex flex-row">
<ds-item-page-title-field class="mr-auto" [item]="object"> <ds-item-page-title-field class="mr-auto" [item]="object">
</ds-item-page-title-field> </ds-item-page-title-field>
<div class="pl-2 space-children-mr"> <ds-dso-edit-menu></ds-dso-edit-menu>
<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>
</div> </div>
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-4"> <div class="col-xs-12 col-md-4">

View File

@@ -1,7 +1,7 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { ViewMode } from '../../../../core/shared/view-mode.model'; import { ViewMode } from '../../../../core/shared/view-mode.model';
import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator';
import { VersionedItemComponent } from '../../../../item-page/simple/item-types/versioned-item/versioned-item.component'; import { ItemComponent } from '../../../../item-page/simple/item-types/shared/item.component';
@listableObjectComponent('Person', ViewMode.StandalonePage) @listableObjectComponent('Person', ViewMode.StandalonePage)
@Component({ @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 * 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"> <div class="d-flex flex-row">
<ds-item-page-title-field [item]="object" class="mr-auto"> <ds-item-page-title-field [item]="object" class="mr-auto">
</ds-item-page-title-field> </ds-item-page-title-field>
<div class="pl-2 space-children-mr"> <ds-dso-edit-menu></ds-dso-edit-menu>
<ds-dso-page-version-button (newVersionEvent)="onCreateNewVersion()" [dso]="object"
[tooltipMsgCreate]="'item.page.version.create'"
[tooltipMsgHasDraft]="'item.page.version.hasDraft'"></ds-dso-page-version-button>
<ds-dso-page-edit-button [pageRoute]="itemPageRoute" [dso]="object" [tooltipMsg]="'project.page.edit'"></ds-dso-page-edit-button>
</div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-4"> <div class="col-xs-12 col-md-4">

View File

@@ -1,7 +1,7 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { ViewMode } from '../../../../core/shared/view-mode.model'; import { ViewMode } from '../../../../core/shared/view-mode.model';
import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; import { listableObjectComponent } from '../../../../shared/object-collection/shared/listable-object/listable-object.decorator';
import { VersionedItemComponent } from '../../../../item-page/simple/item-types/versioned-item/versioned-item.component'; import { ItemComponent } from '../../../../item-page/simple/item-types/shared/item.component';
@listableObjectComponent('Project', ViewMode.StandalonePage) @listableObjectComponent('Project', ViewMode.StandalonePage)
@Component({ @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 * 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 { 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 { ItemSharedModule } from '../../item-page/item-shared.module';
import { ResultsBackButtonModule } from '../../shared/results-back-button/results-back-button.module'; import { ResultsBackButtonModule } from '../../shared/results-back-button/results-back-button.module';
import { DsoPageModule } from '../../shared/dso-page/dso-page.module';
const ENTRY_COMPONENTS = [ const ENTRY_COMPONENTS = [
// put only entry components that use custom decorator // put only entry components that use custom decorator
@@ -71,7 +72,8 @@ const COMPONENTS = [
ItemSharedModule, ItemSharedModule,
SharedModule, SharedModule,
NgbTooltipModule, NgbTooltipModule,
ResultsBackButtonModule ResultsBackButtonModule,
DsoPageModule,
], ],
declarations: [ declarations: [
...COMPONENTS, ...COMPONENTS,

View File

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

View File

@@ -1,4 +1,5 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { TYPE_REQUEST_FORGOT } from '../../register-email-form/register-email-form.component';
@Component({ @Component({
selector: 'ds-forgot-email', selector: 'ds-forgot-email',
@@ -9,5 +10,5 @@ import { Component } from '@angular/core';
* Component responsible the forgot password email step * Component responsible the forgot password email step
*/ */
export class ForgotEmailComponent { 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 { ContextHelpService } from '../../shared/context-help.service';
import { Observable, Subscription } from 'rxjs'; import { Observable } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
/** /**
@@ -12,22 +12,15 @@ import { map } from 'rxjs/operators';
templateUrl: './context-help-toggle.component.html', templateUrl: './context-help-toggle.component.html',
styleUrls: ['./context-help-toggle.component.scss'] styleUrls: ['./context-help-toggle.component.scss']
}) })
export class ContextHelpToggleComponent implements OnInit, OnDestroy { export class ContextHelpToggleComponent implements OnInit {
buttonVisible$: Observable<boolean>; buttonVisible$: Observable<boolean>;
constructor( constructor(
private contextHelpService: ContextHelpService, private contextHelpService: ContextHelpService,
) { } ) { }
private subs: Subscription[];
ngOnInit(): void { ngOnInit(): void {
this.buttonVisible$ = this.contextHelpService.tooltipCount$().pipe(map(x => x > 0)); this.buttonVisible$ = this.contextHelpService.tooltipCount$().pipe(map(x => x > 0));
this.subs = [this.buttonVisible$.subscribe()];
}
ngOnDestroy() {
this.subs.forEach(sub => sub.unsubscribe());
} }
onClick() { 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 { APP_CONFIG, AppConfig } from '../../../config/app-config.interface';
import { isPlatformBrowser } from '@angular/common'; import { isPlatformBrowser } from '@angular/common';
import { setPlaceHolderAttributes } from '../../shared/utils/object-list-utils'; import { setPlaceHolderAttributes } from '../../shared/utils/object-list-utils';
import { DSpaceObjectType } from '../../core/shared/dspace-object-type.model';
@Component({ @Component({
selector: 'ds-recent-item-list', selector: 'ds-recent-item-list',
@@ -67,6 +68,7 @@ export class RecentItemListComponent implements OnInit {
this.itemRD$ = this.searchService.search( this.itemRD$ = this.searchService.search(
new PaginatedSearchOptions({ new PaginatedSearchOptions({
pagination: this.paginationConfig, pagination: this.paginationConfig,
dsoTypes: [DSpaceObjectType.ITEM],
sort: this.sortConfig, sort: this.sortConfig,
}), }),
undefined, undefined,

View File

@@ -22,7 +22,6 @@ import { provideMockStore } from '@ngrx/store/testing';
import { AppComponent } from './app.component'; import { AppComponent } from './app.component';
import { RouteService } from './core/services/route.service'; import { RouteService } from './core/services/route.service';
import { getMockLocaleService } from './app.component.spec'; import { getMockLocaleService } from './app.component.spec';
import { MenuServiceStub } from './shared/testing/menu-service.stub';
import { CorrelationIdService } from './correlation-id/correlation-id.service'; import { CorrelationIdService } from './correlation-id/correlation-id.service';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { TranslateLoaderMock } from './shared/mocks/translate-loader.mock'; import { TranslateLoaderMock } from './shared/mocks/translate-loader.mock';
@@ -124,6 +123,7 @@ describe('InitService', () => {
let transferStateSpy; let transferStateSpy;
let metadataServiceSpy; let metadataServiceSpy;
let breadcrumbsServiceSpy; let breadcrumbsServiceSpy;
let menuServiceSpy;
const BLOCKING = { const BLOCKING = {
t: { core: { auth: { blocking: true } } }, t: { core: { auth: { blocking: true } } },
@@ -150,6 +150,9 @@ describe('InitService', () => {
metadataServiceSpy = jasmine.createSpyObj('metadataService', [ metadataServiceSpy = jasmine.createSpyObj('metadataService', [
'listenForRouteChange', 'listenForRouteChange',
]); ]);
menuServiceSpy = jasmine.createSpyObj('menuServiceSpy', [
'listenForRouteChanges',
]);
TestBed.resetTestingModule(); TestBed.resetTestingModule();
@@ -175,7 +178,7 @@ describe('InitService', () => {
{ provide: AuthService, useValue: new AuthServiceMock() }, { provide: AuthService, useValue: new AuthServiceMock() },
{ provide: Router, useValue: new RouterMock() }, { provide: Router, useValue: new RouterMock() },
{ provide: ActivatedRoute, useValue: new MockActivatedRoute() }, { provide: ActivatedRoute, useValue: new MockActivatedRoute() },
{ provide: MenuService, useValue: new MenuServiceStub() }, { provide: MenuService, useValue: menuServiceSpy },
{ provide: ThemeService, useValue: getMockThemeService() }, { provide: ThemeService, useValue: getMockThemeService() },
provideMockStore({ initialState }), provideMockStore({ initialState }),
AppComponent, AppComponent,
@@ -190,6 +193,7 @@ describe('InitService', () => {
service.initRouteListeners(); service.initRouteListeners();
expect(metadataServiceSpy.listenForRouteChange).toHaveBeenCalledTimes(1); expect(metadataServiceSpy.listenForRouteChange).toHaveBeenCalledTimes(1);
expect(breadcrumbsServiceSpy.listenForRouteChanges).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 { isAuthenticationBlocking } from './core/auth/selectors';
import { distinctUntilChanged, find } from 'rxjs/operators'; import { distinctUntilChanged, find } from 'rxjs/operators';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { MenuService } from './shared/menu/menu.service';
/** /**
* Performs the initialization of the app. * Performs the initialization of the app.
@@ -51,6 +52,8 @@ export abstract class InitService {
protected metadata: MetadataService, protected metadata: MetadataService,
protected breadcrumbsService: BreadcrumbsService, protected breadcrumbsService: BreadcrumbsService,
protected themeService: ThemeService, protected themeService: ThemeService,
protected menuService: MenuService,
) { ) {
} }
@@ -184,6 +187,7 @@ export abstract class InitService {
this.metadata.listenForRouteChange(); this.metadata.listenForRouteChange();
this.breadcrumbsService.listenForRouteChanges(); this.breadcrumbsService.listenForRouteChanges();
this.themeService.listenForRouteChanges(); this.themeService.listenForRouteChanges();
this.menuService.listenForRouteChanges();
} }
/** /**

View File

@@ -3,8 +3,8 @@
<div class="col-12"> <div class="col-12">
<h2 class="border-bottom">{{'item.edit.head' | translate}}</h2> <h2 class="border-bottom">{{'item.edit.head' | translate}}</h2>
<div class="pt-2"> <div class="pt-2">
<ul class="nav nav-tabs justify-content-start"> <ul class="nav nav-tabs justify-content-start" role="tablist">
<li *ngFor="let page of pages" class="nav-item"> <li *ngFor="let page of pages" class="nav-item" [attr.aria-selected]="page.page === currentPage" role="tab">
<a *ngIf="(page.enabled | async)" <a *ngIf="(page.enabled | async)"
class="nav-link" class="nav-link"
[ngClass]="{'active' : page.page === currentPage}" [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 { ObjectValuesPipe } from '../../shared/utils/object-values-pipe';
import { ResourcePoliciesModule } from '../../shared/resource-policies/resource-policies.module'; import { ResourcePoliciesModule } from '../../shared/resource-policies/resource-policies.module';
import { ItemVersionsModule } from '../versions/item-versions.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'; import { DsoSharedModule } from '../../dso-shared/dso-shared.module';
@@ -76,10 +79,13 @@ import { DsoSharedModule } from '../../dso-shared/dso-shared.module';
ItemMoveComponent, ItemMoveComponent,
ItemEditBitstreamDragHandleComponent, ItemEditBitstreamDragHandleComponent,
VirtualMetadataComponent, VirtualMetadataComponent,
ItemAuthorizationsComponent ItemAuthorizationsComponent,
IdentifierDataComponent,
ItemRegisterDoiComponent
], ],
providers: [ providers: [
BundleDataService, BundleDataService,
IdentifierDataService,
ObjectValuesPipe 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_DELETE_PATH = 'delete';
export const ITEM_EDIT_MOVE_PATH = 'move'; export const ITEM_EDIT_MOVE_PATH = 'move';
export const ITEM_EDIT_AUTHORIZATIONS_PATH = 'authorizations'; 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 { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component';
import { ItemCollectionMapperComponent } from './item-collection-mapper/item-collection-mapper.component'; import { ItemCollectionMapperComponent } from './item-collection-mapper/item-collection-mapper.component';
import { ItemMoveComponent } from './item-move/item-move.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 { ItemRelationshipsComponent } from './item-relationships/item-relationships.component';
import { I18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver'; import { I18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver';
import { ItemVersionHistoryComponent } from './item-version-history/item-version-history.component'; import { ItemVersionHistoryComponent } from './item-version-history/item-version-history.component';
@@ -26,7 +27,8 @@ import {
ITEM_EDIT_PRIVATE_PATH, ITEM_EDIT_PRIVATE_PATH,
ITEM_EDIT_PUBLIC_PATH, ITEM_EDIT_PUBLIC_PATH,
ITEM_EDIT_REINSTATE_PATH, ITEM_EDIT_REINSTATE_PATH,
ITEM_EDIT_WITHDRAW_PATH ITEM_EDIT_WITHDRAW_PATH,
ITEM_EDIT_REGISTER_DOI_PATH
} from './edit-item-page.routing-paths'; } from './edit-item-page.routing-paths';
import { ItemPageReinstateGuard } from './item-page-reinstate.guard'; import { ItemPageReinstateGuard } from './item-page-reinstate.guard';
import { ItemPageWithdrawGuard } from './item-page-withdraw.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 { ItemPageVersionHistoryGuard } from './item-page-version-history.guard';
import { ItemPageCollectionMapperGuard } from './item-page-collection-mapper.guard'; import { ItemPageCollectionMapperGuard } from './item-page-collection-mapper.guard';
import { ThemedDsoEditMetadataComponent } from '../../dso-shared/dso-edit-metadata/themed-dso-edit-metadata.component'; 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 * 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, component: ItemMoveComponent,
data: { title: 'item.edit.move.title' }, 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, path: ITEM_EDIT_AUTHORIZATIONS_PATH,
children: [ children: [
@@ -186,6 +195,7 @@ import { ThemedDsoEditMetadataComponent } from '../../dso-shared/dso-edit-metada
ItemPageRelationshipsGuard, ItemPageRelationshipsGuard,
ItemPageVersionHistoryGuard, ItemPageVersionHistoryGuard,
ItemPageCollectionMapperGuard, ItemPageCollectionMapperGuard,
ItemPageRegisterDoiGuard,
] ]
}) })
export class EditItemPageRoutingModule { 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 * Check authorization rights
*/ */
getFeatureIDs(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID[]> { 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]}} {{statusData[statusKey]}}
</div> </div>
</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"> <div class="col-3 float-left status-label">
{{'item.edit.tabs.status.labels.itemPage' | translate}}: {{'item.edit.tabs.status.labels.itemPage' | translate}}:
</div> </div>
@@ -18,4 +29,5 @@
<div *ngFor="let operation of (operations$ | async)" class="w-100" [ngClass]="{'pt-3': operation}"> <div *ngFor="let operation of (operations$ | async)" class="w-100" [ngClass]="{'pt-3': operation}">
<ds-item-operation *ngIf="operation" [operation]="operation"></ds-item-operation> <ds-item-operation *ngIf="operation" [operation]="operation"></ds-item-operation>
</div> </div>
</div> </div>

View File

@@ -11,8 +11,14 @@ import { Item } from '../../../core/shared/item.model';
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { of as observableOf } from 'rxjs'; 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 { 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', () => { describe('ItemStatusComponent', () => {
let comp: 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 itemPageUrl = `/items/${mockItem.uuid}`;
const routeStub = { const routeStub = {
@@ -50,6 +70,8 @@ describe('ItemStatusComponent', () => {
{ provide: ActivatedRoute, useValue: routeStub }, { provide: ActivatedRoute, useValue: routeStub },
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) }, { provide: HostWindowService, useValue: new HostWindowServiceStub(0) },
{ provide: AuthorizationDataService, useValue: authorizationService }, { provide: AuthorizationDataService, useValue: authorizationService },
{ provide: IdentifierDataService, useValue: mockIdentifierDataService },
{ provide: ConfigurationDataService, useValue: mockConfigurationDataService }
], schemas: [CUSTOM_ELEMENTS_SCHEMA] ], schemas: [CUSTOM_ELEMENTS_SCHEMA]
}).compileComponents(); }).compileComponents();
})); }));

View File

@@ -3,14 +3,21 @@ import { fadeIn, fadeInOut } from '../../../shared/animations/fade';
import { Item } from '../../../core/shared/item.model'; import { Item } from '../../../core/shared/item.model';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { ItemOperation } from '../item-operation/itemOperation.model'; import { ItemOperation } from '../item-operation/itemOperation.model';
import { distinctUntilChanged, first, map, mergeMap, toArray } from 'rxjs/operators'; import { distinctUntilChanged, first, map, mergeMap, switchMap, toArray } from 'rxjs/operators';
import { BehaviorSubject, Observable, from as observableFrom } from 'rxjs'; import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import { RemoteData } from '../../../core/data/remote-data'; import { RemoteData } from '../../../core/data/remote-data';
import { getItemEditRoute, getItemPageRoute } from '../../item-page-routing-paths'; import { getItemEditRoute, getItemPageRoute } from '../../item-page-routing-paths';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
import { hasValue } from '../../../shared/empty.util'; 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({ @Component({
selector: 'ds-item-status', selector: 'ds-item-status',
@@ -47,9 +54,15 @@ export class ItemStatusComponent implements OnInit {
operations$: BehaviorSubject<ItemOperation[]> = new BehaviorSubject<ItemOperation[]>([]); 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 * Route to the item's page
@@ -57,9 +70,15 @@ export class ItemStatusComponent implements OnInit {
itemPageRoute$: Observable<string>; itemPageRoute$: Observable<string>;
constructor(private route: ActivatedRoute, constructor(private route: ActivatedRoute,
private authorizationService: AuthorizationDataService) { private authorizationService: AuthorizationDataService,
private identifierDataService: IdentifierDataService,
private configurationService: ConfigurationDataService,
) {
} }
/**
* Initialise component
*/
ngOnInit(): void { ngOnInit(): void {
this.itemRD$ = this.route.parent.data.pipe(map((data) => data.dso)); this.itemRD$ = this.route.parent.data.pipe(map((data) => data.dso));
this.itemRD$.pipe( this.itemRD$.pipe(
@@ -72,12 +91,37 @@ export class ItemStatusComponent implements OnInit {
lastModified: item.lastModified lastModified: item.lastModified
}); });
this.statusDataKeys = Object.keys(this.statusData); 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 The key is used to build messages
i18n example: 'item.edit.tabs.status.buttons.<key>.label' i18n example: 'item.edit.tabs.status.buttons.<key>.label'
The value is supposed to be a href for the button 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('authorizations', this.getCurrentUrl(item) + '/authorizations', FeatureID.CanManagePolicies, true));
operations.push(new ItemOperation('mappedCollections', this.getCurrentUrl(item) + '/mapper', FeatureID.CanManageMappings, true)); operations.push(new ItemOperation('mappedCollections', this.getCurrentUrl(item) + '/mapper', FeatureID.CanManageMappings, true));
if (item.isWithdrawn) { 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('delete', this.getCurrentUrl(item) + '/delete', FeatureID.CanDelete, true));
operations.push(new ItemOperation('move', this.getCurrentUrl(item) + '/move', FeatureID.CanMove, true)); operations.push(new ItemOperation('move', this.getCurrentUrl(item) + '/move', FeatureID.CanMove, true));
this.operations$.next(operations); this.operations$.next(operations);
observableFrom(operations).pipe( /*
mergeMap((operation) => { When the identifier data stream changes, determine whether the register DOI button should be shown or not.
if (hasValue(operation.featureID)) { This is based on whether the DOI is in the right state (minted or pending, not already queued for registration
return this.authorizationService.isAuthorized(operation.featureID, item.self).pipe( 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(), 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 { } else {
return [operation]; return [op];
} }
}), }),
toArray() // Wait for all operations to be emitted and return as an array
).subscribe((ops) => this.operations$.next(ops)); 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( this.itemPageRoute$ = this.itemRD$.pipe(
getAllSucceededRemoteDataPayload(), getAllSucceededRemoteDataPayload(),
map((item) => getItemPageRoute(item)) map((item) => getItemPageRoute(item))
); );
} }
/** /**
@@ -127,4 +218,10 @@ export class ItemStatusComponent implements OnInit {
return hasValue(operation) ? operation.operationKey : undefined; return hasValue(operation) ? operation.operationKey : undefined;
} }
ngOnDestroy(): void {
this.subs
.filter((subscription) => hasValue(subscription))
.forEach((subscription) => subscription.unsubscribe());
}
} }

View File

@@ -1,4 +1,4 @@
<table id="metadata" class="table table-striped table-hover"> <table id="metadata" class="table table-striped table-hover table-responsive">
<thead> <thead>
<tr> <tr>
<th scope="col">{{'item.edit.modify.overview.field'| translate}}</th> <th scope="col">{{'item.edit.modify.overview.field'| translate}}</th>

View File

@@ -1,16 +1,38 @@
<ds-metadata-field-wrapper [label]="label | translate"> <ds-metadata-field-wrapper [label]="label | translate">
<ng-container *ngFor="let mdValue of mdValues; let last=last;"> <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> </ng-container>
<span class="separator" *ngIf="!last" [innerHTML]="separator"></span> <span class="separator" *ngIf="!last" [innerHTML]="separator"></span>
</ng-container> </ng-container>
</ds-metadata-field-wrapper> </ds-metadata-field-wrapper>
<!-- Render value as markdown -->
<ng-template #markdown let-value="value"> <ng-template #markdown let-value="value">
<span class="dont-break-out" [innerHTML]="value | dsMarkdown | async"> <span class="dont-break-out" [innerHTML]="value | dsMarkdown | async">
</span> </span>
</ng-template> </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"> <ng-template #simple let-value="value">
<span class="dont-break-out preserve-line-breaks">{{value}}</span> <span class="dont-break-out preserve-line-breaks">{{value}}</span>
</ng-template> </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.mdValues = mockMetadata;
comp.separator = mockSeperator; comp.separator = mockSeperator;
comp.label = mockLabel; comp.label = mockLabel;
comp.urlRegex = /^.*test.*$/;
fixture.detectChanges(); fixture.detectChanges();
})); }));
@@ -67,4 +68,9 @@ describe('MetadataValuesComponent', () => {
expect(separators.length).toBe(mockMetadata.length - 1); 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 { Component, Inject, Input, OnChanges, SimpleChanges } from '@angular/core';
import { MetadataValue } from '../../../core/shared/metadata.models'; import { MetadataValue } from '../../../core/shared/metadata.models';
import { APP_CONFIG, AppConfig } from '../../../../config/app-config.interface'; 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. * 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; @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. * This variable will be true if both {@link environment.markdown.enabled} and {@link enableMarkdown} are true.
*/ */
renderMarkdown; renderMarkdown;
@Input() browseDefinition?: BrowseDefinition;
ngOnChanges(changes: SimpleChanges): void { ngOnChanges(changes: SimpleChanges): void {
this.renderMarkdown = !!this.appConfig.markdown.enabled && this.enableMarkdown; 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 *ngIf="!item.isWithdrawn || (isAdmin$|async)" class="full-item-info">
<div class="d-flex flex-row"> <div class="d-flex flex-row">
<ds-item-page-title-field class="mr-auto" [item]="item"></ds-item-page-title-field> <ds-item-page-title-field class="mr-auto" [item]="item"></ds-item-page-title-field>
<div class="pl-2 space-children-mr"> <ds-dso-edit-menu></ds-dso-edit-menu>
<ds-dso-page-edit-button [pageRoute]="itemPageRoute$ | async" [dso]="item"
[tooltipMsg]="'item.page.edit'"></ds-dso-page-edit-button>
</div>
</div> </div>
<div class="simple-view-link my-3" *ngIf="!fromSubmissionObject"> <div class="simple-view-link my-3" *ngIf="!fromSubmissionObject">
<a class="btn btn-outline-primary" [routerLink]="[(itemPageRoute$ | async)]"> <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 { REQUEST_COPY_MODULE_PATH } from '../app-routing-paths';
import { OrcidPageComponent } from './orcid-page/orcid-page.component'; import { OrcidPageComponent } from './orcid-page/orcid-page.component';
import { OrcidPageGuard } from './orcid-page/orcid-page.guard'; import { OrcidPageGuard } from './orcid-page/orcid-page.guard';
import { DSOEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver';
@NgModule({ @NgModule({
imports: [ imports: [
@@ -26,7 +27,8 @@ import { OrcidPageGuard } from './orcid-page/orcid-page.guard';
path: ':id', path: ':id',
resolve: { resolve: {
dso: ItemPageResolver, dso: ItemPageResolver,
breadcrumb: ItemBreadcrumbResolver breadcrumb: ItemBreadcrumbResolver,
menu: DSOEditMenuResolver
}, },
runGuardsAndResolvers: 'always', runGuardsAndResolvers: 'always',
children: [ children: [

View File

@@ -39,7 +39,6 @@ import { MediaViewerImageComponent } from './media-viewer/media-viewer-image/med
import { NgxGalleryModule } from '@kolkov/ngx-gallery'; import { NgxGalleryModule } from '@kolkov/ngx-gallery';
import { MiradorViewerComponent } from './mirador-viewer/mirador-viewer.component'; import { MiradorViewerComponent } from './mirador-viewer/mirador-viewer.component';
import { VersionPageComponent } from './version-page/version-page/version-page.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 { ThemedFileSectionComponent } from './simple/field-components/file-section/themed-file-section.component';
import { OrcidAuthComponent } from './orcid-page/orcid-auth/orcid-auth.component'; import { OrcidAuthComponent } from './orcid-page/orcid-auth/orcid-auth.component';
import { OrcidPageComponent } from './orcid-page/orcid-page.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 { BitstreamRequestACopyPageComponent } from './bitstreams/request-a-copy/bitstream-request-a-copy-page.component';
import { FileSectionComponent } from './simple/field-components/file-section/file-section.component'; import { FileSectionComponent } from './simple/field-components/file-section/file-section.component';
import { ItemSharedModule } from './item-shared.module'; import { ItemSharedModule } from './item-shared.module';
import { DsoPageModule } from '../shared/dso-page/dso-page.module';
const ENTRY_COMPONENTS = [ const ENTRY_COMPONENTS = [
@@ -91,7 +91,6 @@ const DECLARATIONS = [
OrcidSyncSettingsComponent, OrcidSyncSettingsComponent,
OrcidQueueComponent, OrcidQueueComponent,
ItemAlertsComponent, ItemAlertsComponent,
VersionedItemComponent,
BitstreamRequestACopyPageComponent, BitstreamRequestACopyPageComponent,
]; ];
@@ -109,7 +108,8 @@ const DECLARATIONS = [
NgxGalleryModule, NgxGalleryModule,
NgbAccordionModule, NgbAccordionModule,
ResultsBackButtonModule, ResultsBackButtonModule,
UploadModule UploadModule,
DsoPageModule,
], ],
declarations: [ declarations: [
...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 { 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 { ItemVersionsSummaryModalComponent } from './versions/item-versions-summary-modal/item-versions-summary-modal.component';
import { MetadataValuesComponent } from './field-components/metadata-values/metadata-values.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 { 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 { MetadataRepresentationListComponent } from './simple/metadata-representation-list/metadata-representation-list.component';
import { RelatedItemsComponent } from './simple/related-items/related-items-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 = [ const ENTRY_COMPONENTS = [
ItemVersionsDeleteModalComponent, ItemVersionsDeleteModalComponent,
ItemVersionsSummaryModalComponent, ItemVersionsSummaryModalComponent,
]; ];
const COMPONENTS = [ const COMPONENTS = [
@@ -27,12 +25,9 @@ const COMPONENTS = [
RelatedEntitiesSearchComponent, RelatedEntitiesSearchComponent,
TabbedRelatedEntitiesSearchComponent, TabbedRelatedEntitiesSearchComponent,
MetadataValuesComponent, MetadataValuesComponent,
DsoPageVersionButtonComponent,
PersonPageClaimButtonComponent,
GenericItemPageFieldComponent, GenericItemPageFieldComponent,
MetadataRepresentationListComponent, MetadataRepresentationListComponent,
RelatedItemsComponent, RelatedItemsComponent,
DsoPageOrcidButtonComponent
]; ];
@NgModule({ @NgModule({

View File

@@ -7,6 +7,8 @@ import { SharedModule } from '../../../../../shared/shared.module';
import { APP_CONFIG } from '../../../../../../config/app-config.interface'; import { APP_CONFIG } from '../../../../../../config/app-config.interface';
import { environment } from '../../../../../../environments/environment'; import { environment } from '../../../../../../environments/environment';
import { By } from '@angular/platform-browser'; 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 comp: ItemPageAbstractFieldComponent;
let fixture: ComponentFixture<ItemPageAbstractFieldComponent>; let fixture: ComponentFixture<ItemPageAbstractFieldComponent>;
@@ -25,6 +27,7 @@ describe('ItemPageAbstractFieldComponent', () => {
], ],
providers: [ providers: [
{ provide: APP_CONFIG, useValue: environment }, { provide: APP_CONFIG, useValue: environment },
{ provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub }
], ],
declarations: [ItemPageAbstractFieldComponent], declarations: [ItemPageAbstractFieldComponent],
schemas: [NO_ERRORS_SCHEMA] 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 { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock'; import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock';
import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component'; 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 { ItemPageAuthorFieldComponent } from './item-page-author-field.component';
import { APP_CONFIG } from '../../../../../../config/app-config.interface'; import { APP_CONFIG } from '../../../../../../config/app-config.interface';
import { environment } from '../../../../../../environments/environment'; 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 comp: ItemPageAuthorFieldComponent;
let fixture: ComponentFixture<ItemPageAuthorFieldComponent>; let fixture: ComponentFixture<ItemPageAuthorFieldComponent>;
@@ -25,6 +27,7 @@ describe('ItemPageAuthorFieldComponent', () => {
})], })],
providers: [ providers: [
{ provide: APP_CONFIG, useValue: environment }, { provide: APP_CONFIG, useValue: environment },
{ provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub }
], ],
declarations: [ItemPageAuthorFieldComponent, MetadataValuesComponent], declarations: [ItemPageAuthorFieldComponent, MetadataValuesComponent],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
@@ -37,7 +40,7 @@ describe('ItemPageAuthorFieldComponent', () => {
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
fixture = TestBed.createComponent(ItemPageAuthorFieldComponent); fixture = TestBed.createComponent(ItemPageAuthorFieldComponent);
comp = fixture.componentInstance; comp = fixture.componentInstance;
comp.item = mockItemWithMetadataFieldAndValue(field, mockValue); comp.item = mockItemWithMetadataFieldsAndValue([field], mockValue);
fixture.detectChanges(); 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 { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock'; import { TranslateLoaderMock } from '../../../../../shared/testing/translate-loader.mock';
import { MetadataValuesComponent } from '../../../../field-components/metadata-values/metadata-values.component'; 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 { ItemPageDateFieldComponent } from './item-page-date-field.component';
import { APP_CONFIG } from '../../../../../../config/app-config.interface'; import { APP_CONFIG } from '../../../../../../config/app-config.interface';
import { environment } from '../../../../../../environments/environment'; 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 comp: ItemPageDateFieldComponent;
let fixture: ComponentFixture<ItemPageDateFieldComponent>; let fixture: ComponentFixture<ItemPageDateFieldComponent>;
@@ -25,6 +27,7 @@ describe('ItemPageDateFieldComponent', () => {
})], })],
providers: [ providers: [
{ provide: APP_CONFIG, useValue: environment }, { provide: APP_CONFIG, useValue: environment },
{ provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub }
], ],
declarations: [ItemPageDateFieldComponent, MetadataValuesComponent], declarations: [ItemPageDateFieldComponent, MetadataValuesComponent],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
@@ -36,7 +39,7 @@ describe('ItemPageDateFieldComponent', () => {
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
fixture = TestBed.createComponent(ItemPageDateFieldComponent); fixture = TestBed.createComponent(ItemPageDateFieldComponent);
comp = fixture.componentInstance; comp = fixture.componentInstance;
comp.item = mockItemWithMetadataFieldAndValue(mockField, mockValue); comp.item = mockItemWithMetadataFieldsAndValue([mockField], mockValue);
fixture.detectChanges(); fixture.detectChanges();
})); }));

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