mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 18:14:17 +00:00
Merge branch 'main' into w2p-98211_advanced-workflow-actions-main
# Conflicts: # src/app/shared/shared.module.ts
This commit is contained in:
@@ -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:
|
||||||
@@ -121,6 +169,9 @@ languages:
|
|||||||
- code: en
|
- code: en
|
||||||
label: English
|
label: English
|
||||||
active: true
|
active: true
|
||||||
|
- code: ca
|
||||||
|
label: Català
|
||||||
|
active: true
|
||||||
- code: cs
|
- code: cs
|
||||||
label: Čeština
|
label: Čeština
|
||||||
active: true
|
active: true
|
||||||
@@ -310,3 +361,11 @@ info:
|
|||||||
markdown:
|
markdown:
|
||||||
enabled: false
|
enabled: false
|
||||||
mathjax: false
|
mathjax: false
|
||||||
|
|
||||||
|
# Which vocabularies should be used for which search filters
|
||||||
|
# and whether to show the filter in the search sidebar
|
||||||
|
# Take a look at the filter-vocabulary-config.ts file for documentation on how the options are obtained
|
||||||
|
vocabularies:
|
||||||
|
- filter: 'subject'
|
||||||
|
vocabulary: 'srsc'
|
||||||
|
enabled: true
|
||||||
|
@@ -93,11 +93,13 @@
|
|||||||
"date-fns": "^2.29.3",
|
"date-fns": "^2.29.3",
|
||||||
"date-fns-tz": "^1.3.7",
|
"date-fns-tz": "^1.3.7",
|
||||||
"deepmerge": "^4.2.2",
|
"deepmerge": "^4.2.2",
|
||||||
|
"ejs": "^3.1.8",
|
||||||
"express": "^4.17.1",
|
"express": "^4.17.1",
|
||||||
"express-rate-limit": "^5.1.3",
|
"express-rate-limit": "^5.1.3",
|
||||||
"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",
|
||||||
@@ -105,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",
|
||||||
@@ -147,6 +150,7 @@
|
|||||||
"@ngtools/webpack": "^13.2.6",
|
"@ngtools/webpack": "^13.2.6",
|
||||||
"@nguniversal/builders": "^13.1.1",
|
"@nguniversal/builders": "^13.1.1",
|
||||||
"@types/deep-freeze": "0.1.2",
|
"@types/deep-freeze": "0.1.2",
|
||||||
|
"@types/ejs": "^3.1.1",
|
||||||
"@types/express": "^4.17.9",
|
"@types/express": "^4.17.9",
|
||||||
"@types/jasmine": "~3.6.0",
|
"@types/jasmine": "~3.6.0",
|
||||||
"@types/js-cookie": "2.2.6",
|
"@types/js-cookie": "2.2.6",
|
||||||
|
276
server.ts
276
server.ts
@@ -22,11 +22,14 @@ import 'rxjs';
|
|||||||
/* eslint-disable import/no-namespace */
|
/* eslint-disable import/no-namespace */
|
||||||
import * as morgan from 'morgan';
|
import * as morgan from 'morgan';
|
||||||
import * as express from 'express';
|
import * as express from 'express';
|
||||||
|
import * as ejs from 'ejs';
|
||||||
import * as compression from 'compression';
|
import * as compression from 'compression';
|
||||||
import * as expressStaticGzip from 'express-static-gzip';
|
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';
|
||||||
@@ -34,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';
|
||||||
@@ -52,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
|
||||||
@@ -60,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);
|
||||||
|
|
||||||
@@ -86,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
|
||||||
@@ -105,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());
|
||||||
|
|
||||||
@@ -136,10 +148,23 @@ export function app() {
|
|||||||
})(_, (options as any), callback)
|
})(_, (options as any), callback)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
server.engine('ejs', ejs.renderFile);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Register the view engines for html and ejs
|
* Register the view engines for html and ejs
|
||||||
*/
|
*/
|
||||||
server.set('view engine', 'html');
|
server.set('view engine', 'html');
|
||||||
|
server.set('view engine', 'ejs');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serve the robots.txt ejs template, filling in the origin variable
|
||||||
|
*/
|
||||||
|
server.get('/robots.txt', (req, res) => {
|
||||||
|
res.setHeader('content-type', 'text/plain');
|
||||||
|
res.render('assets/robots.txt.ejs', {
|
||||||
|
'origin': req.protocol + '://' + req.headers.host
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Set views folder path to directory where template files are stored
|
* Set views folder path to directory where template files are stored
|
||||||
@@ -172,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'],
|
||||||
@@ -188,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);
|
||||||
|
|
||||||
@@ -201,6 +229,25 @@ export function app() {
|
|||||||
*/
|
*/
|
||||||
function ngApp(req, res) {
|
function ngApp(req, res) {
|
||||||
if (environment.universal.preboot) {
|
if (environment.universal.preboot) {
|
||||||
|
// Render the page to user via SSR (server side rendering)
|
||||||
|
serverSideRender(req, res);
|
||||||
|
} else {
|
||||||
|
// If preboot is disabled, just serve the client
|
||||||
|
console.log('Universal off, serving for direct client-side rendering (CSR)');
|
||||||
|
clientSideRender(req, res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render page content on server side using Angular SSR. By default this page content is
|
||||||
|
* returned to the user.
|
||||||
|
* @param req current request
|
||||||
|
* @param res current response
|
||||||
|
* @param sendToUser if true (default), send the rendered content to the user.
|
||||||
|
* If false, then only save this rendered content to the in-memory cache (to refresh cache).
|
||||||
|
*/
|
||||||
|
function serverSideRender(req, res, sendToUser: boolean = true) {
|
||||||
|
// Render the page via SSR (server side rendering)
|
||||||
res.render(indexHtml, {
|
res.render(indexHtml, {
|
||||||
req,
|
req,
|
||||||
res,
|
res,
|
||||||
@@ -210,52 +257,215 @@ function ngApp(req, res) {
|
|||||||
baseUrl: environment.ui.nameSpace,
|
baseUrl: environment.ui.nameSpace,
|
||||||
originUrl: environment.ui.baseUrl,
|
originUrl: environment.ui.baseUrl,
|
||||||
requestUrl: req.originalUrl,
|
requestUrl: req.originalUrl,
|
||||||
providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }]
|
|
||||||
}, (err, data) => {
|
}, (err, data) => {
|
||||||
if (hasNoValue(err) && hasValue(data)) {
|
if (hasNoValue(err) && hasValue(data)) {
|
||||||
res.locals.ssr = true; // mark response as SSR
|
// 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);
|
res.send(data);
|
||||||
|
}
|
||||||
} else if (hasValue(err) && err.code === 'ERR_HTTP_HEADERS_SENT') {
|
} 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
|
// 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
|
// sent. These errors occur for various reasons in universal, not all of which are in our
|
||||||
// control to solve.
|
// control to solve.
|
||||||
console.warn('Warning [ERR_HTTP_HEADERS_SENT]: Tried to set headers after they were sent to the client');
|
console.warn('Warning [ERR_HTTP_HEADERS_SENT]: Tried to set headers after they were sent to the client');
|
||||||
} else {
|
} else {
|
||||||
console.warn('Error in SSR, serving for direct CSR.');
|
console.warn('Error in server-side rendering (SSR)');
|
||||||
if (hasValue(err)) {
|
if (hasValue(err)) {
|
||||||
console.warn('Error details : ', err);
|
console.warn('Error details : ', err);
|
||||||
}
|
}
|
||||||
res.render(indexHtml, {
|
if (sendToUser) {
|
||||||
req,
|
console.warn('Falling back to serving direct client-side rendering (CSR).');
|
||||||
providers: [{
|
clientSideRender(req, res);
|
||||||
provide: APP_BASE_HREF,
|
}
|
||||||
useValue: req.baseUrl
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
}
|
||||||
// If preboot is disabled, just serve the client
|
|
||||||
console.log('Universal off, serving for direct CSR');
|
/**
|
||||||
res.render(indexHtml, {
|
* Send back response to user to trigger direct client-side rendering (CSR)
|
||||||
req,
|
* @param req current request
|
||||||
providers: [{
|
* @param res current response
|
||||||
provide: APP_BASE_HREF,
|
*/
|
||||||
useValue: req.baseUrl
|
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
|
||||||
|
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();
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Callback function for when the server has started
|
* Callback function for when the server has started
|
||||||
|
@@ -9,7 +9,18 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
<ng-template #editheader>
|
<ng-template #editheader>
|
||||||
<h2 class="border-bottom pb-2">{{messagePrefix + '.head.edit' | translate}}</h2>
|
<h2 class="border-bottom pb-2">
|
||||||
|
<span
|
||||||
|
*dsContextHelp="{
|
||||||
|
content: 'admin.access-control.groups.form.tooltip.editGroupPage',
|
||||||
|
id: 'edit-group-page',
|
||||||
|
iconPlacement: 'right',
|
||||||
|
tooltipPlacement: ['right', 'bottom']
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{messagePrefix + '.head.edit' | translate}}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
<ds-alert *ngIf="groupBeingEdited?.permanent" [type]="AlertTypeEnum.Warning"
|
<ds-alert *ngIf="groupBeingEdited?.permanent" [type]="AlertTypeEnum.Warning"
|
||||||
|
@@ -266,6 +266,43 @@ describe('GroupFormComponent', () => {
|
|||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should edit with name and description operations', () => {
|
||||||
|
const operations = [{
|
||||||
|
op: 'add',
|
||||||
|
path: '/metadata/dc.description',
|
||||||
|
value: 'testDescription'
|
||||||
|
}, {
|
||||||
|
op: 'replace',
|
||||||
|
path: '/name',
|
||||||
|
value: 'newGroupName'
|
||||||
|
}];
|
||||||
|
expect(groupsDataServiceStub.patch).toHaveBeenCalledWith(expected, operations);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should edit with description operations', () => {
|
||||||
|
component.groupName.value = null;
|
||||||
|
component.onSubmit();
|
||||||
|
fixture.detectChanges();
|
||||||
|
const operations = [{
|
||||||
|
op: 'add',
|
||||||
|
path: '/metadata/dc.description',
|
||||||
|
value: 'testDescription'
|
||||||
|
}];
|
||||||
|
expect(groupsDataServiceStub.patch).toHaveBeenCalledWith(expected, operations);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should edit with name operations', () => {
|
||||||
|
component.groupDescription.value = null;
|
||||||
|
component.onSubmit();
|
||||||
|
fixture.detectChanges();
|
||||||
|
const operations = [{
|
||||||
|
op: 'replace',
|
||||||
|
path: '/name',
|
||||||
|
value: 'newGroupName'
|
||||||
|
}];
|
||||||
|
expect(groupsDataServiceStub.patch).toHaveBeenCalledWith(expected, operations);
|
||||||
|
});
|
||||||
|
|
||||||
it('should emit the existing group using the correct new values', waitForAsync(() => {
|
it('should emit the existing group using the correct new values', waitForAsync(() => {
|
||||||
fixture.whenStable().then(() => {
|
fixture.whenStable().then(() => {
|
||||||
expect(component.submitForm.emit).toHaveBeenCalledWith(expected2);
|
expect(component.submitForm.emit).toHaveBeenCalledWith(expected2);
|
||||||
|
@@ -346,8 +346,8 @@ export class GroupFormComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
if (hasValue(this.groupDescription.value)) {
|
if (hasValue(this.groupDescription.value)) {
|
||||||
operations = [...operations, {
|
operations = [...operations, {
|
||||||
op: 'replace',
|
op: 'add',
|
||||||
path: '/metadata/dc.description/0/value',
|
path: '/metadata/dc.description',
|
||||||
value: this.groupDescription.value
|
value: this.groupDescription.value
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
@@ -1,9 +1,19 @@
|
|||||||
<ng-container>
|
<ng-container>
|
||||||
<h3 class="border-bottom pb-2">{{messagePrefix + '.head' | translate}}</h3>
|
<h3 class="border-bottom pb-2">{{messagePrefix + '.head' | translate}}</h3>
|
||||||
|
|
||||||
<h4 id="search" class="border-bottom pb-2">{{messagePrefix + '.search.head' | translate}}
|
<h4 id="search" class="border-bottom pb-2">
|
||||||
|
<span
|
||||||
|
*dsContextHelp="{
|
||||||
|
content: 'admin.access-control.groups.form.tooltip.editGroup.addEpeople',
|
||||||
|
id: 'edit-group-add-epeople',
|
||||||
|
iconPlacement: 'right',
|
||||||
|
tooltipPlacement: ['top', 'right', 'bottom']
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{messagePrefix + '.search.head' | translate}}
|
||||||
|
</span>
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between">
|
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between">
|
||||||
<div>
|
<div>
|
||||||
<select name="scope" id="scope" formControlName="scope" class="form-control" aria-label="Search scope">
|
<select name="scope" id="scope" formControlName="scope" class="form-control" aria-label="Search scope">
|
||||||
|
@@ -1,7 +1,16 @@
|
|||||||
<ng-container>
|
<ng-container>
|
||||||
<h3 class="border-bottom pb-2">{{messagePrefix + '.head' | translate}}</h3>
|
<h3 class="border-bottom pb-2">{{messagePrefix + '.head' | translate}}</h3>
|
||||||
|
|
||||||
<h4 id="search" class="border-bottom pb-2">{{messagePrefix + '.search.head' | translate}}
|
<h4 id="search" class="border-bottom pb-2">
|
||||||
|
<span *dsContextHelp="{
|
||||||
|
content: 'admin.access-control.groups.form.tooltip.editGroup.addSubgroups',
|
||||||
|
id: 'edit-group-add-subgroups',
|
||||||
|
iconPlacement: 'right',
|
||||||
|
tooltipPlacement: ['top', 'right', 'bottom']
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{messagePrefix + '.search.head' | translate}}
|
||||||
|
</span>
|
||||||
|
|
||||||
</h4>
|
</h4>
|
||||||
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between">
|
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between">
|
||||||
|
@@ -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: [
|
||||||
|
@@ -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"
|
||||||
|
@@ -17,6 +17,8 @@ describe('AdminSidebarSectionComponent', () => {
|
|||||||
const menuService = new MenuServiceStub();
|
const menuService = new MenuServiceStub();
|
||||||
const iconString = 'test';
|
const iconString = 'test';
|
||||||
|
|
||||||
|
describe('when not disabled', () => {
|
||||||
|
|
||||||
beforeEach(waitForAsync(() => {
|
beforeEach(waitForAsync(() => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [NoopAnimationsModule, RouterTestingModule, TranslateModule.forRoot()],
|
imports: [NoopAnimationsModule, RouterTestingModule, TranslateModule.forRoot()],
|
||||||
@@ -49,6 +51,52 @@ describe('AdminSidebarSectionComponent', () => {
|
|||||||
const icon = fixture.debugElement.query(By.css('.shortcut-icon')).query(By.css('i.fas'));
|
const icon = fixture.debugElement.query(By.css('.shortcut-icon')).query(By.css('i.fas'));
|
||||||
expect(icon.nativeElement.getAttribute('class')).toContain('fa-' + iconString);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// declare a test component
|
// declare a test component
|
||||||
|
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -100,6 +100,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
this.menuVisible = this.menuService.isMenuVisibleWithVisibleSections(this.menuID);
|
||||||
}
|
}
|
||||||
|
|
||||||
@HostListener('focusin')
|
@HostListener('focusin')
|
||||||
|
@@ -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)"
|
||||||
|
@@ -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() },
|
||||||
|
@@ -47,6 +47,7 @@ import { truncatableReducer, TruncatablesState } from './shared/truncatable/trun
|
|||||||
import { ThemeState, themeReducer } from './shared/theme-support/theme.reducer';
|
import { ThemeState, themeReducer } from './shared/theme-support/theme.reducer';
|
||||||
import { MenusState } from './shared/menu/menus-state.model';
|
import { MenusState } from './shared/menu/menus-state.model';
|
||||||
import { correlationIdReducer } from './correlation-id/correlation-id.reducer';
|
import { correlationIdReducer } from './correlation-id/correlation-id.reducer';
|
||||||
|
import { contextHelpReducer, ContextHelpState } from './shared/context-help.reducer';
|
||||||
|
|
||||||
export interface AppState {
|
export interface AppState {
|
||||||
router: RouterReducerState;
|
router: RouterReducerState;
|
||||||
@@ -67,6 +68,7 @@ export interface AppState {
|
|||||||
epeopleRegistry: EPeopleRegistryState;
|
epeopleRegistry: EPeopleRegistryState;
|
||||||
groupRegistry: GroupRegistryState;
|
groupRegistry: GroupRegistryState;
|
||||||
correlationId: string;
|
correlationId: string;
|
||||||
|
contextHelp: ContextHelpState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const appReducers: ActionReducerMap<AppState> = {
|
export const appReducers: ActionReducerMap<AppState> = {
|
||||||
@@ -87,7 +89,8 @@ export const appReducers: ActionReducerMap<AppState> = {
|
|||||||
communityList: CommunityListReducer,
|
communityList: CommunityListReducer,
|
||||||
epeopleRegistry: ePeopleRegistryReducer,
|
epeopleRegistry: ePeopleRegistryReducer,
|
||||||
groupRegistry: groupRegistryReducer,
|
groupRegistry: groupRegistryReducer,
|
||||||
correlationId: correlationIdReducer
|
correlationId: correlationIdReducer,
|
||||||
|
contextHelp: contextHelpReducer,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const routerStateSelector = (state: AppState) => state.router;
|
export const routerStateSelector = (state: AppState) => state.router;
|
||||||
|
@@ -65,6 +65,7 @@ export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent {
|
|||||||
const searchOptions = browseParamsToOptions(params, currentPage, currentSort, this.browseId, this.fetchThumbnails);
|
const searchOptions = browseParamsToOptions(params, currentPage, currentSort, this.browseId, this.fetchThumbnails);
|
||||||
this.updatePageWithItems(searchOptions, this.value, undefined);
|
this.updatePageWithItems(searchOptions, this.value, undefined);
|
||||||
this.updateParent(params.scope);
|
this.updateParent(params.scope);
|
||||||
|
this.updateLogo();
|
||||||
this.updateStartsWithOptions(this.browseId, metadataKeys, params.scope);
|
this.updateStartsWithOptions(this.browseId, metadataKeys, params.scope);
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
@@ -1,10 +1,17 @@
|
|||||||
<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>
|
||||||
|
<!-- Collection logo -->
|
||||||
|
<ds-comcol-page-logo *ngIf="logo$"
|
||||||
|
[logo]="(logo$ | async)?.payload"
|
||||||
|
[alternateText]="'Community or Collection Logo'">
|
||||||
|
</ds-comcol-page-logo>
|
||||||
<!-- Handle -->
|
<!-- Handle -->
|
||||||
<ds-themed-comcol-page-handle
|
<ds-themed-comcol-page-handle
|
||||||
[content]="parentContext.handle"
|
[content]="parentContext.handle"
|
||||||
@@ -17,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>
|
||||||
|
@@ -144,6 +144,9 @@ describe('BrowseByMetadataPageComponent', () => {
|
|||||||
|
|
||||||
route.params = observableOf(paramsWithValue);
|
route.params = observableOf(paramsWithValue);
|
||||||
comp.ngOnInit();
|
comp.ngOnInit();
|
||||||
|
comp.updateParent('fake-scope');
|
||||||
|
comp.updateLogo();
|
||||||
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fetch items', () => {
|
it('should fetch items', () => {
|
||||||
@@ -151,6 +154,10 @@ describe('BrowseByMetadataPageComponent', () => {
|
|||||||
expect(result.payload.page).toEqual(mockItems);
|
expect(result.payload.page).toEqual(mockItems);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should fetch the logo', () => {
|
||||||
|
expect(comp.logo$).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when calling browseParamsToOptions', () => {
|
describe('when calling browseParamsToOptions', () => {
|
||||||
|
@@ -15,7 +15,11 @@ import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.serv
|
|||||||
import { DSpaceObject } from '../../core/shared/dspace-object.model';
|
import { DSpaceObject } from '../../core/shared/dspace-object.model';
|
||||||
import { StartsWithType } from '../../shared/starts-with/starts-with-decorator';
|
import { StartsWithType } from '../../shared/starts-with/starts-with-decorator';
|
||||||
import { PaginationService } from '../../core/pagination/pagination.service';
|
import { PaginationService } from '../../core/pagination/pagination.service';
|
||||||
import { map } from 'rxjs/operators';
|
import { filter, map, mergeMap } from 'rxjs/operators';
|
||||||
|
import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
|
||||||
|
import { Bitstream } from '../../core/shared/bitstream.model';
|
||||||
|
import { Collection } from '../../core/shared/collection.model';
|
||||||
|
import { Community } from '../../core/shared/community.model';
|
||||||
import { APP_CONFIG, AppConfig } from '../../../config/app-config.interface';
|
import { APP_CONFIG, AppConfig } from '../../../config/app-config.interface';
|
||||||
|
|
||||||
export const BBM_PAGINATION_ID = 'bbm';
|
export const BBM_PAGINATION_ID = 'bbm';
|
||||||
@@ -48,6 +52,11 @@ export class BrowseByMetadataPageComponent implements OnInit, OnDestroy {
|
|||||||
*/
|
*/
|
||||||
parent$: Observable<RemoteData<DSpaceObject>>;
|
parent$: Observable<RemoteData<DSpaceObject>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The logo of the current Community or Collection
|
||||||
|
*/
|
||||||
|
logo$: Observable<RemoteData<Bitstream>>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The pagination config used to display the values
|
* The pagination config used to display the values
|
||||||
*/
|
*/
|
||||||
@@ -151,6 +160,7 @@ export class BrowseByMetadataPageComponent implements OnInit, OnDestroy {
|
|||||||
this.updatePage(browseParamsToOptions(params, currentPage, currentSort, this.browseId, false));
|
this.updatePage(browseParamsToOptions(params, currentPage, currentSort, this.browseId, false));
|
||||||
}
|
}
|
||||||
this.updateParent(params.scope);
|
this.updateParent(params.scope);
|
||||||
|
this.updateLogo();
|
||||||
}));
|
}));
|
||||||
this.updateStartsWithTextOptions();
|
this.updateStartsWithTextOptions();
|
||||||
|
|
||||||
@@ -196,12 +206,31 @@ export class BrowseByMetadataPageComponent implements OnInit, OnDestroy {
|
|||||||
*/
|
*/
|
||||||
updateParent(scope: string) {
|
updateParent(scope: string) {
|
||||||
if (hasValue(scope)) {
|
if (hasValue(scope)) {
|
||||||
this.parent$ = this.dsoService.findById(scope).pipe(
|
const linksToFollow = () => {
|
||||||
|
return [followLink('logo')];
|
||||||
|
};
|
||||||
|
this.parent$ = this.dsoService.findById(scope,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
...linksToFollow() as FollowLinkConfig<DSpaceObject>[]).pipe(
|
||||||
getFirstSucceededRemoteData()
|
getFirstSucceededRemoteData()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the parent Community or Collection logo
|
||||||
|
*/
|
||||||
|
updateLogo() {
|
||||||
|
if (hasValue(this.parent$)) {
|
||||||
|
this.logo$ = this.parent$.pipe(
|
||||||
|
map((rd: RemoteData<Collection | Community>) => rd.payload),
|
||||||
|
filter((collectionOrCommunity: Collection | Community) => hasValue(collectionOrCommunity.logo)),
|
||||||
|
mergeMap((collectionOrCommunity: Collection | Community) => collectionOrCommunity.logo)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigate to the previous page
|
* Navigate to the previous page
|
||||||
*/
|
*/
|
||||||
|
@@ -4,16 +4,21 @@ import { BrowseByModule } from './browse-by.module';
|
|||||||
import { ItemDataService } from '../core/data/item-data.service';
|
import { ItemDataService } from '../core/data/item-data.service';
|
||||||
import { BrowseService } from '../core/browse/browse.service';
|
import { BrowseService } from '../core/browse/browse.service';
|
||||||
import { BrowseByGuard } from './browse-by-guard';
|
import { BrowseByGuard } from './browse-by-guard';
|
||||||
|
import { SharedBrowseByModule } from '../shared/browse-by/shared-browse-by.module';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
|
SharedBrowseByModule,
|
||||||
BrowseByRoutingModule,
|
BrowseByRoutingModule,
|
||||||
BrowseByModule.withEntryComponents()
|
BrowseByModule.withEntryComponents(),
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
ItemDataService,
|
ItemDataService,
|
||||||
BrowseService,
|
BrowseService,
|
||||||
BrowseByGuard
|
BrowseByGuard,
|
||||||
|
],
|
||||||
|
declarations: [
|
||||||
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class BrowseByPageModule {
|
export class BrowseByPageModule {
|
||||||
|
@@ -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',
|
||||||
|
@@ -49,6 +49,7 @@ export class BrowseByTitlePageComponent extends BrowseByMetadataPageComponent {
|
|||||||
this.browseId = params.id || this.defaultBrowseId;
|
this.browseId = params.id || this.defaultBrowseId;
|
||||||
this.updatePageWithItems(browseParamsToOptions(params, currentPage, currentSort, this.browseId, this.fetchThumbnails), undefined, undefined);
|
this.updatePageWithItems(browseParamsToOptions(params, currentPage, currentSort, this.browseId, this.fetchThumbnails), undefined, undefined);
|
||||||
this.updateParent(params.scope);
|
this.updateParent(params.scope);
|
||||||
|
this.updateLogo();
|
||||||
}));
|
}));
|
||||||
this.updateStartsWithTextOptions();
|
this.updateStartsWithTextOptions();
|
||||||
}
|
}
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { BrowseByTitlePageComponent } from './browse-by-title-page/browse-by-title-page.component';
|
import { BrowseByTitlePageComponent } from './browse-by-title-page/browse-by-title-page.component';
|
||||||
import { SharedModule } from '../shared/shared.module';
|
|
||||||
import { BrowseByMetadataPageComponent } from './browse-by-metadata-page/browse-by-metadata-page.component';
|
import { BrowseByMetadataPageComponent } from './browse-by-metadata-page/browse-by-metadata-page.component';
|
||||||
import { BrowseByDatePageComponent } from './browse-by-date-page/browse-by-date-page.component';
|
import { BrowseByDatePageComponent } from './browse-by-date-page/browse-by-date-page.component';
|
||||||
import { BrowseBySwitcherComponent } from './browse-by-switcher/browse-by-switcher.component';
|
import { BrowseBySwitcherComponent } from './browse-by-switcher/browse-by-switcher.component';
|
||||||
@@ -10,6 +9,8 @@ import { ComcolModule } from '../shared/comcol/comcol.module';
|
|||||||
import { ThemedBrowseByMetadataPageComponent } from './browse-by-metadata-page/themed-browse-by-metadata-page.component';
|
import { ThemedBrowseByMetadataPageComponent } from './browse-by-metadata-page/themed-browse-by-metadata-page.component';
|
||||||
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 { 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
|
||||||
@@ -25,9 +26,10 @@ const ENTRY_COMPONENTS = [
|
|||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
|
SharedBrowseByModule,
|
||||||
CommonModule,
|
CommonModule,
|
||||||
ComcolModule,
|
ComcolModule,
|
||||||
SharedModule
|
DsoPageModule
|
||||||
],
|
],
|
||||||
declarations: [
|
declarations: [
|
||||||
BrowseBySwitcherComponent,
|
BrowseBySwitcherComponent,
|
||||||
@@ -45,7 +47,7 @@ export class BrowseByModule {
|
|||||||
*/
|
*/
|
||||||
static withEntryComponents() {
|
static withEntryComponents() {
|
||||||
return {
|
return {
|
||||||
ngModule: SharedModule,
|
ngModule: SharedBrowseByModule,
|
||||||
providers: ENTRY_COMPONENTS.map((component) => ({provide: component}))
|
providers: ENTRY_COMPONENTS.map((component) => ({provide: component}))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@@ -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 {
|
||||||
|
@@ -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 -->
|
||||||
|
@@ -16,6 +16,8 @@ import { StatisticsModule } from '../statistics/statistics.module';
|
|||||||
import { CollectionFormModule } from './collection-form/collection-form.module';
|
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 { DsoPageModule } from '../shared/dso-page/dso-page.module';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -26,6 +28,8 @@ import { ComcolModule } from '../shared/comcol/comcol.module';
|
|||||||
EditItemPageModule,
|
EditItemPageModule,
|
||||||
CollectionFormModule,
|
CollectionFormModule,
|
||||||
ComcolModule,
|
ComcolModule,
|
||||||
|
DsoSharedModule,
|
||||||
|
DsoPageModule,
|
||||||
],
|
],
|
||||||
declarations: [
|
declarations: [
|
||||||
CollectionPageComponent,
|
CollectionPageComponent,
|
||||||
|
@@ -3,7 +3,7 @@
|
|||||||
<div class="col-12" *ngVar="(itemRD$ | async) as itemRD">
|
<div class="col-12" *ngVar="(itemRD$ | async) as itemRD">
|
||||||
<ng-container *ngIf="itemRD?.hasSucceeded">
|
<ng-container *ngIf="itemRD?.hasSucceeded">
|
||||||
<h2 class="border-bottom">{{ 'collection.edit.template.head' | translate:{ collection: collection?.name } }}</h2>
|
<h2 class="border-bottom">{{ 'collection.edit.template.head' | translate:{ collection: collection?.name } }}</h2>
|
||||||
<ds-themed-item-metadata [updateService]="itemTemplateService" [item]="itemRD?.payload"></ds-themed-item-metadata>
|
<ds-themed-dso-edit-metadata [updateDataService]="itemTemplateService" [dso]="itemRD?.payload"></ds-themed-dso-edit-metadata>
|
||||||
<button [routerLink]="getCollectionEditUrl(collection)" class="btn btn-outline-secondary">{{ 'collection.edit.template.cancel' | translate }}</button>
|
<button [routerLink]="getCollectionEditUrl(collection)" class="btn btn-outline-secondary">{{ 'collection.edit.template.cancel' | translate }}</button>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ds-themed-loading *ngIf="itemRD?.isLoading" [message]="'collection.edit.template.loading' | translate"></ds-themed-loading>
|
<ds-themed-loading *ngIf="itemRD?.isLoading" [message]="'collection.edit.template.loading' | translate"></ds-themed-loading>
|
||||||
|
@@ -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 {
|
||||||
|
@@ -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">
|
||||||
|
|
||||||
|
@@ -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
|
||||||
|
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -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(),
|
||||||
|
@@ -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>;
|
||||||
|
obs.subscribe((result: PostRequest) => {
|
||||||
expect(result.constructor.name).toBe('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>;
|
||||||
|
obs.subscribe((result: PostRequest) => {
|
||||||
expect(result.href).toBe(href);
|
expect(result.href).toBe(href);
|
||||||
|
done();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@@ -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>;
|
||||||
|
obs.subscribe((result: PostRequest) => {
|
||||||
expect(result.href).toBe(href);
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@@ -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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -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('*');
|
||||||
|
@@ -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
|
|
||||||
];
|
];
|
||||||
|
@@ -173,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
|
||||||
@@ -362,7 +363,8 @@ export const models =
|
|||||||
ResearcherProfile,
|
ResearcherProfile,
|
||||||
OrcidQueue,
|
OrcidQueue,
|
||||||
OrcidHistory,
|
OrcidHistory,
|
||||||
AccessStatusObject
|
AccessStatusObject,
|
||||||
|
IdentifierData,
|
||||||
];
|
];
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
|
@@ -41,21 +41,28 @@ describe('ArrayMoveChangeAnalyzer', () => {
|
|||||||
], new MoveTest(0, 3));
|
], new MoveTest(0, 3));
|
||||||
|
|
||||||
testMove([
|
testMove([
|
||||||
|
{ op: 'move', from: '/2', path: '/3' },
|
||||||
{ op: 'move', from: '/0', path: '/3' },
|
{ op: 'move', from: '/0', path: '/3' },
|
||||||
{ op: 'move', from: '/2', path: '/1' }
|
|
||||||
], new MoveTest(0, 3), new MoveTest(1, 2));
|
], new MoveTest(0, 3), new MoveTest(1, 2));
|
||||||
|
|
||||||
testMove([
|
testMove([
|
||||||
|
{ op: 'move', from: '/3', path: '/4' },
|
||||||
{ op: 'move', from: '/0', path: '/1' },
|
{ op: 'move', from: '/0', path: '/1' },
|
||||||
{ op: 'move', from: '/3', path: '/4' }
|
|
||||||
], new MoveTest(0, 1), new MoveTest(3, 4));
|
], new MoveTest(0, 1), new MoveTest(3, 4));
|
||||||
|
|
||||||
testMove([], new MoveTest(0, 4), new MoveTest(4, 0));
|
testMove([], new MoveTest(0, 4), new MoveTest(4, 0));
|
||||||
|
|
||||||
testMove([
|
testMove([
|
||||||
|
{ op: 'move', from: '/2', path: '/3' },
|
||||||
{ op: 'move', from: '/0', path: '/3' },
|
{ op: 'move', from: '/0', path: '/3' },
|
||||||
{ op: 'move', from: '/2', path: '/1' }
|
|
||||||
], new MoveTest(0, 4), new MoveTest(1, 3), new MoveTest(2, 4));
|
], new MoveTest(0, 4), new MoveTest(1, 3), new MoveTest(2, 4));
|
||||||
|
|
||||||
|
testMove([
|
||||||
|
{ op: 'move', from: '/3', path: '/4' },
|
||||||
|
{ op: 'move', from: '/2', path: '/4' },
|
||||||
|
{ op: 'move', from: '/1', path: '/3' },
|
||||||
|
{ op: 'move', from: '/0', path: '/3' },
|
||||||
|
], new MoveTest(4, 1), new MoveTest(4, 2), new MoveTest(0, 3));
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when some values are undefined (index 2 and 3)', () => {
|
describe('when some values are undefined (index 2 and 3)', () => {
|
||||||
|
@@ -16,22 +16,31 @@ export class ArrayMoveChangeAnalyzer<T> {
|
|||||||
* @param array2 The custom array to compare with the original
|
* @param array2 The custom array to compare with the original
|
||||||
*/
|
*/
|
||||||
diff(array1: T[], array2: T[]): MoveOperation[] {
|
diff(array1: T[], array2: T[]): MoveOperation[] {
|
||||||
const result = [];
|
return this.getMoves(array1, array2).map((move) => Object.assign({
|
||||||
const moved = [...array1];
|
|
||||||
array1.forEach((value: T, index: number) => {
|
|
||||||
if (hasValue(value)) {
|
|
||||||
const otherIndex = array2.indexOf(value);
|
|
||||||
const movedIndex = moved.indexOf(value);
|
|
||||||
if (index !== otherIndex && movedIndex !== otherIndex) {
|
|
||||||
moveItemInArray(moved, movedIndex, otherIndex);
|
|
||||||
result.push(Object.assign({
|
|
||||||
op: 'move',
|
op: 'move',
|
||||||
from: '/' + movedIndex,
|
from: '/' + move[0],
|
||||||
path: '/' + otherIndex
|
path: '/' + move[1],
|
||||||
}) as MoveOperation);
|
}) as MoveOperation);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine a set of moves required to transform array1 into array2
|
||||||
|
* The moves are returned as an array of pairs of numbers where the first number is the original index and the second
|
||||||
|
* is the new index
|
||||||
|
* It is assumed the operations are executed in the order they're returned (and not simultaneously)
|
||||||
|
* @param array1
|
||||||
|
* @param array2
|
||||||
|
*/
|
||||||
|
private getMoves(array1: any[], array2: any[]): number[][] {
|
||||||
|
const moved = [...array2];
|
||||||
|
|
||||||
|
return array1.reduce((moves, item, index) => {
|
||||||
|
if (hasValue(item) && item !== moved[index]) {
|
||||||
|
const last = moved.lastIndexOf(item);
|
||||||
|
moveItemInArray(moved, last, index);
|
||||||
|
moves.unshift([index, last]);
|
||||||
}
|
}
|
||||||
});
|
return moves;
|
||||||
return result;
|
}, []);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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) => {
|
||||||
|
@@ -29,5 +29,8 @@ export enum FeatureID {
|
|||||||
CanViewUsageStatistics = 'canViewUsageStatistics',
|
CanViewUsageStatistics = 'canViewUsageStatistics',
|
||||||
CanSendFeedback = 'canSendFeedback',
|
CanSendFeedback = 'canSendFeedback',
|
||||||
CanClaimItem = 'canClaimItem',
|
CanClaimItem = 'canClaimItem',
|
||||||
CanSynchronizeWithORCID = 'canSynchronizeWithORCID'
|
CanSynchronizeWithORCID = 'canSynchronizeWithORCID',
|
||||||
|
CanSubmit = 'canSubmit',
|
||||||
|
CanEditItem = 'canEditItem',
|
||||||
|
CanRegisterDOI = 'canRegisterDOI',
|
||||||
}
|
}
|
||||||
|
85
src/app/core/data/identifier-data.service.ts
Normal file
85
src/app/core/data/identifier-data.service.ts
Normal 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>>)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@@ -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
|
||||||
|
@@ -0,0 +1,33 @@
|
|||||||
|
import { MetadataPatchOperation } from './metadata-patch-operation.model';
|
||||||
|
import { Operation } from 'fast-json-patch';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper object for a metadata patch move Operation
|
||||||
|
*/
|
||||||
|
export class MetadataPatchMoveOperation extends MetadataPatchOperation {
|
||||||
|
static operationType = 'move';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The original place of the metadata value to move
|
||||||
|
*/
|
||||||
|
from: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The new place to move the metadata value to
|
||||||
|
*/
|
||||||
|
to: number;
|
||||||
|
|
||||||
|
constructor(field: string, from: number, to: number) {
|
||||||
|
super(MetadataPatchMoveOperation.operationType, field);
|
||||||
|
this.from = from;
|
||||||
|
this.to = to;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform the MetadataPatchOperation into a fast-json-patch Operation by constructing its path and other properties
|
||||||
|
* using the information provided.
|
||||||
|
*/
|
||||||
|
toOperation(): Operation {
|
||||||
|
return { op: this.op as any, from: `/metadata/${this.field}/${this.from}`, path: `/metadata/${this.field}/${this.to}` };
|
||||||
|
}
|
||||||
|
}
|
@@ -10,13 +10,19 @@ import { DeleteRequest } from './request.models';
|
|||||||
import { RelationshipDataService } from './relationship-data.service';
|
import { RelationshipDataService } from './relationship-data.service';
|
||||||
import { RequestService } from './request.service';
|
import { RequestService } from './request.service';
|
||||||
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub';
|
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub';
|
||||||
import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
|
import {
|
||||||
|
createFailedRemoteDataObject$,
|
||||||
|
createSuccessfulRemoteDataObject,
|
||||||
|
createSuccessfulRemoteDataObject$
|
||||||
|
} from '../../shared/remote-data.utils';
|
||||||
import { getMockRemoteDataBuildServiceHrefMap } from '../../shared/mocks/remote-data-build.service.mock';
|
import { getMockRemoteDataBuildServiceHrefMap } from '../../shared/mocks/remote-data-build.service.mock';
|
||||||
import { getMockRequestService } from '../../shared/mocks/request.service.mock';
|
import { getMockRequestService } from '../../shared/mocks/request.service.mock';
|
||||||
import { createPaginatedList } from '../../shared/testing/utils.test';
|
import { createPaginatedList } from '../../shared/testing/utils.test';
|
||||||
import { RequestEntry } from './request-entry.model';
|
import { RequestEntry } from './request-entry.model';
|
||||||
import { FindListOptions } from './find-list-options.model';
|
import { FindListOptions } from './find-list-options.model';
|
||||||
import { testSearchDataImplementation } from './base/search-data.spec';
|
import { testSearchDataImplementation } from './base/search-data.spec';
|
||||||
|
import { MetadataValue } from '../shared/metadata.models';
|
||||||
|
import { MetadataRepresentationType } from '../shared/metadata-representation/metadata-representation.model';
|
||||||
|
|
||||||
describe('RelationshipDataService', () => {
|
describe('RelationshipDataService', () => {
|
||||||
let service: RelationshipDataService;
|
let service: RelationshipDataService;
|
||||||
@@ -233,4 +239,152 @@ describe('RelationshipDataService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('resolveMetadataRepresentation', () => {
|
||||||
|
const parentItem: Item = Object.assign(new Item(), {
|
||||||
|
id: 'parent-item',
|
||||||
|
metadata: {
|
||||||
|
'dc.contributor.author': [
|
||||||
|
Object.assign(new MetadataValue(), {
|
||||||
|
language: null,
|
||||||
|
value: 'Related Author with authority',
|
||||||
|
authority: 'virtual::related-author',
|
||||||
|
place: 2
|
||||||
|
}),
|
||||||
|
Object.assign(new MetadataValue(), {
|
||||||
|
language: null,
|
||||||
|
value: 'Author without authority',
|
||||||
|
place: 1
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
'dc.creator': [
|
||||||
|
Object.assign(new MetadataValue(), {
|
||||||
|
language: null,
|
||||||
|
value: 'Related Creator with authority',
|
||||||
|
authority: 'virtual::related-creator',
|
||||||
|
place: 3,
|
||||||
|
}),
|
||||||
|
Object.assign(new MetadataValue(), {
|
||||||
|
language: null,
|
||||||
|
value: 'Related Creator with authority - unauthorized',
|
||||||
|
authority: 'virtual::related-creator-unauthorized',
|
||||||
|
place: 4,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
'dc.title': [
|
||||||
|
Object.assign(new MetadataValue(), {
|
||||||
|
language: null,
|
||||||
|
value: 'Parent Item'
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const relatedAuthor: Item = Object.assign(new Item(), {
|
||||||
|
id: 'related-author',
|
||||||
|
metadata: {
|
||||||
|
'dc.title': [
|
||||||
|
Object.assign(new MetadataValue(), {
|
||||||
|
language: null,
|
||||||
|
value: 'Related Author'
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const relatedCreator: Item = Object.assign(new Item(), {
|
||||||
|
id: 'related-creator',
|
||||||
|
metadata: {
|
||||||
|
'dc.title': [
|
||||||
|
Object.assign(new MetadataValue(), {
|
||||||
|
language: null,
|
||||||
|
value: 'Related Creator'
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
'dspace.entity.type': 'Person',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const authorRelation: Relationship = Object.assign(new Relationship(), {
|
||||||
|
leftItem: createSuccessfulRemoteDataObject$(parentItem),
|
||||||
|
rightItem: createSuccessfulRemoteDataObject$(relatedAuthor)
|
||||||
|
});
|
||||||
|
const creatorRelation: Relationship = Object.assign(new Relationship(), {
|
||||||
|
leftItem: createSuccessfulRemoteDataObject$(parentItem),
|
||||||
|
rightItem: createSuccessfulRemoteDataObject$(relatedCreator),
|
||||||
|
});
|
||||||
|
const creatorRelationUnauthorized: Relationship = Object.assign(new Relationship(), {
|
||||||
|
leftItem: createSuccessfulRemoteDataObject$(parentItem),
|
||||||
|
rightItem: createFailedRemoteDataObject$('Unauthorized', 401),
|
||||||
|
});
|
||||||
|
|
||||||
|
let metadatum: MetadataValue;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service.findById = (id: string) => {
|
||||||
|
if (id === 'related-author') {
|
||||||
|
return createSuccessfulRemoteDataObject$(authorRelation);
|
||||||
|
}
|
||||||
|
if (id === 'related-creator') {
|
||||||
|
return createSuccessfulRemoteDataObject$(creatorRelation);
|
||||||
|
}
|
||||||
|
if (id === 'related-creator-unauthorized') {
|
||||||
|
return createSuccessfulRemoteDataObject$(creatorRelationUnauthorized);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when the metadata isn\'t virtual', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
metadatum = parentItem.metadata['dc.contributor.author'][1];
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return a plain text MetadatumRepresentation', (done) => {
|
||||||
|
service.resolveMetadataRepresentation(metadatum, parentItem, 'Person').subscribe((result) => {
|
||||||
|
expect(result.representationType).toEqual(MetadataRepresentationType.PlainText);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when the metadata is a virtual author', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
metadatum = parentItem.metadata['dc.contributor.author'][0];
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return a ItemMetadataRepresentation with the correct value', (done) => {
|
||||||
|
service.resolveMetadataRepresentation(metadatum, parentItem, 'Person').subscribe((result) => {
|
||||||
|
expect(result.representationType).toEqual(MetadataRepresentationType.Item);
|
||||||
|
expect(result.getValue()).toEqual(metadatum.value);
|
||||||
|
expect((result as any).id).toEqual(relatedAuthor.id);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when the metadata is a virtual creator', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
metadatum = parentItem.metadata['dc.creator'][0];
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return a ItemMetadataRepresentation with the correct value', (done) => {
|
||||||
|
service.resolveMetadataRepresentation(metadatum, parentItem, 'Person').subscribe((result) => {
|
||||||
|
expect(result.representationType).toEqual(MetadataRepresentationType.Item);
|
||||||
|
expect(result.getValue()).toEqual(metadatum.value);
|
||||||
|
expect((result as any).id).toEqual(relatedCreator.id);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when the metadata refers to a relationship leading to an error response', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
metadatum = parentItem.metadata['dc.creator'][1];
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return an authority controlled MetadatumRepresentation', (done) => {
|
||||||
|
service.resolveMetadataRepresentation(metadatum, parentItem, 'Person').subscribe((result) => {
|
||||||
|
expect(result.representationType).toEqual(MetadataRepresentationType.AuthorityControlled);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
import { HttpHeaders } from '@angular/common/http';
|
import { HttpHeaders } from '@angular/common/http';
|
||||||
import { Inject, Injectable } from '@angular/core';
|
import { Inject, Injectable } from '@angular/core';
|
||||||
import { MemoizedSelector, select, Store } from '@ngrx/store';
|
import { MemoizedSelector, select, Store } from '@ngrx/store';
|
||||||
import { combineLatest as observableCombineLatest, Observable } from 'rxjs';
|
import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs';
|
||||||
import { distinctUntilChanged, filter, map, mergeMap, startWith, switchMap, take, tap } from 'rxjs/operators';
|
import { distinctUntilChanged, filter, map, mergeMap, startWith, switchMap, take, tap } from 'rxjs/operators';
|
||||||
import {
|
import {
|
||||||
compareArraysUsingIds, PAGINATED_RELATIONS_TO_ITEMS_OPERATOR,
|
compareArraysUsingIds, PAGINATED_RELATIONS_TO_ITEMS_OPERATOR,
|
||||||
@@ -46,6 +46,11 @@ import { PutData, PutDataImpl } from './base/put-data';
|
|||||||
import { IdentifiableDataService } from './base/identifiable-data.service';
|
import { IdentifiableDataService } from './base/identifiable-data.service';
|
||||||
import { dataService } from './base/data-service.decorator';
|
import { dataService } from './base/data-service.decorator';
|
||||||
import { itemLinksToFollow } from '../../shared/utils/relation-query.utils';
|
import { itemLinksToFollow } from '../../shared/utils/relation-query.utils';
|
||||||
|
import { MetadataValue } from '../shared/metadata.models';
|
||||||
|
import { MetadataRepresentation } from '../shared/metadata-representation/metadata-representation.model';
|
||||||
|
import { MetadatumRepresentation } from '../shared/metadata-representation/metadatum/metadatum-representation.model';
|
||||||
|
import { ItemMetadataRepresentation } from '../shared/metadata-representation/item/item-metadata-representation.model';
|
||||||
|
import { DSpaceObject } from '../shared/dspace-object.model';
|
||||||
|
|
||||||
const relationshipListsStateSelector = (state: AppState) => state.relationshipLists;
|
const relationshipListsStateSelector = (state: AppState) => state.relationshipLists;
|
||||||
|
|
||||||
@@ -550,4 +555,40 @@ export class RelationshipDataService extends IdentifiableDataService<Relationshi
|
|||||||
searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig<Relationship>[]): Observable<RemoteData<PaginatedList<Relationship>>> {
|
searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig<Relationship>[]): Observable<RemoteData<PaginatedList<Relationship>>> {
|
||||||
return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
|
return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a {@link MetadataValue} into a {@link MetadataRepresentation} of the correct type
|
||||||
|
* @param metadatum {@link MetadataValue} to resolve
|
||||||
|
* @param parentItem Parent dspace object the metadata value belongs to
|
||||||
|
* @param itemType The type of item this metadata value represents (will only be used when no related item can be found, as a fallback)
|
||||||
|
*/
|
||||||
|
resolveMetadataRepresentation(metadatum: MetadataValue, parentItem: DSpaceObject, itemType: string): Observable<MetadataRepresentation> {
|
||||||
|
if (metadatum.isVirtual) {
|
||||||
|
return this.findById(metadatum.virtualValue, true, false, followLink('leftItem'), followLink('rightItem')).pipe(
|
||||||
|
getFirstSucceededRemoteData(),
|
||||||
|
switchMap((relRD: RemoteData<Relationship>) =>
|
||||||
|
observableCombineLatest(relRD.payload.leftItem, relRD.payload.rightItem).pipe(
|
||||||
|
filter(([leftItem, rightItem]) => leftItem.hasCompleted && rightItem.hasCompleted),
|
||||||
|
map(([leftItem, rightItem]) => {
|
||||||
|
if (!leftItem.hasSucceeded || !rightItem.hasSucceeded) {
|
||||||
|
return null;
|
||||||
|
} else if (rightItem.hasSucceeded && leftItem.payload.id === parentItem.id) {
|
||||||
|
return rightItem.payload;
|
||||||
|
} else if (rightItem.payload.id === parentItem.id) {
|
||||||
|
return leftItem.payload;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
map((item: Item) => {
|
||||||
|
if (hasValue(item)) {
|
||||||
|
return Object.assign(new ItemMetadataRepresentation(metadatum), item);
|
||||||
|
} else {
|
||||||
|
return Object.assign(new MetadatumRepresentation(itemType), metadatum);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
return observableOf(Object.assign(new MetadatumRepresentation(itemType), metadatum));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
13
src/app/core/data/system-wide-alert-data.service.spec.ts
Normal file
13
src/app/core/data/system-wide-alert-data.service.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
104
src/app/core/data/system-wide-alert-data.service.ts
Normal file
104
src/app/core/data/system-wide-alert-data.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
@@ -8,7 +8,12 @@ import { Observable, of as observableOf, of } from 'rxjs';
|
|||||||
import { RemoteData } from '../data/remote-data';
|
import { RemoteData } from '../data/remote-data';
|
||||||
import { Item } from '../shared/item.model';
|
import { Item } from '../shared/item.model';
|
||||||
|
|
||||||
import { ItemMock, MockBitstream1, MockBitstream3 } from '../../shared/mocks/item.mock';
|
import {
|
||||||
|
ItemMock,
|
||||||
|
MockBitstream1,
|
||||||
|
MockBitstream3,
|
||||||
|
MockBitstream2
|
||||||
|
} from '../../shared/mocks/item.mock';
|
||||||
import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
|
import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
|
||||||
import { PaginatedList } from '../data/paginated-list.model';
|
import { PaginatedList } from '../data/paginated-list.model';
|
||||||
import { Bitstream } from '../shared/bitstream.model';
|
import { Bitstream } from '../shared/bitstream.model';
|
||||||
@@ -24,6 +29,7 @@ import { HardRedirectService } from '../services/hard-redirect.service';
|
|||||||
import { getMockStore } from '@ngrx/store/testing';
|
import { getMockStore } from '@ngrx/store/testing';
|
||||||
import { AddMetaTagAction, ClearMetaTagAction } from './meta-tag.actions';
|
import { AddMetaTagAction, ClearMetaTagAction } from './meta-tag.actions';
|
||||||
import { AuthorizationDataService } from '../data/feature-authorization/authorization-data.service';
|
import { AuthorizationDataService } from '../data/feature-authorization/authorization-data.service';
|
||||||
|
import { AppConfig } from '../../../config/app-config.interface';
|
||||||
|
|
||||||
describe('MetadataService', () => {
|
describe('MetadataService', () => {
|
||||||
let metadataService: MetadataService;
|
let metadataService: MetadataService;
|
||||||
@@ -44,6 +50,8 @@ describe('MetadataService', () => {
|
|||||||
let router: Router;
|
let router: Router;
|
||||||
let store;
|
let store;
|
||||||
|
|
||||||
|
let appConfig: AppConfig;
|
||||||
|
|
||||||
const initialState = { 'core': { metaTag: { tagsInUse: ['title', 'description'] }}};
|
const initialState = { 'core': { metaTag: { tagsInUse: ['title', 'description'] }}};
|
||||||
|
|
||||||
|
|
||||||
@@ -86,6 +94,14 @@ describe('MetadataService', () => {
|
|||||||
store = getMockStore({ initialState });
|
store = getMockStore({ initialState });
|
||||||
spyOn(store, 'dispatch');
|
spyOn(store, 'dispatch');
|
||||||
|
|
||||||
|
appConfig = {
|
||||||
|
item: {
|
||||||
|
bitstream: {
|
||||||
|
pageSize: 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as any;
|
||||||
|
|
||||||
metadataService = new MetadataService(
|
metadataService = new MetadataService(
|
||||||
router,
|
router,
|
||||||
translateService,
|
translateService,
|
||||||
@@ -98,6 +114,7 @@ describe('MetadataService', () => {
|
|||||||
rootService,
|
rootService,
|
||||||
store,
|
store,
|
||||||
hardRedirectService,
|
hardRedirectService,
|
||||||
|
appConfig,
|
||||||
authorizationService
|
authorizationService
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -358,13 +375,18 @@ describe('MetadataService', () => {
|
|||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
|
|
||||||
it('should link to first Bitstream with allowed format', fakeAsync(() => {
|
describe(`when there's a bitstream with an allowed format on the first page`, () => {
|
||||||
const bitstreams = [MockBitstream3, MockBitstream3, MockBitstream1];
|
let bitstreams;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
bitstreams = [MockBitstream2, MockBitstream3, MockBitstream1];
|
||||||
(bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$(bitstreams));
|
(bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$(bitstreams));
|
||||||
(bitstreamDataService.findListByHref as jasmine.Spy).and.returnValues(
|
(bitstreamDataService.findListByHref as jasmine.Spy).and.returnValues(
|
||||||
...mockBitstreamPages$(bitstreams).map(bp => createSuccessfulRemoteDataObject$(bp)),
|
...mockBitstreamPages$(bitstreams).map(bp => createSuccessfulRemoteDataObject$(bp)),
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should link to first Bitstream with allowed format', fakeAsync(() => {
|
||||||
(metadataService as any).processRouteChange({
|
(metadataService as any).processRouteChange({
|
||||||
data: {
|
data: {
|
||||||
value: {
|
value: {
|
||||||
@@ -375,12 +397,44 @@ describe('MetadataService', () => {
|
|||||||
tick();
|
tick();
|
||||||
expect(meta.addTag).toHaveBeenCalledWith({
|
expect(meta.addTag).toHaveBeenCalledWith({
|
||||||
name: 'citation_pdf_url',
|
name: 'citation_pdf_url',
|
||||||
content: 'https://request.org/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/download'
|
content: 'https://request.org/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28/download'
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe(`when there's no bitstream with an allowed format on the first page`, () => {
|
||||||
|
let bitstreams;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
bitstreams = [MockBitstream1, MockBitstream3, MockBitstream2];
|
||||||
|
(bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$(bitstreams));
|
||||||
|
(bitstreamDataService.findListByHref as jasmine.Spy).and.returnValues(
|
||||||
|
...mockBitstreamPages$(bitstreams).map(bp => createSuccessfulRemoteDataObject$(bp)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`shouldn't add a citation_pdf_url meta tag`, fakeAsync(() => {
|
||||||
|
(metadataService as any).processRouteChange({
|
||||||
|
data: {
|
||||||
|
value: {
|
||||||
|
dso: createSuccessfulRemoteDataObject(ItemMock),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tick();
|
||||||
|
expect(meta.addTag).not.toHaveBeenCalledWith({
|
||||||
|
name: 'citation_pdf_url',
|
||||||
|
content: 'https://request.org/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28/download'
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
describe('tagstore', () => {
|
describe('tagstore', () => {
|
||||||
beforeEach(fakeAsync(() => {
|
beforeEach(fakeAsync(() => {
|
||||||
(metadataService as any).processRouteChange({
|
(metadataService as any).processRouteChange({
|
||||||
|
@@ -1,14 +1,21 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable, Inject } from '@angular/core';
|
||||||
|
|
||||||
import { Meta, MetaDefinition, Title } from '@angular/platform-browser';
|
import { Meta, MetaDefinition, Title } from '@angular/platform-browser';
|
||||||
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
|
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
|
||||||
|
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
|
||||||
import { BehaviorSubject, combineLatest, EMPTY, Observable, of as observableOf } from 'rxjs';
|
import {
|
||||||
import { expand, filter, map, switchMap, take } from 'rxjs/operators';
|
BehaviorSubject,
|
||||||
|
combineLatest,
|
||||||
|
Observable,
|
||||||
|
of as observableOf,
|
||||||
|
concat as observableConcat,
|
||||||
|
EMPTY
|
||||||
|
} from 'rxjs';
|
||||||
|
import { filter, map, switchMap, take, mergeMap } from 'rxjs/operators';
|
||||||
|
|
||||||
import { hasNoValue, hasValue } from '../../shared/empty.util';
|
import { hasNoValue, hasValue, isNotEmpty } from '../../shared/empty.util';
|
||||||
import { DSONameService } from '../breadcrumbs/dso-name.service';
|
import { DSONameService } from '../breadcrumbs/dso-name.service';
|
||||||
import { BitstreamDataService } from '../data/bitstream-data.service';
|
import { BitstreamDataService } from '../data/bitstream-data.service';
|
||||||
import { BitstreamFormatDataService } from '../data/bitstream-format-data.service';
|
import { BitstreamFormatDataService } from '../data/bitstream-format-data.service';
|
||||||
@@ -37,6 +44,7 @@ import { coreSelector } from '../core.selectors';
|
|||||||
import { CoreState } from '../core-state.model';
|
import { CoreState } from '../core-state.model';
|
||||||
import { AuthorizationDataService } from '../data/feature-authorization/authorization-data.service';
|
import { AuthorizationDataService } from '../data/feature-authorization/authorization-data.service';
|
||||||
import { getDownloadableBitstream } from '../shared/bitstream.operators';
|
import { getDownloadableBitstream } from '../shared/bitstream.operators';
|
||||||
|
import { APP_CONFIG, AppConfig } from '../../../config/app-config.interface';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The base selector function to select the metaTag section in the store
|
* The base selector function to select the metaTag section in the store
|
||||||
@@ -87,6 +95,7 @@ export class MetadataService {
|
|||||||
private rootService: RootDataService,
|
private rootService: RootDataService,
|
||||||
private store: Store<CoreState>,
|
private store: Store<CoreState>,
|
||||||
private hardRedirectService: HardRedirectService,
|
private hardRedirectService: HardRedirectService,
|
||||||
|
@Inject(APP_CONFIG) private appConfig: AppConfig,
|
||||||
private authorizationService: AuthorizationDataService
|
private authorizationService: AuthorizationDataService
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
@@ -298,7 +307,13 @@ export class MetadataService {
|
|||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
followLink('primaryBitstream'),
|
followLink('primaryBitstream'),
|
||||||
followLink('bitstreams', {}, followLink('format')),
|
followLink('bitstreams', {
|
||||||
|
findListOptions: {
|
||||||
|
// limit the number of bitstreams used to find the citation pdf url to the number
|
||||||
|
// shown by default on an item page
|
||||||
|
elementsPerPage: this.appConfig.item.bitstream.pageSize
|
||||||
|
}
|
||||||
|
}, followLink('format')),
|
||||||
).pipe(
|
).pipe(
|
||||||
getFirstSucceededRemoteDataPayload(),
|
getFirstSucceededRemoteDataPayload(),
|
||||||
switchMap((bundle: Bundle) =>
|
switchMap((bundle: Bundle) =>
|
||||||
@@ -363,53 +378,30 @@ export class MetadataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* For Items with more than one Bitstream (and no primary Bitstream), link to the first Bitstream with a MIME type
|
* For Items with more than one Bitstream (and no primary Bitstream), link to the first Bitstream
|
||||||
|
* with a MIME type.
|
||||||
|
*
|
||||||
|
* Note this will only check the current page (page size determined item.bitstream.pageSize in the
|
||||||
|
* config) of bitstreams for performance reasons.
|
||||||
|
* See https://github.com/DSpace/DSpace/issues/8648 for more info
|
||||||
|
*
|
||||||
* included in {@linkcode CITATION_PDF_URL_MIMETYPES}
|
* included in {@linkcode CITATION_PDF_URL_MIMETYPES}
|
||||||
* @param bitstreamRd
|
* @param bitstreamRd
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
private getFirstAllowedFormatBitstreamLink(bitstreamRd: RemoteData<PaginatedList<Bitstream>>): Observable<string> {
|
private getFirstAllowedFormatBitstreamLink(bitstreamRd: RemoteData<PaginatedList<Bitstream>>): Observable<string> {
|
||||||
return observableOf(bitstreamRd.payload).pipe(
|
if (hasValue(bitstreamRd.payload) && isNotEmpty(bitstreamRd.payload.page)) {
|
||||||
// Because there can be more than one page of bitstreams, this expand operator
|
// Retrieve the formats of all bitstreams in the page sequentially
|
||||||
// will retrieve them in turn. Due to the take(1) at the bottom, it will only
|
return observableConcat(
|
||||||
// retrieve pages until a match is found
|
...bitstreamRd.payload.page.map((bitstream: Bitstream) => bitstream.format.pipe(
|
||||||
expand((paginatedList: PaginatedList<Bitstream>) => {
|
|
||||||
if (hasNoValue(paginatedList.next)) {
|
|
||||||
// If there's no next page, stop.
|
|
||||||
return EMPTY;
|
|
||||||
} else {
|
|
||||||
// Otherwise retrieve the next page
|
|
||||||
return this.bitstreamDataService.findListByHref(
|
|
||||||
paginatedList.next,
|
|
||||||
undefined,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
followLink('format')
|
|
||||||
).pipe(
|
|
||||||
getFirstCompletedRemoteData(),
|
|
||||||
map((next: RemoteData<PaginatedList<Bitstream>>) => {
|
|
||||||
if (hasValue(next.payload)) {
|
|
||||||
return next.payload;
|
|
||||||
} else {
|
|
||||||
return EMPTY;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
// Return the array of bitstreams inside each paginated list
|
|
||||||
map((paginatedList: PaginatedList<Bitstream>) => paginatedList.page),
|
|
||||||
// Emit the bitstreams in the list one at a time
|
|
||||||
switchMap((bitstreams: Bitstream[]) => bitstreams),
|
|
||||||
// Retrieve the format for each bitstream
|
|
||||||
switchMap((bitstream: Bitstream) => bitstream.format.pipe(
|
|
||||||
getFirstSucceededRemoteDataPayload(),
|
getFirstSucceededRemoteDataPayload(),
|
||||||
// Keep the original bitstream, because it, not the format, is what we'll need
|
// Keep the original bitstream, because it, not the format, is what we'll need
|
||||||
// for the link at the end
|
// for the link at the end
|
||||||
map((format: BitstreamFormat) => [bitstream, format])
|
map((format: BitstreamFormat) => [bitstream, format])
|
||||||
)),
|
))
|
||||||
// Check if bitstream downloadable
|
).pipe(
|
||||||
switchMap(([bitstream, format]: [Bitstream, BitstreamFormat]) => observableOf(bitstream).pipe(
|
// Verify that the bitstream is downloadable
|
||||||
|
mergeMap(([bitstream, format]: [Bitstream, BitstreamFormat]) => observableOf(bitstream).pipe(
|
||||||
getDownloadableBitstream(this.authorizationService),
|
getDownloadableBitstream(this.authorizationService),
|
||||||
map((bit: Bitstream) => [bit, format])
|
map((bit: Bitstream) => [bit, format])
|
||||||
)),
|
)),
|
||||||
@@ -419,8 +411,12 @@ export class MetadataService {
|
|||||||
// We only need 1
|
// We only need 1
|
||||||
take(1),
|
take(1),
|
||||||
// Emit the link of the match
|
// Emit the link of the match
|
||||||
|
// tap((v) => console.log('result', v)),
|
||||||
map(([bitstream, ]: [Bitstream, BitstreamFormat]) => getBitstreamDownloadRoute(bitstream))
|
map(([bitstream, ]: [Bitstream, BitstreamFormat]) => getBitstreamDownloadRoute(bitstream))
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -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
|
||||||
|
16
src/app/core/services/server-xhr.service.ts
Normal file
16
src/app/core/services/server-xhr.service.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
@@ -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
|
||||||
*/
|
*/
|
||||||
|
@@ -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;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@@ -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;
|
||||||
}
|
}
|
||||||
|
@@ -226,7 +226,7 @@ export const metadataFieldsToString = () =>
|
|||||||
map((schema: MetadataSchema) => ({ field, schema }))
|
map((schema: MetadataSchema) => ({ field, schema }))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
return observableCombineLatest(fieldSchemaArray);
|
return isNotEmpty(fieldSchemaArray) ? observableCombineLatest(fieldSchemaArray) : [[]];
|
||||||
}),
|
}),
|
||||||
map((fieldSchemaArray: { field: MetadataField, schema: MetadataSchema }[]): string[] => {
|
map((fieldSchemaArray: { field: MetadataField, schema: MetadataSchema }[]): string[] => {
|
||||||
return fieldSchemaArray.map((fieldSchema: { field: MetadataField, schema: MetadataSchema }) => fieldSchema.schema.prefix + '.' + fieldSchema.field.toString());
|
return fieldSchemaArray.map((fieldSchema: { field: MetadataField, schema: MetadataSchema }) => fieldSchema.schema.prefix + '.' + fieldSchema.field.toString());
|
||||||
|
@@ -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[]
|
||||||
|
}
|
@@ -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;
|
||||||
|
@@ -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
|
||||||
|
@@ -0,0 +1,15 @@
|
|||||||
|
<div class="flex-grow-1 ds-drop-list h-100" [class.disabled]="(draggingMdField$ | async) && (draggingMdField$ | async) !== mdField" cdkDropList (cdkDropListDropped)="drop($event)" role="table">
|
||||||
|
<ds-dso-edit-metadata-value-headers role="presentation" [dsoType]="dsoType"></ds-dso-edit-metadata-value-headers>
|
||||||
|
<ds-dso-edit-metadata-value *ngFor="let mdValue of form.fields[mdField]; let idx = index" role="presentation"
|
||||||
|
[dso]="dso"
|
||||||
|
[mdValue]="mdValue"
|
||||||
|
[dsoType]="dsoType"
|
||||||
|
[saving$]="saving$"
|
||||||
|
[isOnlyValue]="form.fields[mdField].length === 1"
|
||||||
|
(edit)="mdValue.editing = true"
|
||||||
|
(confirm)="mdValue.confirmChanges($event); form.resetReinstatable(); valueSaved.emit()"
|
||||||
|
(remove)="mdValue.change === DsoEditMetadataChangeTypeEnum.ADD ? form.remove(mdField, idx) : mdValue.change = DsoEditMetadataChangeTypeEnum.REMOVE; form.resetReinstatable(); valueSaved.emit()"
|
||||||
|
(undo)="mdValue.change === DsoEditMetadataChangeTypeEnum.ADD ? form.remove(mdField, idx) : mdValue.discard(); valueSaved.emit()"
|
||||||
|
(dragging)="$event ? draggingMdField$.next(mdField) : draggingMdField$.next(null)">
|
||||||
|
</ds-dso-edit-metadata-value>
|
||||||
|
</div>
|
@@ -0,0 +1,7 @@
|
|||||||
|
.ds-drop-list {
|
||||||
|
background-color: var(--bs-gray-500);
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,135 @@
|
|||||||
|
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||||
|
import { VarDirective } from '../../../shared/utils/var.directive';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { RouterTestingModule } from '@angular/router/testing';
|
||||||
|
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
|
import { DsoEditMetadataFieldValuesComponent } from './dso-edit-metadata-field-values.component';
|
||||||
|
import { DsoEditMetadataForm } from '../dso-edit-metadata-form';
|
||||||
|
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
|
||||||
|
import { MetadataValue } from '../../../core/shared/metadata.models';
|
||||||
|
import { of } from 'rxjs/internal/observable/of';
|
||||||
|
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
|
||||||
|
import { By } from '@angular/platform-browser';
|
||||||
|
|
||||||
|
describe('DsoEditMetadataFieldValuesComponent', () => {
|
||||||
|
let component: DsoEditMetadataFieldValuesComponent;
|
||||||
|
let fixture: ComponentFixture<DsoEditMetadataFieldValuesComponent>;
|
||||||
|
|
||||||
|
let form: DsoEditMetadataForm;
|
||||||
|
let dso: DSpaceObject;
|
||||||
|
let mdField: string;
|
||||||
|
let draggingMdField$: BehaviorSubject<string>;
|
||||||
|
|
||||||
|
beforeEach(waitForAsync(() => {
|
||||||
|
dso = Object.assign(new DSpaceObject(), {
|
||||||
|
metadata: {
|
||||||
|
'dc.title': [
|
||||||
|
Object.assign(new MetadataValue(), {
|
||||||
|
value: 'Test Title',
|
||||||
|
language: 'en',
|
||||||
|
place: 0,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
'dc.subject': [
|
||||||
|
Object.assign(new MetadataValue(), {
|
||||||
|
value: 'Subject One',
|
||||||
|
language: 'en',
|
||||||
|
place: 0,
|
||||||
|
}),
|
||||||
|
Object.assign(new MetadataValue(), {
|
||||||
|
value: 'Subject Two',
|
||||||
|
language: 'en',
|
||||||
|
place: 1,
|
||||||
|
}),
|
||||||
|
Object.assign(new MetadataValue(), {
|
||||||
|
value: 'Subject Three',
|
||||||
|
language: 'en',
|
||||||
|
place: 2,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
form = new DsoEditMetadataForm(dso.metadata);
|
||||||
|
mdField = 'dc.subject';
|
||||||
|
draggingMdField$ = new BehaviorSubject<string>(null);
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
declarations: [DsoEditMetadataFieldValuesComponent, VarDirective],
|
||||||
|
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])],
|
||||||
|
providers: [
|
||||||
|
],
|
||||||
|
schemas: [NO_ERRORS_SCHEMA]
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(DsoEditMetadataFieldValuesComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
component.dso = dso;
|
||||||
|
component.form = form;
|
||||||
|
component.mdField = mdField;
|
||||||
|
component.saving$ = of(false);
|
||||||
|
component.draggingMdField$ = draggingMdField$;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when draggingMdField$ emits a value equal to mdField', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
draggingMdField$.next(mdField);
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not disable the list', () => {
|
||||||
|
expect(fixture.debugElement.query(By.css('.ds-drop-list.disabled'))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when draggingMdField$ emits a value different to mdField', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
draggingMdField$.next(`${mdField}.fake`);
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should disable the list', () => {
|
||||||
|
expect(fixture.debugElement.query(By.css('.ds-drop-list.disabled'))).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when draggingMdField$ emits null', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
draggingMdField$.next(null);
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not disable the list', () => {
|
||||||
|
expect(fixture.debugElement.query(By.css('.ds-drop-list.disabled'))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dropping a value on a different index', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
component.drop(Object.assign({
|
||||||
|
previousIndex: 0,
|
||||||
|
currentIndex: 2,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should physically move the relevant metadata value within the form', () => {
|
||||||
|
expect(form.fields[mdField][0].newValue.value).toEqual('Subject Two');
|
||||||
|
expect(form.fields[mdField][1].newValue.value).toEqual('Subject Three');
|
||||||
|
expect(form.fields[mdField][2].newValue.value).toEqual('Subject One');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update the metadata values their new place to match the new physical order', () => {
|
||||||
|
expect(form.fields[mdField][0].newValue.place).toEqual(0);
|
||||||
|
expect(form.fields[mdField][1].newValue.place).toEqual(1);
|
||||||
|
expect(form.fields[mdField][2].newValue.place).toEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should maintain the metadata values their original place in their original value so it can be used later to determine the patch operations', () => {
|
||||||
|
expect(form.fields[mdField][0].originalValue.place).toEqual(1);
|
||||||
|
expect(form.fields[mdField][1].originalValue.place).toEqual(2);
|
||||||
|
expect(form.fields[mdField][2].originalValue.place).toEqual(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@@ -0,0 +1,81 @@
|
|||||||
|
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||||
|
import { DsoEditMetadataChangeType, DsoEditMetadataForm, DsoEditMetadataValue } from '../dso-edit-metadata-form';
|
||||||
|
import { Observable } from 'rxjs/internal/Observable';
|
||||||
|
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
|
||||||
|
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
|
||||||
|
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-dso-edit-metadata-field-values',
|
||||||
|
styleUrls: ['./dso-edit-metadata-field-values.component.scss'],
|
||||||
|
templateUrl: './dso-edit-metadata-field-values.component.html',
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* Component displaying table rows for each value for a certain metadata field within a form
|
||||||
|
*/
|
||||||
|
export class DsoEditMetadataFieldValuesComponent {
|
||||||
|
/**
|
||||||
|
* The parent {@link DSpaceObject} to display a metadata form for
|
||||||
|
* Also used to determine metadata-representations in case of virtual metadata
|
||||||
|
*/
|
||||||
|
@Input() dso: DSpaceObject;
|
||||||
|
/**
|
||||||
|
* A dynamic form object containing all information about the metadata and the changes made to them, see {@link DsoEditMetadataForm}
|
||||||
|
*/
|
||||||
|
@Input() form: DsoEditMetadataForm;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata field to display values for
|
||||||
|
*/
|
||||||
|
@Input() mdField: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type of DSO we're displaying values for
|
||||||
|
* Determines i18n messages
|
||||||
|
*/
|
||||||
|
@Input() dsoType: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observable to check if the form is being saved or not
|
||||||
|
*/
|
||||||
|
@Input() saving$: Observable<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks for which metadata-field a drag operation is taking place
|
||||||
|
* Null when no drag is currently happening for any field
|
||||||
|
*/
|
||||||
|
@Input() draggingMdField$: BehaviorSubject<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit when the value has been saved within the form
|
||||||
|
*/
|
||||||
|
@Output() valueSaved: EventEmitter<any> = new EventEmitter<any>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The DsoEditMetadataChangeType enumeration for access in the component's template
|
||||||
|
* @type {DsoEditMetadataChangeType}
|
||||||
|
*/
|
||||||
|
public DsoEditMetadataChangeTypeEnum = DsoEditMetadataChangeType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop a value into a new position
|
||||||
|
* Update the form's value array for the current field to match the dropped position
|
||||||
|
* Update the values their place property to match the new order
|
||||||
|
* Send an update to the parent
|
||||||
|
* @param event
|
||||||
|
*/
|
||||||
|
drop(event: CdkDragDrop<any>) {
|
||||||
|
const dragIndex = event.previousIndex;
|
||||||
|
const dropIndex = event.currentIndex;
|
||||||
|
// Move the value within its field
|
||||||
|
moveItemInArray(this.form.fields[this.mdField], dragIndex, dropIndex);
|
||||||
|
// Update all the values in this field their place property
|
||||||
|
this.form.fields[this.mdField].forEach((value: DsoEditMetadataValue, index: number) => {
|
||||||
|
value.newValue.place = index;
|
||||||
|
value.confirmChanges();
|
||||||
|
});
|
||||||
|
// Update the form statuses
|
||||||
|
this.form.resetReinstatable();
|
||||||
|
this.valueSaved.emit();
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,275 @@
|
|||||||
|
import { DsoEditMetadataChangeType, DsoEditMetadataForm } from './dso-edit-metadata-form';
|
||||||
|
import { DSpaceObject } from '../../core/shared/dspace-object.model';
|
||||||
|
import { MetadataValue } from '../../core/shared/metadata.models';
|
||||||
|
|
||||||
|
describe('DsoEditMetadataForm', () => {
|
||||||
|
let form: DsoEditMetadataForm;
|
||||||
|
let dso: DSpaceObject;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dso = Object.assign(new DSpaceObject(), {
|
||||||
|
metadata: {
|
||||||
|
'dc.title': [
|
||||||
|
Object.assign(new MetadataValue(), {
|
||||||
|
value: 'Test Title',
|
||||||
|
language: 'en',
|
||||||
|
place: 0,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
'dc.subject': [
|
||||||
|
Object.assign(new MetadataValue(), {
|
||||||
|
value: 'Subject One',
|
||||||
|
language: 'en',
|
||||||
|
place: 0,
|
||||||
|
}),
|
||||||
|
Object.assign(new MetadataValue(), {
|
||||||
|
value: 'Subject Two',
|
||||||
|
language: 'en',
|
||||||
|
place: 1,
|
||||||
|
}),
|
||||||
|
Object.assign(new MetadataValue(), {
|
||||||
|
value: 'Subject Three',
|
||||||
|
language: 'en',
|
||||||
|
place: 2,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
form = new DsoEditMetadataForm(dso.metadata);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe('adding a new value', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
form.add();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add an empty value to \"newValue\" with no place yet and editing set to true', () => {
|
||||||
|
expect(form.newValue).toBeDefined();
|
||||||
|
expect(form.newValue.originalValue.place).toBeUndefined();
|
||||||
|
expect(form.newValue.newValue.place).toBeUndefined();
|
||||||
|
expect(form.newValue.editing).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not mark the form as changed yet', () => {
|
||||||
|
expect(form.hasChanges()).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('and assigning a value and metadata field to it', () => {
|
||||||
|
let mdField: string;
|
||||||
|
let value: string;
|
||||||
|
let expectedPlace: number;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mdField = 'dc.subject';
|
||||||
|
value = 'Subject Four';
|
||||||
|
form.newValue.newValue.value = value;
|
||||||
|
form.setMetadataField(mdField);
|
||||||
|
expectedPlace = form.fields[mdField].length - 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add the new value to the values of the relevant field', () => {
|
||||||
|
expect(form.fields[mdField][expectedPlace].newValue.value).toEqual(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set its editing flag to false', () => {
|
||||||
|
expect(form.fields[mdField][expectedPlace].editing).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set both its original and new place to match its position in the value array', () => {
|
||||||
|
expect(form.fields[mdField][expectedPlace].newValue.place).toEqual(expectedPlace);
|
||||||
|
expect(form.fields[mdField][expectedPlace].originalValue.place).toEqual(expectedPlace);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear \"newValue\"', () => {
|
||||||
|
expect(form.newValue).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mark the form as changed', () => {
|
||||||
|
expect(form.hasChanges()).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('discard', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
form.discard();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove the new value', () => {
|
||||||
|
expect(form.fields[mdField][expectedPlace]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mark the form as unchanged again', () => {
|
||||||
|
expect(form.hasChanges()).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reinstate', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
form.reinstate();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should re-add the new value', () => {
|
||||||
|
expect(form.fields[mdField][expectedPlace].newValue.value).toEqual(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mark the form as changed once again', () => {
|
||||||
|
expect(form.hasChanges()).toEqual(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removing a value entirely (not just marking deleted)', () => {
|
||||||
|
it('should remove the value on the correct index', () => {
|
||||||
|
form.remove('dc.subject', 1);
|
||||||
|
expect(form.fields['dc.subject'].length).toEqual(2);
|
||||||
|
expect(form.fields['dc.subject'][0].newValue.value).toEqual('Subject One');
|
||||||
|
expect(form.fields['dc.subject'][1].newValue.value).toEqual('Subject Three');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('moving a value', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
form.fields['dc.subject'][0].newValue.place = form.fields['dc.subject'][1].originalValue.place;
|
||||||
|
form.fields['dc.subject'][1].newValue.place = form.fields['dc.subject'][0].originalValue.place;
|
||||||
|
form.fields['dc.subject'][0].confirmChanges();
|
||||||
|
form.fields['dc.subject'][1].confirmChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mark the value as changed', () => {
|
||||||
|
expect(form.fields['dc.subject'][0].hasChanges()).toEqual(true);
|
||||||
|
expect(form.fields['dc.subject'][1].hasChanges()).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mark the form as changed', () => {
|
||||||
|
expect(form.hasChanges()).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('discard', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
form.discard();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset the moved values their places to their original values', () => {
|
||||||
|
expect(form.fields['dc.subject'][0].newValue.place).toEqual(form.fields['dc.subject'][0].originalValue.place);
|
||||||
|
expect(form.fields['dc.subject'][1].newValue.place).toEqual(form.fields['dc.subject'][1].originalValue.place);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mark the form as unchanged again', () => {
|
||||||
|
expect(form.hasChanges()).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reinstate', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
form.reinstate();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should move the values to their new places again', () => {
|
||||||
|
expect(form.fields['dc.subject'][0].newValue.place).toEqual(form.fields['dc.subject'][1].originalValue.place);
|
||||||
|
expect(form.fields['dc.subject'][1].newValue.place).toEqual(form.fields['dc.subject'][0].originalValue.place);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mark the form as changed once again', () => {
|
||||||
|
expect(form.hasChanges()).toEqual(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('marking a value deleted', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
form.fields['dc.title'][0].change = DsoEditMetadataChangeType.REMOVE;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mark the value as changed', () => {
|
||||||
|
expect(form.fields['dc.title'][0].hasChanges()).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mark the form as changed', () => {
|
||||||
|
expect(form.hasChanges()).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('discard', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
form.discard();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove the deleted mark from the value', () => {
|
||||||
|
expect(form.fields['dc.title'][0].change).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mark the form as unchanged again', () => {
|
||||||
|
expect(form.hasChanges()).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reinstate', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
form.reinstate();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should re-mark the value as deleted', () => {
|
||||||
|
expect(form.fields['dc.title'][0].change).toEqual(DsoEditMetadataChangeType.REMOVE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mark the form as changed once again', () => {
|
||||||
|
expect(form.hasChanges()).toEqual(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('editing a value', () => {
|
||||||
|
const value = 'New title';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
form.fields['dc.title'][0].editing = true;
|
||||||
|
form.fields['dc.title'][0].newValue.value = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not mark the form as changed yet', () => {
|
||||||
|
expect(form.hasChanges()).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('and confirming the changes', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
form.fields['dc.title'][0].confirmChanges(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mark the value as changed', () => {
|
||||||
|
expect(form.fields['dc.title'][0].hasChanges()).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mark the form as changed', () => {
|
||||||
|
expect(form.hasChanges()).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('discard', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
form.discard();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset the changed value to its original value', () => {
|
||||||
|
expect(form.fields['dc.title'][0].newValue.value).toEqual(form.fields['dc.title'][0].originalValue.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mark the form as unchanged again', () => {
|
||||||
|
expect(form.hasChanges()).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reinstate', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
form.reinstate();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should put the changed value back in place', () => {
|
||||||
|
expect(form.fields['dc.title'][0].newValue.value).toEqual(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mark the form as changed once again', () => {
|
||||||
|
expect(form.hasChanges()).toEqual(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
453
src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-form.ts
Normal file
453
src/app/dso-shared/dso-edit-metadata/dso-edit-metadata-form.ts
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
/* eslint-disable max-classes-per-file */
|
||||||
|
import { MetadataMap, MetadataValue } from '../../core/shared/metadata.models';
|
||||||
|
import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util';
|
||||||
|
import { MoveOperation, Operation } from 'fast-json-patch';
|
||||||
|
import { MetadataPatchReplaceOperation } from '../../core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-replace-operation.model';
|
||||||
|
import { MetadataPatchRemoveOperation } from '../../core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-remove-operation.model';
|
||||||
|
import { MetadataPatchAddOperation } from '../../core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-add-operation.model';
|
||||||
|
import { ArrayMoveChangeAnalyzer } from '../../core/data/array-move-change-analyzer.service';
|
||||||
|
import { MetadataPatchMoveOperation } from '../../core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-move-operation.model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enumeration for the type of change occurring on a metadata value
|
||||||
|
*/
|
||||||
|
export enum DsoEditMetadataChangeType {
|
||||||
|
UPDATE = 1,
|
||||||
|
ADD = 2,
|
||||||
|
REMOVE = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class holding information about a metadata value and its changes within an edit form
|
||||||
|
*/
|
||||||
|
export class DsoEditMetadataValue {
|
||||||
|
/**
|
||||||
|
* The original metadata value (should stay the same!) used to compare changes with
|
||||||
|
*/
|
||||||
|
originalValue: MetadataValue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The new value, dynamically changing
|
||||||
|
*/
|
||||||
|
newValue: MetadataValue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A value that can be used to undo any discarding that took place
|
||||||
|
*/
|
||||||
|
reinstatableValue: MetadataValue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether or not this value is currently being edited or not
|
||||||
|
*/
|
||||||
|
editing = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type of change that's taking place on this metadata value
|
||||||
|
* Empty if no changes are made
|
||||||
|
*/
|
||||||
|
change: DsoEditMetadataChangeType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A flag to keep track if the value has been reordered (place has changed)
|
||||||
|
*/
|
||||||
|
reordered = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A type or change that can be used to undo any discarding that took place
|
||||||
|
*/
|
||||||
|
reinstatableChange: DsoEditMetadataChangeType;
|
||||||
|
|
||||||
|
constructor(value: MetadataValue, added = false) {
|
||||||
|
this.originalValue = value;
|
||||||
|
this.newValue = Object.assign(new MetadataValue(), value);
|
||||||
|
if (added) {
|
||||||
|
this.change = DsoEditMetadataChangeType.ADD;
|
||||||
|
this.editing = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save the current changes made to the metadata value
|
||||||
|
* This will set the type of change to UPDATE if the new metadata value's value and/or language are different from
|
||||||
|
* the original value
|
||||||
|
* It will also set the editing flag to false
|
||||||
|
*/
|
||||||
|
confirmChanges(finishEditing = false) {
|
||||||
|
this.reordered = this.originalValue.place !== this.newValue.place;
|
||||||
|
if (hasNoValue(this.change) || this.change === DsoEditMetadataChangeType.UPDATE) {
|
||||||
|
if ((this.originalValue.value !== this.newValue.value || this.originalValue.language !== this.newValue.language)) {
|
||||||
|
this.change = DsoEditMetadataChangeType.UPDATE;
|
||||||
|
} else {
|
||||||
|
this.change = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (finishEditing) {
|
||||||
|
this.editing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns if the current value contains changes or not
|
||||||
|
* If the metadata value contains changes, but they haven't been confirmed yet through confirmChanges(), this might
|
||||||
|
* return false (which is desired)
|
||||||
|
*/
|
||||||
|
hasChanges(): boolean {
|
||||||
|
return hasValue(this.change) || this.reordered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discard the current changes and mark the value and change type re-instatable by storing them in their relevant
|
||||||
|
* properties
|
||||||
|
*/
|
||||||
|
discardAndMarkReinstatable(): void {
|
||||||
|
if (this.change === DsoEditMetadataChangeType.UPDATE || this.reordered) {
|
||||||
|
this.reinstatableValue = this.newValue;
|
||||||
|
}
|
||||||
|
this.reinstatableChange = this.change;
|
||||||
|
this.discard(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discard the current changes
|
||||||
|
* Call discardAndMarkReinstatable() instead, if the discard should be re-instatable
|
||||||
|
*/
|
||||||
|
discard(keepPlace = true): void {
|
||||||
|
this.change = undefined;
|
||||||
|
const place = this.newValue.place;
|
||||||
|
this.newValue = Object.assign(new MetadataValue(), this.originalValue);
|
||||||
|
if (keepPlace) {
|
||||||
|
this.newValue.place = place;
|
||||||
|
}
|
||||||
|
this.confirmChanges(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-instate (undo) the last discard by replacing the value and change type with their reinstate properties (if present)
|
||||||
|
*/
|
||||||
|
reinstate(): void {
|
||||||
|
if (hasValue(this.reinstatableValue)) {
|
||||||
|
this.newValue = this.reinstatableValue;
|
||||||
|
this.reinstatableValue = undefined;
|
||||||
|
}
|
||||||
|
if (hasValue(this.reinstatableChange)) {
|
||||||
|
this.change = this.reinstatableChange;
|
||||||
|
this.reinstatableChange = undefined;
|
||||||
|
}
|
||||||
|
this.confirmChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns if either the value or change type have a re-instatable property
|
||||||
|
* This will be the case if a discard has taken place that undid changes to the value or type
|
||||||
|
*/
|
||||||
|
isReinstatable(): boolean {
|
||||||
|
return hasValue(this.reinstatableValue) || hasValue(this.reinstatableChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the state of the re-instatable properties
|
||||||
|
*/
|
||||||
|
resetReinstatable() {
|
||||||
|
this.reinstatableValue = undefined;
|
||||||
|
this.reinstatableChange = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class holding information about the metadata of a DSpaceObject and its changes within an edit form
|
||||||
|
*/
|
||||||
|
export class DsoEditMetadataForm {
|
||||||
|
/**
|
||||||
|
* List of original metadata field keys (before any changes took place)
|
||||||
|
*/
|
||||||
|
originalFieldKeys: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of current metadata field keys (includes new fields for values added by the user)
|
||||||
|
*/
|
||||||
|
fieldKeys: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current state of the form
|
||||||
|
* Key: Metadata field
|
||||||
|
* Value: List of {@link DsoEditMetadataValue}s for the metadata field
|
||||||
|
*/
|
||||||
|
fields: {
|
||||||
|
[mdField: string]: DsoEditMetadataValue[],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A map of previously added metadata values before a discard of the form took place
|
||||||
|
* This can be used to re-instate the entire form to before the discard taking place
|
||||||
|
*/
|
||||||
|
reinstatableNewValues: {
|
||||||
|
[mdField: string]: DsoEditMetadataValue[],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A (temporary) new metadata value added by the user, not belonging to a metadata field yet
|
||||||
|
* This value will be finalised and added to a field using setMetadataField()
|
||||||
|
*/
|
||||||
|
newValue: DsoEditMetadataValue;
|
||||||
|
|
||||||
|
constructor(metadata: MetadataMap) {
|
||||||
|
this.originalFieldKeys = [];
|
||||||
|
this.fieldKeys = [];
|
||||||
|
this.fields = {};
|
||||||
|
this.reinstatableNewValues = {};
|
||||||
|
Object.entries(metadata).forEach(([mdField, values]: [string, MetadataValue[]]) => {
|
||||||
|
this.originalFieldKeys.push(mdField);
|
||||||
|
this.fieldKeys.push(mdField);
|
||||||
|
this.setValuesForFieldSorted(mdField, values.map((value: MetadataValue) => new DsoEditMetadataValue(value)));
|
||||||
|
});
|
||||||
|
this.sortFieldKeys();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new temporary value for the user to edit
|
||||||
|
*/
|
||||||
|
add(): void {
|
||||||
|
if (hasNoValue(this.newValue)) {
|
||||||
|
this.newValue = new DsoEditMetadataValue(new MetadataValue(), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the temporary value to a metadata field
|
||||||
|
* Clear the temporary value afterwards
|
||||||
|
* @param mdField
|
||||||
|
*/
|
||||||
|
setMetadataField(mdField: string): void {
|
||||||
|
this.newValue.editing = false;
|
||||||
|
this.addValueToField(this.newValue, mdField);
|
||||||
|
// Set the place property to match the new value's position within its field
|
||||||
|
const place = this.fields[mdField].length - 1;
|
||||||
|
this.fields[mdField][place].originalValue.place = place;
|
||||||
|
this.fields[mdField][place].newValue.place = place;
|
||||||
|
this.newValue = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a value to a metadata field within the map
|
||||||
|
* @param value
|
||||||
|
* @param mdField
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private addValueToField(value: DsoEditMetadataValue, mdField: string): void {
|
||||||
|
if (isEmpty(this.fields[mdField])) {
|
||||||
|
this.fieldKeys.push(mdField);
|
||||||
|
this.sortFieldKeys();
|
||||||
|
this.fields[mdField] = [];
|
||||||
|
}
|
||||||
|
this.fields[mdField].push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a value from a metadata field on a given index (this actually removes the value, not just marking it deleted)
|
||||||
|
* @param mdField
|
||||||
|
* @param index
|
||||||
|
*/
|
||||||
|
remove(mdField: string, index: number): void {
|
||||||
|
if (isNotEmpty(this.fields[mdField])) {
|
||||||
|
this.fields[mdField].splice(index, 1);
|
||||||
|
if (this.fields[mdField].length === 0) {
|
||||||
|
this.fieldKeys.splice(this.fieldKeys.indexOf(mdField), 1);
|
||||||
|
delete this.fields[mdField];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns if at least one value within the form contains a change
|
||||||
|
*/
|
||||||
|
hasChanges(): boolean {
|
||||||
|
return Object.values(this.fields).some((values: DsoEditMetadataValue[]) => values.some((value: DsoEditMetadataValue) => value.hasChanges()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a metadata field contains changes within its order (place property of values)
|
||||||
|
* @param mdField
|
||||||
|
*/
|
||||||
|
hasOrderChanges(mdField: string): boolean {
|
||||||
|
return this.fields[mdField].some((value: DsoEditMetadataValue) => value.originalValue.place !== value.newValue.place);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discard all changes within the form and store their current values within re-instatable properties so they can be
|
||||||
|
* undone afterwards
|
||||||
|
*/
|
||||||
|
discard(): void {
|
||||||
|
this.resetReinstatable();
|
||||||
|
// Discard changes from each value from each field
|
||||||
|
Object.entries(this.fields).forEach(([field, values]: [string, DsoEditMetadataValue[]]) => {
|
||||||
|
let removeFromIndex = -1;
|
||||||
|
values.forEach((value: DsoEditMetadataValue, index: number) => {
|
||||||
|
if (value.change === DsoEditMetadataChangeType.ADD) {
|
||||||
|
if (isEmpty(this.reinstatableNewValues[field])) {
|
||||||
|
this.reinstatableNewValues[field] = [];
|
||||||
|
}
|
||||||
|
this.reinstatableNewValues[field].push(value);
|
||||||
|
if (removeFromIndex === -1) {
|
||||||
|
removeFromIndex = index;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
value.discardAndMarkReinstatable();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (removeFromIndex > -1) {
|
||||||
|
this.fields[field].splice(removeFromIndex, this.fields[field].length - removeFromIndex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Delete new metadata fields
|
||||||
|
this.fieldKeys.forEach((field: string) => {
|
||||||
|
if (this.originalFieldKeys.indexOf(field) < 0) {
|
||||||
|
delete this.fields[field];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.fieldKeys = [...this.originalFieldKeys];
|
||||||
|
this.sortFieldKeys();
|
||||||
|
// Reset the order of values within their fields to match their place property
|
||||||
|
this.fieldKeys.forEach((field: string) => {
|
||||||
|
this.setValuesForFieldSorted(field, this.fields[field]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the order of values within a metadata field to their original places
|
||||||
|
* Update the actual array to match the place properties
|
||||||
|
* @param mdField
|
||||||
|
*/
|
||||||
|
resetOrder(mdField: string) {
|
||||||
|
this.fields[mdField].forEach((value: DsoEditMetadataValue) => {
|
||||||
|
value.newValue.place = value.originalValue.place;
|
||||||
|
value.confirmChanges();
|
||||||
|
});
|
||||||
|
this.setValuesForFieldSorted(mdField, this.fields[mdField]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort fieldKeys alphabetically
|
||||||
|
* Should be called whenever a field is added to ensure the alphabetical order is kept
|
||||||
|
*/
|
||||||
|
sortFieldKeys() {
|
||||||
|
this.fieldKeys.sort((a: string, b: string) => a.localeCompare(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Undo any previously discarded changes
|
||||||
|
*/
|
||||||
|
reinstate(): void {
|
||||||
|
// Reinstate each value
|
||||||
|
Object.values(this.fields).forEach((values: DsoEditMetadataValue[]) => {
|
||||||
|
values.forEach((value: DsoEditMetadataValue) => {
|
||||||
|
value.reinstate();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Re-add new values
|
||||||
|
Object.entries(this.reinstatableNewValues).forEach(([field, values]: [string, DsoEditMetadataValue[]]) => {
|
||||||
|
values.forEach((value: DsoEditMetadataValue) => {
|
||||||
|
this.addValueToField(value, field);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Reset the order of values within their fields to match their place property
|
||||||
|
this.fieldKeys.forEach((field: string) => {
|
||||||
|
this.setValuesForFieldSorted(field, this.fields[field]);
|
||||||
|
});
|
||||||
|
this.reinstatableNewValues = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns if at least one value contains a re-instatable property, meaning a discard can be reversed
|
||||||
|
*/
|
||||||
|
isReinstatable(): boolean {
|
||||||
|
return isNotEmpty(this.reinstatableNewValues) ||
|
||||||
|
Object.values(this.fields)
|
||||||
|
.some((values: DsoEditMetadataValue[]) => values
|
||||||
|
.some((value: DsoEditMetadataValue) => value.isReinstatable()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the state of the re-instatable properties and values
|
||||||
|
*/
|
||||||
|
resetReinstatable(): void {
|
||||||
|
this.reinstatableNewValues = {};
|
||||||
|
Object.values(this.fields).forEach((values: DsoEditMetadataValue[]) => {
|
||||||
|
values.forEach((value: DsoEditMetadataValue) => {
|
||||||
|
value.resetReinstatable();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the values of a metadata field and sort them by their newValue's place property
|
||||||
|
* @param mdField
|
||||||
|
* @param values
|
||||||
|
*/
|
||||||
|
private setValuesForFieldSorted(mdField: string, values: DsoEditMetadataValue[]) {
|
||||||
|
this.fields[mdField] = values.sort((a: DsoEditMetadataValue, b: DsoEditMetadataValue) => a.newValue.place - b.newValue.place);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the json PATCH operations for the current changes within this form
|
||||||
|
* For each metadata field, it'll return operations in the following order: replace, remove (from last to first place), add and move
|
||||||
|
* This order is important, as each operation is executed in succession of the previous one
|
||||||
|
*/
|
||||||
|
getOperations(moveAnalyser: ArrayMoveChangeAnalyzer<number>): Operation[] {
|
||||||
|
const operations: Operation[] = [];
|
||||||
|
Object.entries(this.fields).forEach(([field, values]: [string, DsoEditMetadataValue[]]) => {
|
||||||
|
const replaceOperations: MetadataPatchReplaceOperation[] = [];
|
||||||
|
const removeOperations: MetadataPatchRemoveOperation[] = [];
|
||||||
|
const addOperations: MetadataPatchAddOperation[] = [];
|
||||||
|
[...values]
|
||||||
|
.sort((a: DsoEditMetadataValue, b: DsoEditMetadataValue) => a.originalValue.place - b.originalValue.place)
|
||||||
|
.forEach((value: DsoEditMetadataValue) => {
|
||||||
|
if (hasValue(value.change)) {
|
||||||
|
if (value.change === DsoEditMetadataChangeType.UPDATE) {
|
||||||
|
// Only changes to value or language are considered "replace" operations. Changes to place are considered "move", which is processed below.
|
||||||
|
if (value.originalValue.value !== value.newValue.value || value.originalValue.language !== value.newValue.language) {
|
||||||
|
replaceOperations.push(new MetadataPatchReplaceOperation(field, value.originalValue.place, {
|
||||||
|
value: value.newValue.value,
|
||||||
|
language: value.newValue.language,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} else if (value.change === DsoEditMetadataChangeType.REMOVE) {
|
||||||
|
removeOperations.push(new MetadataPatchRemoveOperation(field, value.originalValue.place));
|
||||||
|
} else if (value.change === DsoEditMetadataChangeType.ADD) {
|
||||||
|
addOperations.push(new MetadataPatchAddOperation(field, {
|
||||||
|
value: value.newValue.value,
|
||||||
|
language: value.newValue.language,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
console.warn('Illegal metadata change state detected for', value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
operations.push(...replaceOperations
|
||||||
|
.map((operation: MetadataPatchReplaceOperation) => operation.toOperation()));
|
||||||
|
operations.push(...removeOperations
|
||||||
|
// Sort remove operations backwards first, because they get executed in order. This avoids one removal affecting the next.
|
||||||
|
.sort((a: MetadataPatchRemoveOperation, b: MetadataPatchRemoveOperation) => b.place - a.place)
|
||||||
|
.map((operation: MetadataPatchRemoveOperation) => operation.toOperation()));
|
||||||
|
operations.push(...addOperations
|
||||||
|
.map((operation: MetadataPatchAddOperation) => operation.toOperation()));
|
||||||
|
});
|
||||||
|
// Calculate and add the move operations that need to happen in order to move value from their old place to their new within the field
|
||||||
|
// This uses an ArrayMoveChangeAnalyzer
|
||||||
|
Object.entries(this.fields).forEach(([field, values]: [string, DsoEditMetadataValue[]]) => {
|
||||||
|
// Exclude values marked for removal, because operations are executed in order (remove first, then move)
|
||||||
|
const valuesWithoutRemoved = values.filter((value: DsoEditMetadataValue) => value.change !== DsoEditMetadataChangeType.REMOVE);
|
||||||
|
const moveOperations = moveAnalyser
|
||||||
|
.diff(
|
||||||
|
[...valuesWithoutRemoved]
|
||||||
|
.sort((a: DsoEditMetadataValue, b: DsoEditMetadataValue) => a.originalValue.place - b.originalValue.place)
|
||||||
|
.map((value: DsoEditMetadataValue) => value.originalValue.place),
|
||||||
|
[...valuesWithoutRemoved]
|
||||||
|
.sort((a: DsoEditMetadataValue, b: DsoEditMetadataValue) => a.newValue.place - b.newValue.place)
|
||||||
|
.map((value: DsoEditMetadataValue) => value.originalValue.place))
|
||||||
|
.map((operation: MoveOperation) => new MetadataPatchMoveOperation(field, +operation.from.substr(1), +operation.path.substr(1)).toOperation());
|
||||||
|
operations.push(...moveOperations);
|
||||||
|
});
|
||||||
|
return operations;
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,10 @@
|
|||||||
|
<div class="d-flex flex-row ds-field-row ds-header-row">
|
||||||
|
<div class="lbl-cell">{{ dsoType + '.edit.metadata.headers.field' | translate }}</div>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="d-flex flex-row">
|
||||||
|
<div class="flex-grow-1 ds-flex-cell ds-value-cell"><b class="dont-break-out preserve-line-breaks">{{ dsoType + '.edit.metadata.headers.value' | translate }}</b></div>
|
||||||
|
<div class="ds-flex-cell ds-lang-cell"><b>{{ dsoType + '.edit.metadata.headers.language' | translate }}</b></div>
|
||||||
|
<div class="text-center ds-flex-cell ds-edit-cell"><b>{{ dsoType + '.edit.metadata.headers.edit' | translate }}</b></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@@ -0,0 +1,12 @@
|
|||||||
|
.lbl-cell {
|
||||||
|
min-width: var(--ds-dso-edit-field-width);
|
||||||
|
max-width: var(--ds-dso-edit-field-width);
|
||||||
|
background-color: var(--bs-gray-100);
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--bs-gray-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-header-row {
|
||||||
|
background-color: var(--bs-gray-100);
|
||||||
|
}
|
@@ -0,0 +1,32 @@
|
|||||||
|
import { DsoEditMetadataHeadersComponent } from './dso-edit-metadata-headers.component';
|
||||||
|
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||||
|
import { VarDirective } from '../../../shared/utils/var.directive';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { RouterTestingModule } from '@angular/router/testing';
|
||||||
|
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
|
import { By } from '@angular/platform-browser';
|
||||||
|
|
||||||
|
describe('DsoEditMetadataHeadersComponent', () => {
|
||||||
|
let component: DsoEditMetadataHeadersComponent;
|
||||||
|
let fixture: ComponentFixture<DsoEditMetadataHeadersComponent>;
|
||||||
|
|
||||||
|
beforeEach(waitForAsync(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
declarations: [DsoEditMetadataHeadersComponent, VarDirective],
|
||||||
|
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])],
|
||||||
|
providers: [
|
||||||
|
],
|
||||||
|
schemas: [NO_ERRORS_SCHEMA]
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(DsoEditMetadataHeadersComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display three headers', () => {
|
||||||
|
expect(fixture.debugElement.queryAll(By.css('.ds-flex-cell')).length).toEqual(3);
|
||||||
|
});
|
||||||
|
});
|
@@ -0,0 +1,17 @@
|
|||||||
|
import { Component, Input } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-dso-edit-metadata-headers',
|
||||||
|
styleUrls: ['./dso-edit-metadata-headers.component.scss', '../dso-edit-metadata-shared/dso-edit-metadata-cells.scss'],
|
||||||
|
templateUrl: './dso-edit-metadata-headers.component.html',
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* Component displaying the header table row for DSO edit metadata page
|
||||||
|
*/
|
||||||
|
export class DsoEditMetadataHeadersComponent {
|
||||||
|
/**
|
||||||
|
* Type of DSO we're displaying values for
|
||||||
|
* Determines i18n messages
|
||||||
|
*/
|
||||||
|
@Input() dsoType: string;
|
||||||
|
}
|
@@ -0,0 +1,49 @@
|
|||||||
|
.ds-field-row {
|
||||||
|
border: 1px solid var(--bs-gray-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-flex-cell {
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--bs-gray-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-lang-cell {
|
||||||
|
min-width: var(--ds-dso-edit-lang-width);
|
||||||
|
max-width: var(--ds-dso-edit-lang-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-edit-cell {
|
||||||
|
min-width: var(--ds-dso-edit-actions-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-value-row {
|
||||||
|
background-color: white;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ds-warning {
|
||||||
|
background-color: var(--bs-warning-bg);
|
||||||
|
|
||||||
|
.ds-flex-cell {
|
||||||
|
border: 1px solid var(--bs-warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ds-danger {
|
||||||
|
background-color: var(--bs-danger-bg);
|
||||||
|
|
||||||
|
.ds-flex-cell {
|
||||||
|
border: 1px solid var(--bs-danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ds-success {
|
||||||
|
background-color: var(--bs-success-bg);
|
||||||
|
|
||||||
|
.ds-flex-cell {
|
||||||
|
border: 1px solid var(--bs-success);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,5 @@
|
|||||||
|
<div role="row" class="visually-hidden">
|
||||||
|
<div role="columnheader">{{ dsoType + '.edit.metadata.headers.value' | translate }}</div>
|
||||||
|
<div role="columnheader">{{ dsoType + '.edit.metadata.headers.language' | translate }}</div>
|
||||||
|
<div role="columnheader">{{ dsoType + '.edit.metadata.headers.edit' | translate }}</div>
|
||||||
|
</div>
|
@@ -0,0 +1,17 @@
|
|||||||
|
import { Component, Input } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-dso-edit-metadata-value-headers',
|
||||||
|
styleUrls: ['./dso-edit-metadata-value-headers.component.scss', '../dso-edit-metadata-shared/dso-edit-metadata-cells.scss'],
|
||||||
|
templateUrl: './dso-edit-metadata-value-headers.component.html',
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* Component displaying invisible headers for a list of metadata values using table roles for accessibility
|
||||||
|
*/
|
||||||
|
export class DsoEditMetadataValueHeadersComponent {
|
||||||
|
/**
|
||||||
|
* Type of DSO we're displaying values for
|
||||||
|
* Determines i18n messages
|
||||||
|
*/
|
||||||
|
@Input() dsoType: string;
|
||||||
|
}
|
@@ -0,0 +1,56 @@
|
|||||||
|
<div class="d-flex flex-row ds-value-row" *ngVar="mdValue.newValue.isVirtual as isVirtual" role="row"
|
||||||
|
cdkDrag (cdkDragStarted)="dragging.emit(true)" (cdkDragEnded)="dragging.emit(false)"
|
||||||
|
[ngClass]="{ 'ds-warning': mdValue.reordered || mdValue.change === DsoEditMetadataChangeTypeEnum.UPDATE, 'ds-danger': mdValue.change === DsoEditMetadataChangeTypeEnum.REMOVE, 'ds-success': mdValue.change === DsoEditMetadataChangeTypeEnum.ADD, 'h-100': isOnlyValue }">
|
||||||
|
<div class="flex-grow-1 ds-flex-cell ds-value-cell d-flex align-items-center" *ngVar="(mdRepresentation$ | async) as mdRepresentation" role="cell">
|
||||||
|
<div class="dont-break-out preserve-line-breaks" *ngIf="!mdValue.editing && !mdRepresentation">{{ mdValue.newValue.value }}</div>
|
||||||
|
<textarea class="form-control" rows="5" *ngIf="mdValue.editing && !mdRepresentation" [(ngModel)]="mdValue.newValue.value"
|
||||||
|
[dsDebounce]="300" (onDebounce)="confirm.emit(false)"></textarea>
|
||||||
|
<div class="d-flex" *ngIf="mdRepresentation">
|
||||||
|
<a class="mr-2" target="_blank" [routerLink]="mdRepresentationItemRoute$ | async">{{ mdRepresentationName$ | async }}</a>
|
||||||
|
<ds-type-badge [object]="mdRepresentation"></ds-type-badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ds-flex-cell ds-lang-cell" role="cell">
|
||||||
|
<div class="dont-break-out preserve-line-breaks" *ngIf="!mdValue.editing">{{ mdValue.newValue.language }}</div>
|
||||||
|
<input class="form-control" type="text" *ngIf="mdValue.editing" [(ngModel)]="mdValue.newValue.language"
|
||||||
|
[dsDebounce]="300" (onDebounce)="confirm.emit(false)" />
|
||||||
|
</div>
|
||||||
|
<div class="text-center ds-flex-cell ds-edit-cell" role="cell">
|
||||||
|
<div class="btn-group">
|
||||||
|
<div class="edit-field">
|
||||||
|
<div class="btn-group edit-buttons" [ngbTooltip]="isVirtual ? (dsoType + '.edit.metadata.edit.buttons.virtual' | translate) : null">
|
||||||
|
<button class="btn btn-outline-primary btn-sm ng-star-inserted" id="metadata-edit-btn" *ngIf="!mdValue.editing"
|
||||||
|
[title]="dsoType + '.edit.metadata.edit.buttons.edit' | translate"
|
||||||
|
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.edit' | translate }}"
|
||||||
|
[disabled]="isVirtual || mdValue.change === DsoEditMetadataChangeTypeEnum.REMOVE || (saving$ | async)" (click)="edit.emit()">
|
||||||
|
<i class="fas fa-edit fa-fw"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-success btn-sm ng-star-inserted" id="metadata-confirm-btn" *ngIf="mdValue.editing"
|
||||||
|
[title]="dsoType + '.edit.metadata.edit.buttons.confirm' | translate"
|
||||||
|
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.confirm' | translate }}"
|
||||||
|
[disabled]="isVirtual || (saving$ | async)" (click)="confirm.emit(true)">
|
||||||
|
<i class="fas fa-check fa-fw"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-danger btn-sm" id="metadata-remove-btn"
|
||||||
|
[title]="dsoType + '.edit.metadata.edit.buttons.remove' | translate"
|
||||||
|
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.remove' | translate }}"
|
||||||
|
[disabled]="isVirtual || (mdValue.change && mdValue.change !== DsoEditMetadataChangeTypeEnum.ADD) || mdValue.editing || (saving$ | async)" (click)="remove.emit()">
|
||||||
|
<i class="fas fa-trash-alt fa-fw"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-warning btn-sm" id="metadata-undo-btn"
|
||||||
|
[title]="dsoType + '.edit.metadata.edit.buttons.undo' | translate"
|
||||||
|
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.undo' | translate }}"
|
||||||
|
[disabled]="isVirtual || (!mdValue.change && mdValue.reordered) || (!mdValue.change && !mdValue.editing) || (saving$ | async)" (click)="undo.emit()">
|
||||||
|
<i class="fas fa-undo-alt fa-fw"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-outline-secondary ds-drag-handle btn-sm" id="metadata-drag-btn" *ngVar="(isOnlyValue || (saving$ | async)) as disabled"
|
||||||
|
cdkDragHandle [cdkDragHandleDisabled]="disabled" [ngClass]="{'disabled': disabled}" [disabled]="disabled"
|
||||||
|
[title]="dsoType + '.edit.metadata.edit.buttons.drag' | translate"
|
||||||
|
ngbTooltip="{{ dsoType + '.edit.metadata.edit.buttons.drag' | translate }}">
|
||||||
|
<i class="fas fa-grip-vertical fa-fw"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@@ -0,0 +1,16 @@
|
|||||||
|
.ds-success {
|
||||||
|
background-color: var(--bs-success-bg);
|
||||||
|
border: 1px solid var(--bs-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-drag-handle:not(.disabled) {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
::ng-deep .edit-field>ngb-tooltip-window .tooltip-inner {
|
||||||
|
min-width: var(--ds-dso-edit-virtual-tooltip-min-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdk-drag-placeholder {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
@@ -0,0 +1,170 @@
|
|||||||
|
import { DsoEditMetadataValueComponent } from './dso-edit-metadata-value.component';
|
||||||
|
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||||
|
import { VarDirective } from '../../../shared/utils/var.directive';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { RouterTestingModule } from '@angular/router/testing';
|
||||||
|
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
|
import { RelationshipDataService } from '../../../core/data/relationship-data.service';
|
||||||
|
import { DSONameService } from '../../../core/breadcrumbs/dso-name.service';
|
||||||
|
import { of } from 'rxjs/internal/observable/of';
|
||||||
|
import { ItemMetadataRepresentation } from '../../../core/shared/metadata-representation/item/item-metadata-representation.model';
|
||||||
|
import { MetadataValue, VIRTUAL_METADATA_PREFIX } from '../../../core/shared/metadata.models';
|
||||||
|
import { DsoEditMetadataChangeType, DsoEditMetadataValue } from '../dso-edit-metadata-form';
|
||||||
|
import { By } from '@angular/platform-browser';
|
||||||
|
|
||||||
|
const EDIT_BTN = 'edit';
|
||||||
|
const CONFIRM_BTN = 'confirm';
|
||||||
|
const REMOVE_BTN = 'remove';
|
||||||
|
const UNDO_BTN = 'undo';
|
||||||
|
const DRAG_BTN = 'drag';
|
||||||
|
|
||||||
|
describe('DsoEditMetadataValueComponent', () => {
|
||||||
|
let component: DsoEditMetadataValueComponent;
|
||||||
|
let fixture: ComponentFixture<DsoEditMetadataValueComponent>;
|
||||||
|
|
||||||
|
let relationshipService: RelationshipDataService;
|
||||||
|
let dsoNameService: DSONameService;
|
||||||
|
|
||||||
|
let editMetadataValue: DsoEditMetadataValue;
|
||||||
|
let metadataValue: MetadataValue;
|
||||||
|
|
||||||
|
function initServices(): void {
|
||||||
|
relationshipService = jasmine.createSpyObj('relationshipService', {
|
||||||
|
resolveMetadataRepresentation: of(new ItemMetadataRepresentation(metadataValue)),
|
||||||
|
});
|
||||||
|
dsoNameService = jasmine.createSpyObj('dsoNameService', {
|
||||||
|
getName: 'Related Name',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(waitForAsync(() => {
|
||||||
|
metadataValue = Object.assign(new MetadataValue(), {
|
||||||
|
value: 'Regular Name',
|
||||||
|
language: 'en',
|
||||||
|
place: 0,
|
||||||
|
authority: undefined,
|
||||||
|
});
|
||||||
|
editMetadataValue = new DsoEditMetadataValue(metadataValue);
|
||||||
|
|
||||||
|
initServices();
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
declarations: [DsoEditMetadataValueComponent, VarDirective],
|
||||||
|
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])],
|
||||||
|
providers: [
|
||||||
|
{ provide: RelationshipDataService, useValue: relationshipService },
|
||||||
|
{ provide: DSONameService, useValue: dsoNameService },
|
||||||
|
],
|
||||||
|
schemas: [NO_ERRORS_SCHEMA]
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(DsoEditMetadataValueComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
component.mdValue = editMetadataValue;
|
||||||
|
component.saving$ = of(false);
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not show a badge', () => {
|
||||||
|
expect(fixture.debugElement.query(By.css('ds-type-badge'))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when no changes have been made', () => {
|
||||||
|
assertButton(EDIT_BTN, true, false);
|
||||||
|
assertButton(CONFIRM_BTN, false);
|
||||||
|
assertButton(REMOVE_BTN, true, false);
|
||||||
|
assertButton(UNDO_BTN, true, true);
|
||||||
|
assertButton(DRAG_BTN, true, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when this is the only metadata value within its field', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
component.isOnlyValue = true;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
assertButton(DRAG_BTN, true, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when the value is marked for removal', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
editMetadataValue.change = DsoEditMetadataChangeType.REMOVE;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
assertButton(REMOVE_BTN, true, true);
|
||||||
|
assertButton(UNDO_BTN, true, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when the value is being edited', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
editMetadataValue.editing = true;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
assertButton(EDIT_BTN, false);
|
||||||
|
assertButton(CONFIRM_BTN, true, false);
|
||||||
|
assertButton(UNDO_BTN, true, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when the value is new', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
editMetadataValue.change = DsoEditMetadataChangeType.ADD;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
assertButton(REMOVE_BTN, true, false);
|
||||||
|
assertButton(UNDO_BTN, true, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when the metadata value is virtual', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
metadataValue = Object.assign(new MetadataValue(), {
|
||||||
|
value: 'Virtual Name',
|
||||||
|
language: 'en',
|
||||||
|
place: 0,
|
||||||
|
authority: `${VIRTUAL_METADATA_PREFIX}authority-key`,
|
||||||
|
});
|
||||||
|
editMetadataValue = new DsoEditMetadataValue(metadataValue);
|
||||||
|
component.mdValue = editMetadataValue;
|
||||||
|
component.ngOnInit();
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show a badge', () => {
|
||||||
|
expect(fixture.debugElement.query(By.css('ds-type-badge'))).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
assertButton(EDIT_BTN, true, true);
|
||||||
|
assertButton(CONFIRM_BTN, false);
|
||||||
|
assertButton(REMOVE_BTN, true, true);
|
||||||
|
assertButton(UNDO_BTN, true, true);
|
||||||
|
assertButton(DRAG_BTN, true, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
function assertButton(name: string, exists: boolean, disabled: boolean = false): void {
|
||||||
|
describe(`${name} button`, () => {
|
||||||
|
let btn: DebugElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
btn = fixture.debugElement.query(By.css(`#metadata-${name}-btn`));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
it('should exist', () => {
|
||||||
|
expect(btn).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`should${disabled ? ' ' : ' not '}be disabled`, () => {
|
||||||
|
expect(btn.nativeElement.disabled).toBe(disabled);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
it('should not exist', () => {
|
||||||
|
expect(btn).toBeNull();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
@@ -0,0 +1,126 @@
|
|||||||
|
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||||
|
import { DsoEditMetadataChangeType, DsoEditMetadataValue } from '../dso-edit-metadata-form';
|
||||||
|
import { Observable } from 'rxjs/internal/Observable';
|
||||||
|
import {
|
||||||
|
MetadataRepresentation,
|
||||||
|
MetadataRepresentationType
|
||||||
|
} from '../../../core/shared/metadata-representation/metadata-representation.model';
|
||||||
|
import { RelationshipDataService } from '../../../core/data/relationship-data.service';
|
||||||
|
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
|
||||||
|
import { ItemMetadataRepresentation } from '../../../core/shared/metadata-representation/item/item-metadata-representation.model';
|
||||||
|
import { map } from 'rxjs/operators';
|
||||||
|
import { getItemPageRoute } from '../../../item-page/item-page-routing-paths';
|
||||||
|
import { DSONameService } from '../../../core/breadcrumbs/dso-name.service';
|
||||||
|
import { EMPTY } from 'rxjs/internal/observable/empty';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-dso-edit-metadata-value',
|
||||||
|
styleUrls: ['./dso-edit-metadata-value.component.scss', '../dso-edit-metadata-shared/dso-edit-metadata-cells.scss'],
|
||||||
|
templateUrl: './dso-edit-metadata-value.component.html',
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* Component displaying a single editable row for a metadata value
|
||||||
|
*/
|
||||||
|
export class DsoEditMetadataValueComponent implements OnInit {
|
||||||
|
/**
|
||||||
|
* The parent {@link DSpaceObject} to display a metadata form for
|
||||||
|
* Also used to determine metadata-representations in case of virtual metadata
|
||||||
|
*/
|
||||||
|
@Input() dso: DSpaceObject;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editable metadata value to show
|
||||||
|
*/
|
||||||
|
@Input() mdValue: DsoEditMetadataValue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type of DSO we're displaying values for
|
||||||
|
* Determines i18n messages
|
||||||
|
*/
|
||||||
|
@Input() dsoType: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observable to check if the form is being saved or not
|
||||||
|
* Will disable certain functionality while saving
|
||||||
|
*/
|
||||||
|
@Input() saving$: Observable<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is this value the only one within its list?
|
||||||
|
* Will disable certain functionality like dragging (because dragging within a list of 1 is pointless)
|
||||||
|
*/
|
||||||
|
@Input() isOnlyValue = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits when the user clicked edit
|
||||||
|
*/
|
||||||
|
@Output() edit: EventEmitter<any> = new EventEmitter<any>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits when the user clicked confirm
|
||||||
|
*/
|
||||||
|
@Output() confirm: EventEmitter<boolean> = new EventEmitter<boolean>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits when the user clicked remove
|
||||||
|
*/
|
||||||
|
@Output() remove: EventEmitter<any> = new EventEmitter<any>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits when the user clicked undo
|
||||||
|
*/
|
||||||
|
@Output() undo: EventEmitter<any> = new EventEmitter<any>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits true when the user starts dragging a value, false when the user stops dragging
|
||||||
|
*/
|
||||||
|
@Output() dragging: EventEmitter<boolean> = new EventEmitter<boolean>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The DsoEditMetadataChangeType enumeration for access in the component's template
|
||||||
|
* @type {DsoEditMetadataChangeType}
|
||||||
|
*/
|
||||||
|
public DsoEditMetadataChangeTypeEnum = DsoEditMetadataChangeType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The item this metadata value represents in case it's virtual (if any, otherwise null)
|
||||||
|
*/
|
||||||
|
mdRepresentation$: Observable<ItemMetadataRepresentation | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The route to the item represented by this virtual metadata value (otherwise null)
|
||||||
|
*/
|
||||||
|
mdRepresentationItemRoute$: Observable<string | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name of the item represented by this virtual metadata value (otherwise null)
|
||||||
|
*/
|
||||||
|
mdRepresentationName$: Observable<string | null>;
|
||||||
|
|
||||||
|
constructor(protected relationshipService: RelationshipDataService,
|
||||||
|
protected dsoNameService: DSONameService) {
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.initVirtualProperties();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialise potential properties of a virtual metadata value
|
||||||
|
*/
|
||||||
|
initVirtualProperties(): void {
|
||||||
|
this.mdRepresentation$ = this.mdValue.newValue.isVirtual ?
|
||||||
|
this.relationshipService.resolveMetadataRepresentation(this.mdValue.newValue, this.dso, 'Item')
|
||||||
|
.pipe(
|
||||||
|
map((mdRepresentation: MetadataRepresentation) =>
|
||||||
|
mdRepresentation.representationType === MetadataRepresentationType.Item ? mdRepresentation as ItemMetadataRepresentation : null
|
||||||
|
)
|
||||||
|
) : EMPTY;
|
||||||
|
this.mdRepresentationItemRoute$ = this.mdRepresentation$.pipe(
|
||||||
|
map((mdRepresentation: ItemMetadataRepresentation) => mdRepresentation ? getItemPageRoute(mdRepresentation) : null),
|
||||||
|
);
|
||||||
|
this.mdRepresentationName$ = this.mdRepresentation$.pipe(
|
||||||
|
map((mdRepresentation: ItemMetadataRepresentation) => mdRepresentation ? this.dsoNameService.getName(mdRepresentation) : null),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,91 @@
|
|||||||
|
<div class="item-metadata" *ngIf="form">
|
||||||
|
<div class="button-row top d-flex my-2 space-children-mr ml-gap">
|
||||||
|
<button class="mr-auto btn btn-success" id="dso-add-btn" [disabled]="form.newValue || (saving$ | async)"
|
||||||
|
[title]="dsoType + '.edit.metadata.add-button' | translate"
|
||||||
|
(click)="add()"><i class="fas fa-plus"></i>
|
||||||
|
<span class="d-none d-sm-inline"> {{ dsoType + '.edit.metadata.add-button' | translate }}</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-warning ml-1" id="dso-reinstate-btn" *ngIf="isReinstatable" [disabled]="(saving$ | async)"
|
||||||
|
[title]="dsoType + '.edit.metadata.reinstate-button' | translate"
|
||||||
|
(click)="reinstate()"><i class="fas fa-undo-alt"></i>
|
||||||
|
<span class="d-none d-sm-inline"> {{ dsoType + '.edit.metadata.reinstate-button' | translate }}</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary ml-1" id="dso-save-btn" [disabled]="!hasChanges || (saving$ | async)"
|
||||||
|
[title]="dsoType + '.edit.metadata.save-button' | translate"
|
||||||
|
(click)="submit()"><i class="fas fa-save"></i>
|
||||||
|
<span class="d-none d-sm-inline"> {{ dsoType + '.edit.metadata.save-button' | translate }}</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-danger ml-1" id="dso-discard-btn" *ngIf="!isReinstatable"
|
||||||
|
[title]="dsoType + '.edit.metadata.discard-button' | translate"
|
||||||
|
[disabled]="!hasChanges || (saving$ | async)"
|
||||||
|
(click)="discard()"><i class="fas fa-times"></i>
|
||||||
|
<span class="d-none d-sm-inline"> {{ dsoType + '.edit.metadata.discard-button' | translate }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div role="table" [attr.aria-label]="'item.edit.head' | translate">
|
||||||
|
<ds-dso-edit-metadata-headers [dsoType]="dsoType"></ds-dso-edit-metadata-headers>
|
||||||
|
<div class="d-flex flex-row ds-field-row" role="row" *ngIf="form.newValue">
|
||||||
|
<div class="lbl-cell ds-success" role="rowheader">
|
||||||
|
<ds-metadata-field-selector [dsoType]="dsoType"
|
||||||
|
[(mdField)]="newMdField"
|
||||||
|
[autofocus]="true">
|
||||||
|
</ds-metadata-field-selector>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1 ds-drop-list" role="cell">
|
||||||
|
<div role="table">
|
||||||
|
<ds-dso-edit-metadata-value-headers role="presentation" [dsoType]="dsoType"></ds-dso-edit-metadata-value-headers>
|
||||||
|
<ds-dso-edit-metadata-value [dso]="dso"
|
||||||
|
[mdValue]="form.newValue"
|
||||||
|
[dsoType]="dsoType"
|
||||||
|
[saving$]="savingOrLoadingFieldValidation$"
|
||||||
|
[isOnlyValue]="true"
|
||||||
|
(confirm)="confirmNewValue($event)"
|
||||||
|
(remove)="form.newValue = undefined"
|
||||||
|
(undo)="form.newValue = undefined">
|
||||||
|
</ds-dso-edit-metadata-value>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-row ds-field-row" role="row" *ngFor="let mdField of form.fieldKeys">
|
||||||
|
<div class="lbl-cell" role="rowheader">
|
||||||
|
<span class="dont-break-out preserve-line-breaks">{{ mdField }}</span>
|
||||||
|
<div class="btn btn-warning reset-order-button mt-2 w-100" *ngIf="form.hasOrderChanges(mdField)"
|
||||||
|
(click)="form.resetOrder(mdField); onValueSaved()">
|
||||||
|
{{ dsoType + '.edit.metadata.reset-order-button' | translate }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ds-dso-edit-metadata-field-values class="flex-grow-1" role="cell"
|
||||||
|
[dso]="dso"
|
||||||
|
[form]="form"
|
||||||
|
[dsoType]="dsoType"
|
||||||
|
[saving$]="saving$"
|
||||||
|
[draggingMdField$]="draggingMdField$"
|
||||||
|
[mdField]="mdField"
|
||||||
|
(valueSaved)="onValueSaved()">
|
||||||
|
</ds-dso-edit-metadata-field-values>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="isEmpty && !form.newValue">
|
||||||
|
<ds-alert [content]="dsoType + '.edit.metadata.empty'" [type]="AlertTypeEnum.Info"></ds-alert>
|
||||||
|
</div>
|
||||||
|
<div class="button-row bottom d-inline-block w-100">
|
||||||
|
<div class="mt-2 float-right space-children-mr ml-gap">
|
||||||
|
<button class="btn btn-warning" *ngIf="isReinstatable" [disabled]="(saving$ | async)"
|
||||||
|
[title]="dsoType + '.edit.metadata.reinstate-button' | translate"
|
||||||
|
(click)="reinstate()"><i class="fas fa-undo-alt"></i> {{ dsoType + '.edit.metadata.reinstate-button' | translate }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" [disabled]="!hasChanges || (saving$ | async)"
|
||||||
|
[title]="dsoType + '.edit.metadata.save-button' | translate"
|
||||||
|
(click)="submit()"><i class="fas fa-save"></i> {{ dsoType + '.edit.metadata.save-button' | translate }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-danger" *ngIf="!isReinstatable"
|
||||||
|
[title]="dsoType + '.edit.metadata.discard-button' | translate"
|
||||||
|
[disabled]="!hasChanges || (saving$ | async)"
|
||||||
|
(click)="discard()"><i class="fas fa-times"></i> {{ dsoType + '.edit.metadata.discard-button' | translate }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ds-loading *ngIf="!form"></ds-loading>
|
@@ -0,0 +1,21 @@
|
|||||||
|
.lbl-cell {
|
||||||
|
min-width: var(--ds-dso-edit-field-width);
|
||||||
|
max-width: var(--ds-dso-edit-field-width);
|
||||||
|
background-color: var(--bs-gray-100);
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--bs-gray-200);
|
||||||
|
|
||||||
|
&.ds-success {
|
||||||
|
background-color: var(--bs-success-bg);
|
||||||
|
border: 1px solid var(--bs-success);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ds-field-row {
|
||||||
|
border: 1px solid var(--bs-gray-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-order-button:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
@@ -0,0 +1,193 @@
|
|||||||
|
import { DsoEditMetadataComponent } from './dso-edit-metadata.component';
|
||||||
|
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||||
|
import { VarDirective } from '../../shared/utils/var.directive';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { RouterTestingModule } from '@angular/router/testing';
|
||||||
|
import { DebugElement, Injectable, NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
|
import { DSpaceObject } from '../../core/shared/dspace-object.model';
|
||||||
|
import { Item } from '../../core/shared/item.model';
|
||||||
|
import { MetadataValue } from '../../core/shared/metadata.models';
|
||||||
|
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
|
||||||
|
import { By } from '@angular/platform-browser';
|
||||||
|
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||||
|
import { ArrayMoveChangeAnalyzer } from '../../core/data/array-move-change-analyzer.service';
|
||||||
|
import { ITEM } from '../../core/shared/item.resource-type';
|
||||||
|
import { DATA_SERVICE_FACTORY } from '../../core/data/base/data-service.decorator';
|
||||||
|
import { Operation } from 'fast-json-patch';
|
||||||
|
import { RemoteData } from '../../core/data/remote-data';
|
||||||
|
import { Observable } from 'rxjs/internal/Observable';
|
||||||
|
|
||||||
|
const ADD_BTN = 'add';
|
||||||
|
const REINSTATE_BTN = 'reinstate';
|
||||||
|
const SAVE_BTN = 'save';
|
||||||
|
const DISCARD_BTN = 'discard';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
class TestDataService {
|
||||||
|
patch(object: Item, operations: Operation[]): Observable<RemoteData<Item>> {
|
||||||
|
return createSuccessfulRemoteDataObject$(object);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DsoEditMetadataComponent', () => {
|
||||||
|
let component: DsoEditMetadataComponent;
|
||||||
|
let fixture: ComponentFixture<DsoEditMetadataComponent>;
|
||||||
|
|
||||||
|
let notificationsService: NotificationsService;
|
||||||
|
|
||||||
|
let dso: DSpaceObject;
|
||||||
|
|
||||||
|
beforeEach(waitForAsync(() => {
|
||||||
|
dso = Object.assign(new Item(), {
|
||||||
|
type: ITEM,
|
||||||
|
metadata: {
|
||||||
|
'dc.title': [
|
||||||
|
Object.assign(new MetadataValue(), {
|
||||||
|
value: 'Test Title',
|
||||||
|
language: 'en',
|
||||||
|
place: 0,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
'dc.subject': [
|
||||||
|
Object.assign(new MetadataValue(), {
|
||||||
|
value: 'Subject One',
|
||||||
|
language: 'en',
|
||||||
|
place: 0,
|
||||||
|
}),
|
||||||
|
Object.assign(new MetadataValue(), {
|
||||||
|
value: 'Subject Two',
|
||||||
|
language: 'en',
|
||||||
|
place: 1,
|
||||||
|
}),
|
||||||
|
Object.assign(new MetadataValue(), {
|
||||||
|
value: 'Subject Three',
|
||||||
|
language: 'en',
|
||||||
|
place: 2,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
notificationsService = jasmine.createSpyObj('notificationsService', ['error', 'success']);
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
declarations: [DsoEditMetadataComponent, VarDirective],
|
||||||
|
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])],
|
||||||
|
providers: [
|
||||||
|
TestDataService,
|
||||||
|
{ provide: DATA_SERVICE_FACTORY, useValue: jasmine.createSpy('getDataServiceFor').and.returnValue(TestDataService) },
|
||||||
|
{ provide: NotificationsService, useValue: notificationsService },
|
||||||
|
ArrayMoveChangeAnalyzer,
|
||||||
|
],
|
||||||
|
schemas: [NO_ERRORS_SCHEMA]
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(DsoEditMetadataComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
component.dso = dso;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when no changes have been made', () => {
|
||||||
|
assertButton(ADD_BTN, true, false);
|
||||||
|
assertButton(REINSTATE_BTN, false);
|
||||||
|
assertButton(SAVE_BTN, true, true);
|
||||||
|
assertButton(DISCARD_BTN, true, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when the form contains changes', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
component.form.fields['dc.title'][0].newValue.value = 'Updated Title Once';
|
||||||
|
component.form.fields['dc.title'][0].confirmChanges();
|
||||||
|
component.form.resetReinstatable();
|
||||||
|
component.onValueSaved();
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
assertButton(SAVE_BTN, true, false);
|
||||||
|
assertButton(DISCARD_BTN, true, false);
|
||||||
|
|
||||||
|
describe('and they were discarded', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
component.discard();
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
assertButton(REINSTATE_BTN, true, false);
|
||||||
|
assertButton(SAVE_BTN, true, true);
|
||||||
|
assertButton(DISCARD_BTN, false);
|
||||||
|
|
||||||
|
describe('and a new change is made', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
component.form.fields['dc.title'][0].newValue.value = 'Updated Title Twice';
|
||||||
|
component.form.fields['dc.title'][0].confirmChanges();
|
||||||
|
component.form.resetReinstatable();
|
||||||
|
component.onValueSaved();
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
assertButton(REINSTATE_BTN, false);
|
||||||
|
assertButton(SAVE_BTN, true, false);
|
||||||
|
assertButton(DISCARD_BTN, true, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when a new value is present', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
component.add();
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
assertButton(ADD_BTN, true, true);
|
||||||
|
|
||||||
|
it('should display a row with a field selector and metadata value', () => {
|
||||||
|
expect(fixture.debugElement.query(By.css('ds-metadata-field-selector'))).toBeTruthy();
|
||||||
|
expect(fixture.debugElement.query(By.css('ds-dso-edit-metadata-value'))).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('and gets assigned to a metadata field', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
component.form.newValue.newValue.value = 'New Subject';
|
||||||
|
component.form.setMetadataField('dc.subject');
|
||||||
|
component.form.resetReinstatable();
|
||||||
|
component.onValueSaved();
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
assertButton(ADD_BTN, true, false);
|
||||||
|
|
||||||
|
it('should not display the separate row with field selector and metadata value anymore', () => {
|
||||||
|
expect(fixture.debugElement.query(By.css('ds-metadata-field-selector'))).toBeNull();
|
||||||
|
expect(fixture.debugElement.query(By.css('ds-dso-edit-metadata-value'))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function assertButton(name: string, exists: boolean, disabled: boolean = false): void {
|
||||||
|
describe(`${name} button`, () => {
|
||||||
|
let btn: DebugElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
btn = fixture.debugElement.query(By.css(`#dso-${name}-btn`));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
it('should exist', () => {
|
||||||
|
expect(btn).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`should${disabled ? ' ' : ' not '}be disabled`, () => {
|
||||||
|
expect(btn.nativeElement.disabled).toBe(disabled);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
it('should not exist', () => {
|
||||||
|
expect(btn).toBeNull();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
@@ -0,0 +1,261 @@
|
|||||||
|
import { Component, Inject, Injector, Input, OnDestroy, OnInit, ViewChild } from '@angular/core';
|
||||||
|
import { AlertType } from '../../shared/alert/aletr-type';
|
||||||
|
import { DSpaceObject } from '../../core/shared/dspace-object.model';
|
||||||
|
import { DsoEditMetadataForm } from './dso-edit-metadata-form';
|
||||||
|
import { map } from 'rxjs/operators';
|
||||||
|
import { ActivatedRoute, Data } from '@angular/router';
|
||||||
|
import { combineLatest as observableCombineLatest } from 'rxjs/internal/observable/combineLatest';
|
||||||
|
import { Subscription } from 'rxjs/internal/Subscription';
|
||||||
|
import { RemoteData } from '../../core/data/remote-data';
|
||||||
|
import { hasNoValue, hasValue } from '../../shared/empty.util';
|
||||||
|
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
|
||||||
|
import {
|
||||||
|
getFirstCompletedRemoteData,
|
||||||
|
} from '../../core/shared/operators';
|
||||||
|
import { UpdateDataService } from '../../core/data/update-data.service';
|
||||||
|
import { ResourceType } from '../../core/shared/resource-type';
|
||||||
|
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
import { MetadataFieldSelectorComponent } from './metadata-field-selector/metadata-field-selector.component';
|
||||||
|
import { Observable } from 'rxjs/internal/Observable';
|
||||||
|
import { ArrayMoveChangeAnalyzer } from '../../core/data/array-move-change-analyzer.service';
|
||||||
|
import { DATA_SERVICE_FACTORY } from '../../core/data/base/data-service.decorator';
|
||||||
|
import { GenericConstructor } from '../../core/shared/generic-constructor';
|
||||||
|
import { HALDataService } from '../../core/data/base/hal-data-service.interface';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-dso-edit-metadata',
|
||||||
|
styleUrls: ['./dso-edit-metadata.component.scss'],
|
||||||
|
templateUrl: './dso-edit-metadata.component.html',
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* Component showing a table of all metadata on a DSpaceObject and options to modify them
|
||||||
|
*/
|
||||||
|
export class DsoEditMetadataComponent implements OnInit, OnDestroy {
|
||||||
|
/**
|
||||||
|
* DSpaceObject to edit metadata for
|
||||||
|
*/
|
||||||
|
@Input() dso: DSpaceObject;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference to the component responsible for showing a metadata-field selector
|
||||||
|
* Used to validate its contents (existing metadata field) before adding a new metadata value
|
||||||
|
*/
|
||||||
|
@ViewChild(MetadataFieldSelectorComponent) metadataFieldSelectorComponent: MetadataFieldSelectorComponent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolved update data-service for the given DSpaceObject (depending on its type, e.g. ItemDataService for an Item)
|
||||||
|
* Used to send the PATCH request
|
||||||
|
*/
|
||||||
|
@Input() updateDataService: UpdateDataService<DSpaceObject>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type of the DSpaceObject in String
|
||||||
|
* Used to resolve i18n messages
|
||||||
|
*/
|
||||||
|
dsoType: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dynamic form object containing all information about the metadata and the changes made to them, see {@link DsoEditMetadataForm}
|
||||||
|
*/
|
||||||
|
form: DsoEditMetadataForm;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The metadata field entered by the user for a new metadata value
|
||||||
|
*/
|
||||||
|
newMdField: string;
|
||||||
|
|
||||||
|
// Properties determined by the state of the dynamic form, updated by onValueSaved()
|
||||||
|
isReinstatable: boolean;
|
||||||
|
hasChanges: boolean;
|
||||||
|
isEmpty: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether or not the form is currently being submitted
|
||||||
|
*/
|
||||||
|
saving$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks for which metadata-field a drag operation is taking place
|
||||||
|
* Null when no drag is currently happening for any field
|
||||||
|
* This is a BehaviorSubject that is passed down to child components, to give them the power to alter the state
|
||||||
|
*/
|
||||||
|
draggingMdField$: BehaviorSubject<string> = new BehaviorSubject<string>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether or not the metadata field is currently being validated
|
||||||
|
*/
|
||||||
|
loadingFieldValidation$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combination of saving$ and loadingFieldValidation$
|
||||||
|
* Emits true when any of the two emit true
|
||||||
|
*/
|
||||||
|
savingOrLoadingFieldValidation$: Observable<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The AlertType enumeration for access in the component's template
|
||||||
|
* @type {AlertType}
|
||||||
|
*/
|
||||||
|
public AlertTypeEnum = AlertType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscription for updating the current DSpaceObject
|
||||||
|
* Unsubscribed from in ngOnDestroy()
|
||||||
|
*/
|
||||||
|
dsoUpdateSubscription: Subscription;
|
||||||
|
|
||||||
|
constructor(protected route: ActivatedRoute,
|
||||||
|
protected notificationsService: NotificationsService,
|
||||||
|
protected translateService: TranslateService,
|
||||||
|
protected parentInjector: Injector,
|
||||||
|
protected arrayMoveChangeAnalyser: ArrayMoveChangeAnalyzer<number>,
|
||||||
|
@Inject(DATA_SERVICE_FACTORY) protected getDataServiceFor: (resourceType: ResourceType) => GenericConstructor<HALDataService<any>>) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the route (or parent route)'s data to retrieve the current DSpaceObject
|
||||||
|
* After it's retrieved, initialise the data-service and form
|
||||||
|
*/
|
||||||
|
ngOnInit(): void {
|
||||||
|
if (hasNoValue(this.dso)) {
|
||||||
|
this.dsoUpdateSubscription = observableCombineLatest([this.route.data, this.route.parent.data]).pipe(
|
||||||
|
map(([data, parentData]: [Data, Data]) => Object.assign({}, data, parentData)),
|
||||||
|
map((data: any) => data.dso)
|
||||||
|
).subscribe((rd: RemoteData<DSpaceObject>) => {
|
||||||
|
this.dso = rd.payload;
|
||||||
|
this.initDataService();
|
||||||
|
this.initForm();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.initDataService();
|
||||||
|
this.initForm();
|
||||||
|
}
|
||||||
|
this.savingOrLoadingFieldValidation$ = observableCombineLatest([this.saving$, this.loadingFieldValidation$]).pipe(
|
||||||
|
map(([saving, loading]: [boolean, boolean]) => saving || loading),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialise (resolve) the data-service for the current DSpaceObject
|
||||||
|
*/
|
||||||
|
initDataService(): void {
|
||||||
|
let type: ResourceType;
|
||||||
|
if (typeof this.dso.type === 'string') {
|
||||||
|
type = new ResourceType(this.dso.type);
|
||||||
|
} else {
|
||||||
|
type = this.dso.type;
|
||||||
|
}
|
||||||
|
if (hasNoValue(this.updateDataService)) {
|
||||||
|
const provider = this.getDataServiceFor(type);
|
||||||
|
this.updateDataService = Injector.create({
|
||||||
|
providers: [],
|
||||||
|
parent: this.parentInjector
|
||||||
|
}).get(provider);
|
||||||
|
}
|
||||||
|
this.dsoType = type.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialise the dynamic form object by passing the DSpaceObject's metadata
|
||||||
|
* Call onValueSaved() to update the form's state properties
|
||||||
|
*/
|
||||||
|
initForm(): void {
|
||||||
|
this.form = new DsoEditMetadataForm(this.dso.metadata);
|
||||||
|
this.onValueSaved();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the form's state properties
|
||||||
|
*/
|
||||||
|
onValueSaved(): void {
|
||||||
|
this.hasChanges = this.form.hasChanges();
|
||||||
|
this.isReinstatable = this.form.isReinstatable();
|
||||||
|
this.isEmpty = Object.keys(this.form.fields).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit the current changes to the form by retrieving json PATCH operations from the form and sending it to the
|
||||||
|
* DSpaceObject's data-service
|
||||||
|
* Display notificiations and reset the form afterwards if successful
|
||||||
|
*/
|
||||||
|
submit(): void {
|
||||||
|
this.saving$.next(true);
|
||||||
|
this.updateDataService.patch(this.dso, this.form.getOperations(this.arrayMoveChangeAnalyser)).pipe(
|
||||||
|
getFirstCompletedRemoteData()
|
||||||
|
).subscribe((rd: RemoteData<DSpaceObject>) => {
|
||||||
|
this.saving$.next(false);
|
||||||
|
if (rd.hasFailed) {
|
||||||
|
this.notificationsService.error(this.translateService.instant(`${this.dsoType}.edit.metadata.notifications.error.title`), rd.errorMessage);
|
||||||
|
} else {
|
||||||
|
this.notificationsService.success(
|
||||||
|
this.translateService.instant(`${this.dsoType}.edit.metadata.notifications.saved.title`),
|
||||||
|
this.translateService.instant(`${this.dsoType}.edit.metadata.notifications.saved.content`)
|
||||||
|
);
|
||||||
|
this.dso = rd.payload;
|
||||||
|
this.initForm();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm the newly added value
|
||||||
|
* @param saved Whether or not the value was manually saved (only then, add the value to its metadata field)
|
||||||
|
*/
|
||||||
|
confirmNewValue(saved: boolean): void {
|
||||||
|
if (saved) {
|
||||||
|
this.setMetadataField();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the metadata field of the temporary added new metadata value
|
||||||
|
* This will move the new value to its respective parent metadata field
|
||||||
|
* Validate the metadata field first
|
||||||
|
*/
|
||||||
|
setMetadataField(): void {
|
||||||
|
this.form.resetReinstatable();
|
||||||
|
this.loadingFieldValidation$.next(true);
|
||||||
|
this.metadataFieldSelectorComponent.validate().subscribe((valid: boolean) => {
|
||||||
|
this.loadingFieldValidation$.next(false);
|
||||||
|
if (valid) {
|
||||||
|
this.form.setMetadataField(this.newMdField);
|
||||||
|
this.onValueSaved();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new temporary metadata value
|
||||||
|
*/
|
||||||
|
add(): void {
|
||||||
|
this.newMdField = undefined;
|
||||||
|
this.form.add();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discard all changes within the current form
|
||||||
|
*/
|
||||||
|
discard(): void {
|
||||||
|
this.form.discard();
|
||||||
|
this.onValueSaved();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore any changes previously discarded from the form
|
||||||
|
*/
|
||||||
|
reinstate(): void {
|
||||||
|
this.form.reinstate();
|
||||||
|
this.onValueSaved();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsubscribe from any open subscriptions
|
||||||
|
*/
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
if (hasValue(this.dsoUpdateSubscription)) {
|
||||||
|
this.dsoUpdateSubscription.unsubscribe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -0,0 +1,19 @@
|
|||||||
|
<div class="w-100 position-relative">
|
||||||
|
<input type="text" #mdFieldInput
|
||||||
|
class="form-control" [ngClass]="{ 'is-invalid': showInvalid }"
|
||||||
|
[value]="mdField"
|
||||||
|
[formControl]="input"
|
||||||
|
(focusin)="query$.next(mdField)"
|
||||||
|
(dsClickOutside)="query$.next(null)"
|
||||||
|
(click)="$event.stopPropagation();" />
|
||||||
|
<div class="invalid-feedback show-feedback" *ngIf="showInvalid">{{ dsoType + '.edit.metadata.metadatafield.invalid' | translate }}</div>
|
||||||
|
<div class="autocomplete dropdown-menu" [ngClass]="{'show': (mdFieldOptions$ | async)?.length > 0}">
|
||||||
|
<div class="dropdown-list">
|
||||||
|
<div *ngFor="let mdFieldOption of (mdFieldOptions$ | async)">
|
||||||
|
<a href="javascript:void(0);" class="d-block dropdown-item" (click)="select(mdFieldOption)">
|
||||||
|
<span [innerHTML]="mdFieldOption"></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@@ -0,0 +1,122 @@
|
|||||||
|
import { MetadataFieldSelectorComponent } from './metadata-field-selector.component';
|
||||||
|
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||||
|
import { VarDirective } from '../../../shared/utils/var.directive';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { RouterTestingModule } from '@angular/router/testing';
|
||||||
|
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
|
import { RegistryService } from '../../../core/registry/registry.service';
|
||||||
|
import { MetadataField } from '../../../core/metadata/metadata-field.model';
|
||||||
|
import { MetadataSchema } from '../../../core/metadata/metadata-schema.model';
|
||||||
|
import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
|
||||||
|
import { createPaginatedList } from '../../../shared/testing/utils.test';
|
||||||
|
import { followLink } from '../../../shared/utils/follow-link-config.model';
|
||||||
|
import { By } from '@angular/platform-browser';
|
||||||
|
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||||
|
|
||||||
|
describe('MetadataFieldSelectorComponent', () => {
|
||||||
|
let component: MetadataFieldSelectorComponent;
|
||||||
|
let fixture: ComponentFixture<MetadataFieldSelectorComponent>;
|
||||||
|
|
||||||
|
let registryService: RegistryService;
|
||||||
|
let notificationsService: NotificationsService;
|
||||||
|
|
||||||
|
let metadataSchema: MetadataSchema;
|
||||||
|
let metadataFields: MetadataField[];
|
||||||
|
|
||||||
|
beforeEach(waitForAsync(() => {
|
||||||
|
metadataSchema = Object.assign(new MetadataSchema(), {
|
||||||
|
id: 0,
|
||||||
|
prefix: 'dc',
|
||||||
|
namespace: 'http://dublincore.org/documents/dcmi-terms/',
|
||||||
|
});
|
||||||
|
metadataFields = [
|
||||||
|
Object.assign(new MetadataField(), {
|
||||||
|
id: 0,
|
||||||
|
element: 'description',
|
||||||
|
qualifier: undefined,
|
||||||
|
schema: createSuccessfulRemoteDataObject$(metadataSchema),
|
||||||
|
}),
|
||||||
|
Object.assign(new MetadataField(), {
|
||||||
|
id: 1,
|
||||||
|
element: 'description',
|
||||||
|
qualifier: 'abstract',
|
||||||
|
schema: createSuccessfulRemoteDataObject$(metadataSchema),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
registryService = jasmine.createSpyObj('registryService', {
|
||||||
|
queryMetadataFields: createSuccessfulRemoteDataObject$(createPaginatedList(metadataFields)),
|
||||||
|
});
|
||||||
|
notificationsService = jasmine.createSpyObj('notificationsService', ['error', 'success']);
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
declarations: [MetadataFieldSelectorComponent, VarDirective],
|
||||||
|
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])],
|
||||||
|
providers: [
|
||||||
|
{ provide: RegistryService, useValue: registryService },
|
||||||
|
{ provide: NotificationsService, useValue: notificationsService },
|
||||||
|
],
|
||||||
|
schemas: [NO_ERRORS_SCHEMA]
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(MetadataFieldSelectorComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when a query is entered', () => {
|
||||||
|
const query = 'test query';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
component.showInvalid = true;
|
||||||
|
component.query$.next(query);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset showInvalid', () => {
|
||||||
|
expect(component.showInvalid).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should query the registry service for metadata fields and include the schema', () => {
|
||||||
|
expect(registryService.queryMetadataFields).toHaveBeenCalledWith(query, null, true, false, followLink('schema'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validate', () => {
|
||||||
|
it('should return an observable true and show no feedback if the current mdField exists in registry', (done) => {
|
||||||
|
component.mdField = 'dc.description.abstract';
|
||||||
|
component.validate().subscribe((result) => {
|
||||||
|
expect(result).toBeTrue();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.debugElement.query(By.css('.invalid-feedback'))).toBeNull();
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return an observable false and show invalid feedback if the current mdField is missing in registry', (done) => {
|
||||||
|
component.mdField = 'dc.fake.field';
|
||||||
|
component.validate().subscribe((result) => {
|
||||||
|
expect(result).toBeFalse();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.debugElement.query(By.css('.invalid-feedback'))).toBeTruthy();
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when querying the metadata fields returns an error response', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
(registryService.queryMetadataFields as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$('Failed'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return an observable false and show a notification', (done) => {
|
||||||
|
component.mdField = 'dc.description.abstract';
|
||||||
|
component.validate().subscribe((result) => {
|
||||||
|
expect(result).toBeFalse();
|
||||||
|
expect(notificationsService.error).toHaveBeenCalled();
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@@ -0,0 +1,188 @@
|
|||||||
|
import {
|
||||||
|
AfterViewInit,
|
||||||
|
Component,
|
||||||
|
ElementRef,
|
||||||
|
EventEmitter,
|
||||||
|
Input,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit,
|
||||||
|
Output,
|
||||||
|
ViewChild
|
||||||
|
} from '@angular/core';
|
||||||
|
import { switchMap, debounceTime, distinctUntilChanged, map, tap, take } from 'rxjs/operators';
|
||||||
|
import { followLink } from '../../../shared/utils/follow-link-config.model';
|
||||||
|
import {
|
||||||
|
getAllSucceededRemoteData, getFirstCompletedRemoteData,
|
||||||
|
metadataFieldsToString
|
||||||
|
} from '../../../core/shared/operators';
|
||||||
|
import { Observable } from 'rxjs/internal/Observable';
|
||||||
|
import { RegistryService } from '../../../core/registry/registry.service';
|
||||||
|
import { FormControl } from '@angular/forms';
|
||||||
|
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
|
||||||
|
import { hasValue } from '../../../shared/empty.util';
|
||||||
|
import { Subscription } from 'rxjs/internal/Subscription';
|
||||||
|
import { of } from 'rxjs/internal/observable/of';
|
||||||
|
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-metadata-field-selector',
|
||||||
|
styleUrls: ['./metadata-field-selector.component.scss'],
|
||||||
|
templateUrl: './metadata-field-selector.component.html'
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* Component displaying a searchable input for metadata-fields
|
||||||
|
*/
|
||||||
|
export class MetadataFieldSelectorComponent implements OnInit, OnDestroy, AfterViewInit {
|
||||||
|
/**
|
||||||
|
* Type of the DSpaceObject
|
||||||
|
* Used to resolve i18n messages
|
||||||
|
*/
|
||||||
|
@Input() dsoType: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The currently entered metadata field
|
||||||
|
*/
|
||||||
|
@Input() mdField: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If true, the input will be automatically focussed upon when the component is first loaded
|
||||||
|
*/
|
||||||
|
@Input() autofocus = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit any changes made to the metadata field
|
||||||
|
* This will only emit after a debounce takes place to avoid constant emits when the user is typing
|
||||||
|
*/
|
||||||
|
@Output() mdFieldChange = new EventEmitter<string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference to the metadata-field's input
|
||||||
|
*/
|
||||||
|
@ViewChild('mdFieldInput', { static: true }) mdFieldInput: ElementRef;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of available metadata field options to choose from, dependent on the current query the user entered
|
||||||
|
* Shows up in a dropdown below the input
|
||||||
|
*/
|
||||||
|
mdFieldOptions$: Observable<string[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FormControl for the input
|
||||||
|
*/
|
||||||
|
public input: FormControl = new FormControl();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current query to update mdFieldOptions$ for
|
||||||
|
* This is controlled by a debounce, to avoid too many requests
|
||||||
|
*/
|
||||||
|
query$: BehaviorSubject<string> = new BehaviorSubject<string>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The amount of time to debounce the query for (in ms)
|
||||||
|
*/
|
||||||
|
debounceTime = 300;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether or not the the user just selected a value
|
||||||
|
* This flag avoids the metadata field from updating twice, which would result in the dropdown opening again right after selecting a value
|
||||||
|
*/
|
||||||
|
selectedValueLoading = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether or not to show the invalid feedback
|
||||||
|
* True when validate() is called and the mdField isn't present in the available metadata fields retrieved from the server
|
||||||
|
*/
|
||||||
|
showInvalid = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscriptions to unsubscribe from on destroy
|
||||||
|
*/
|
||||||
|
subs: Subscription[] = [];
|
||||||
|
|
||||||
|
constructor(protected registryService: RegistryService,
|
||||||
|
protected notificationsService: NotificationsService,
|
||||||
|
protected translate: TranslateService) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to any changes made to the input, with a debounce and fire a query, as well as emit the change from this component
|
||||||
|
* Update the mdFieldOptions$ depending on the query$ fired by querying the server
|
||||||
|
*/
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.subs.push(
|
||||||
|
this.input.valueChanges.pipe(
|
||||||
|
debounceTime(this.debounceTime),
|
||||||
|
).subscribe((valueChange) => {
|
||||||
|
if (!this.selectedValueLoading) {
|
||||||
|
this.query$.next(valueChange);
|
||||||
|
}
|
||||||
|
this.selectedValueLoading = false;
|
||||||
|
this.mdField = valueChange;
|
||||||
|
this.mdFieldChange.emit(this.mdField);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.mdFieldOptions$ = this.query$.pipe(
|
||||||
|
distinctUntilChanged(),
|
||||||
|
switchMap((query: string) => {
|
||||||
|
this.showInvalid = false;
|
||||||
|
if (query !== null) {
|
||||||
|
return this.registryService.queryMetadataFields(query, null, true, false, followLink('schema')).pipe(
|
||||||
|
getAllSucceededRemoteData(),
|
||||||
|
metadataFieldsToString(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return [[]];
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Focus the input if autofocus is enabled
|
||||||
|
*/
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
if (this.autofocus) {
|
||||||
|
this.mdFieldInput.nativeElement.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the metadata field to check if it exists on the server and return an observable boolean for success/error
|
||||||
|
* Upon subscribing to the returned observable, the showInvalid flag is updated accordingly to show the feedback under the input
|
||||||
|
*/
|
||||||
|
validate(): Observable<boolean> {
|
||||||
|
return this.registryService.queryMetadataFields(this.mdField, null, true, false, followLink('schema')).pipe(
|
||||||
|
getFirstCompletedRemoteData(),
|
||||||
|
switchMap((rd) => {
|
||||||
|
if (rd.hasSucceeded) {
|
||||||
|
return of(rd).pipe(
|
||||||
|
metadataFieldsToString(),
|
||||||
|
take(1),
|
||||||
|
map((fields: string[]) => fields.indexOf(this.mdField) > -1),
|
||||||
|
tap((exists: boolean) => this.showInvalid = !exists),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.notificationsService.error(this.translate.instant(`${this.dsoType}.edit.metadata.metadatafield.error`), rd.errorMessage);
|
||||||
|
return [false];
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select a metadata field from the dropdown options
|
||||||
|
* @param mdFieldOption
|
||||||
|
*/
|
||||||
|
select(mdFieldOption: string): void {
|
||||||
|
this.selectedValueLoading = true;
|
||||||
|
this.input.setValue(mdFieldOption);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsubscribe from any open subscriptions
|
||||||
|
*/
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.subs.filter((sub: Subscription) => hasValue(sub)).forEach((sub: Subscription) => sub.unsubscribe());
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,33 @@
|
|||||||
|
import { ThemedComponent } from '../../shared/theme-support/themed.component';
|
||||||
|
import { DsoEditMetadataComponent } from './dso-edit-metadata.component';
|
||||||
|
import { Component, Input } from '@angular/core';
|
||||||
|
import { DSpaceObject } from '../../core/shared/dspace-object.model';
|
||||||
|
import { UpdateDataService } from '../../core/data/update-data.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-themed-dso-edit-metadata',
|
||||||
|
styleUrls: [],
|
||||||
|
templateUrl: './../../shared/theme-support/themed.component.html',
|
||||||
|
})
|
||||||
|
export class ThemedDsoEditMetadataComponent extends ThemedComponent<DsoEditMetadataComponent> {
|
||||||
|
|
||||||
|
@Input() dso: DSpaceObject;
|
||||||
|
|
||||||
|
@Input() updateDataService: UpdateDataService<DSpaceObject>;
|
||||||
|
|
||||||
|
protected inAndOutputNames: (keyof DsoEditMetadataComponent & keyof this)[] = ['dso', 'updateDataService'];
|
||||||
|
|
||||||
|
protected getComponentName(): string {
|
||||||
|
return 'DsoEditMetadataComponent';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected importThemedComponent(themeName: string): Promise<any> {
|
||||||
|
return import(`../../../themes/${themeName}/app/dso-shared/dso-edit-metadata/dso-edit-metadata.component`);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected importUnthemedComponent(): Promise<any> {
|
||||||
|
return import(`./dso-edit-metadata.component`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
36
src/app/dso-shared/dso-shared.module.ts
Normal file
36
src/app/dso-shared/dso-shared.module.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { SharedModule } from '../shared/shared.module';
|
||||||
|
import { DsoEditMetadataComponent } from './dso-edit-metadata/dso-edit-metadata.component';
|
||||||
|
import { MetadataFieldSelectorComponent } from './dso-edit-metadata/metadata-field-selector/metadata-field-selector.component';
|
||||||
|
import { DsoEditMetadataFieldValuesComponent } from './dso-edit-metadata/dso-edit-metadata-field-values/dso-edit-metadata-field-values.component';
|
||||||
|
import { DsoEditMetadataValueComponent } from './dso-edit-metadata/dso-edit-metadata-value/dso-edit-metadata-value.component';
|
||||||
|
import { DsoEditMetadataHeadersComponent } from './dso-edit-metadata/dso-edit-metadata-headers/dso-edit-metadata-headers.component';
|
||||||
|
import { DsoEditMetadataValueHeadersComponent } from './dso-edit-metadata/dso-edit-metadata-value-headers/dso-edit-metadata-value-headers.component';
|
||||||
|
import { ThemedDsoEditMetadataComponent } from './dso-edit-metadata/themed-dso-edit-metadata.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [
|
||||||
|
SharedModule,
|
||||||
|
],
|
||||||
|
declarations: [
|
||||||
|
DsoEditMetadataComponent,
|
||||||
|
ThemedDsoEditMetadataComponent,
|
||||||
|
MetadataFieldSelectorComponent,
|
||||||
|
DsoEditMetadataFieldValuesComponent,
|
||||||
|
DsoEditMetadataValueComponent,
|
||||||
|
DsoEditMetadataHeadersComponent,
|
||||||
|
DsoEditMetadataValueHeadersComponent,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
DsoEditMetadataComponent,
|
||||||
|
ThemedDsoEditMetadataComponent,
|
||||||
|
MetadataFieldSelectorComponent,
|
||||||
|
DsoEditMetadataFieldValuesComponent,
|
||||||
|
DsoEditMetadataValueComponent,
|
||||||
|
DsoEditMetadataHeadersComponent,
|
||||||
|
DsoEditMetadataValueHeadersComponent,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class DsoSharedModule {
|
||||||
|
|
||||||
|
}
|
@@ -1,12 +1,8 @@
|
|||||||
|
<ds-themed-results-back-button *ngIf="showBackButton | async" [back]="back"></ds-themed-results-back-button>
|
||||||
<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">
|
||||||
|
@@ -1 +1,2 @@
|
|||||||
@import '../../../../../styles/variables.scss';
|
@import '../../../../../styles/variables.scss';
|
||||||
|
|
||||||
|
@@ -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 {
|
||||||
}
|
}
|
||||||
|
@@ -1,12 +1,8 @@
|
|||||||
|
<ds-themed-results-back-button *ngIf="showBackButton | async" [back]="back"></ds-themed-results-back-button>
|
||||||
<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">
|
||||||
|
@@ -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 {
|
||||||
}
|
}
|
||||||
|
@@ -1,12 +1,8 @@
|
|||||||
|
<ds-themed-results-back-button *ngIf="showBackButton | async" [back]="back"></ds-themed-results-back-button>
|
||||||
<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">
|
||||||
|
@@ -34,6 +34,11 @@ import { VersionHistoryDataService } from '../../../../core/data/version-history
|
|||||||
import { VersionDataService } from '../../../../core/data/version-data.service';
|
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 {
|
||||||
|
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>;
|
||||||
@@ -99,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: {} }
|
{ provide: RouteService, useValue: mockRouteService },
|
||||||
|
{ provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub }
|
||||||
],
|
],
|
||||||
|
|
||||||
schemas: [NO_ERRORS_SCHEMA]
|
schemas: [NO_ERRORS_SCHEMA]
|
||||||
|
@@ -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 {
|
||||||
}
|
}
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user