mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 01:54:15 +00:00
Merge remote-tracking branch 'upstream/main' into w2p-94390_replace-dso-page-edit-buttons-with-a-menu
This commit is contained in:
@@ -32,12 +32,60 @@ cache:
|
||||
# NOTE: how long should objects be cached for by default
|
||||
msToLive:
|
||||
default: 900000 # 15 minutes
|
||||
control: max-age=60 # revalidate browser
|
||||
# Default 'Cache-Control' HTTP Header to set for all static content (including compiled *.js files)
|
||||
# Defaults to max-age=604,800 seconds (one week). This lets a user's browser know that it can cache these
|
||||
# files for one week, after which they will be "stale" and need to be redownloaded.
|
||||
# NOTE: When updates are made to compiled *.js files, it will automatically bypass this browser cache, because
|
||||
# all compiled *.js files include a unique hash in their name which updates when content is modified.
|
||||
control: max-age=604800 # revalidate browser
|
||||
autoSync:
|
||||
defaultTime: 0
|
||||
maxBufferSize: 100
|
||||
timePerMethod:
|
||||
PATCH: 3 # time in seconds
|
||||
# In-memory cache(s) of server-side rendered pages. These caches will store the most recently accessed public pages.
|
||||
# Pages are automatically added/dropped from these caches based on how recently they have been used.
|
||||
# Restarting the app clears all page caches.
|
||||
# NOTE: To control the cache size, use the "max" setting. Keep in mind, individual cached pages are usually small (<100KB).
|
||||
# Enabling *both* caches will mean that a page may be cached twice, once in each cache (but may expire at different times via timeToLive).
|
||||
serverSide:
|
||||
# Set to true to see all cache hits/misses/refreshes in your console logs. Useful for debugging SSR caching issues.
|
||||
debug: false
|
||||
# When enabled (i.e. max > 0), known bots will be sent pages from a server side cache specific for bots.
|
||||
# (Keep in mind, bot detection cannot be guarranteed. It is possible some bots will bypass this cache.)
|
||||
botCache:
|
||||
# Maximum number of pages to cache for known bots. Set to zero (0) to disable server side caching for bots.
|
||||
# Default is 1000, which means the 1000 most recently accessed public pages will be cached.
|
||||
# As all pages are cached in server memory, increasing this value will increase memory needs.
|
||||
# Individual cached pages are usually small (<100KB), so max=1000 should only require ~100MB of memory.
|
||||
max: 1000
|
||||
# Amount of time after which cached pages are considered stale (in ms). After becoming stale, the cached
|
||||
# copy is automatically refreshed on the next request.
|
||||
# NOTE: For the bot cache, this setting may impact how quickly search engine bots will index new content on your site.
|
||||
# For example, setting this to one week may mean that search engine bots may not find all new content for one week.
|
||||
timeToLive: 86400000 # 1 day
|
||||
# When set to true, after timeToLive expires, the next request will receive the *cached* page & then re-render the page
|
||||
# behind the scenes to update the cache. This ensures users primarily interact with the cache, but may receive stale pages (older than timeToLive).
|
||||
# When set to false, after timeToLive expires, the next request will wait on SSR to complete & receive a fresh page (which is then saved to cache).
|
||||
# This ensures stale pages (older than timeToLive) are never returned from the cache, but some users will wait on SSR.
|
||||
allowStale: true
|
||||
# When enabled (i.e. max > 0), all anonymous users will be sent pages from a server side cache.
|
||||
# This allows anonymous users to interact more quickly with the site, but also means they may see slightly
|
||||
# outdated content (based on timeToLive)
|
||||
anonymousCache:
|
||||
# Maximum number of pages to cache. Default is zero (0) which means anonymous user cache is disabled.
|
||||
# As all pages are cached in server memory, increasing this value will increase memory needs.
|
||||
# Individual cached pages are usually small (<100KB), so a value of max=1000 would only require ~100MB of memory.
|
||||
max: 0
|
||||
# Amount of time after which cached pages are considered stale (in ms). After becoming stale, the cached
|
||||
# copy is automatically refreshed on the next request.
|
||||
# NOTE: For the anonymous cache, it is recommended to keep this value low to avoid anonymous users seeing outdated content.
|
||||
timeToLive: 10000 # 10 seconds
|
||||
# When set to true, after timeToLive expires, the next request will receive the *cached* page & then re-render the page
|
||||
# behind the scenes to update the cache. This ensures users primarily interact with the cache, but may receive stale pages (older than timeToLive).
|
||||
# When set to false, after timeToLive expires, the next request will wait on SSR to complete & receive a fresh page (which is then saved to cache).
|
||||
# This ensures stale pages (older than timeToLive) are never returned from the cache, but some users will wait on SSR.
|
||||
allowStale: true
|
||||
|
||||
# Authentication settings
|
||||
auth:
|
||||
|
@@ -99,6 +99,7 @@
|
||||
"fast-json-patch": "^3.0.0-1",
|
||||
"filesize": "^6.1.0",
|
||||
"http-proxy-middleware": "^1.0.5",
|
||||
"isbot": "^3.6.5",
|
||||
"js-cookie": "2.2.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
"json5": "^2.2.2",
|
||||
@@ -106,6 +107,7 @@
|
||||
"jwt-decode": "^3.1.2",
|
||||
"klaro": "^0.7.18",
|
||||
"lodash": "^4.17.21",
|
||||
"lru-cache": "^7.14.1",
|
||||
"markdown-it": "^13.0.1",
|
||||
"markdown-it-mathjax3": "^4.3.1",
|
||||
"mirador": "^3.3.0",
|
||||
|
310
server.ts
310
server.ts
@@ -28,6 +28,8 @@ import * as expressStaticGzip from 'express-static-gzip';
|
||||
/* eslint-enable import/no-namespace */
|
||||
|
||||
import axios from 'axios';
|
||||
import LRU from 'lru-cache';
|
||||
import isbot from 'isbot';
|
||||
import { createCertificate } from 'pem';
|
||||
import { createServer } from 'https';
|
||||
import { json } from 'body-parser';
|
||||
@@ -53,6 +55,8 @@ import { buildAppConfig } from './src/config/config.server';
|
||||
import { APP_CONFIG, AppConfig } from './src/config/app-config.interface';
|
||||
import { extendEnvironmentWithAppConfig } from './src/config/config.util';
|
||||
import { logStartupMessage } from './startup-message';
|
||||
import { TOKENITEM } from 'src/app/core/auth/models/auth-token-info.model';
|
||||
|
||||
|
||||
/*
|
||||
* Set path for the browser application's dist folder
|
||||
@@ -67,6 +71,12 @@ const cookieParser = require('cookie-parser');
|
||||
|
||||
const appConfig: AppConfig = buildAppConfig(join(DIST_FOLDER, 'assets/config.json'));
|
||||
|
||||
// cache of SSR pages for known bots, only enabled in production mode
|
||||
let botCache: LRU<string, any>;
|
||||
|
||||
// cache of SSR pages for anonymous users. Disabled by default, and only available in production mode
|
||||
let anonymousCache: LRU<string, any>;
|
||||
|
||||
// extend environment with app config for server
|
||||
extendEnvironmentWithAppConfig(environment, appConfig);
|
||||
|
||||
@@ -87,10 +97,12 @@ export function app() {
|
||||
/*
|
||||
* If production mode is enabled in the environment file:
|
||||
* - Enable Angular's production mode
|
||||
* - Initialize caching of SSR rendered pages (if enabled in config.yml)
|
||||
* - Enable compression for SSR reponses. See [compression](https://github.com/expressjs/compression)
|
||||
*/
|
||||
if (environment.production) {
|
||||
enableProdMode();
|
||||
initCache();
|
||||
server.use(compression({
|
||||
// only compress responses we've marked as SSR
|
||||
// otherwise, this middleware may compress files we've chosen not to compress via compression-webpack-plugin
|
||||
@@ -106,13 +118,13 @@ export function app() {
|
||||
|
||||
/*
|
||||
* Add cookie parser middleware
|
||||
* See [morgan](https://github.com/expressjs/cookie-parser)
|
||||
* See [cookie-parser](https://github.com/expressjs/cookie-parser)
|
||||
*/
|
||||
server.use(cookieParser());
|
||||
|
||||
/*
|
||||
* Add parser for request bodies
|
||||
* See [morgan](https://github.com/expressjs/body-parser)
|
||||
* Add JSON parser for request bodies
|
||||
* See [body-parser](https://github.com/expressjs/body-parser)
|
||||
*/
|
||||
server.use(json());
|
||||
|
||||
@@ -186,7 +198,7 @@ export function app() {
|
||||
* Serve static resources (images, i18n messages, …)
|
||||
* Handle pre-compressed files with [express-static-gzip](https://github.com/tkoenig89/express-static-gzip)
|
||||
*/
|
||||
router.get('*.*', cacheControl, expressStaticGzip(DIST_FOLDER, {
|
||||
router.get('*.*', addCacheControl, expressStaticGzip(DIST_FOLDER, {
|
||||
index: false,
|
||||
enableBrotli: true,
|
||||
orderPreference: ['br', 'gzip'],
|
||||
@@ -202,8 +214,11 @@ export function app() {
|
||||
*/
|
||||
server.get('/app/health', healthCheck);
|
||||
|
||||
// Register the ngApp callback function to handle incoming requests
|
||||
router.get('*', ngApp);
|
||||
/**
|
||||
* Default sending all incoming requests to ngApp() function, after first checking for a cached
|
||||
* copy of the page (see cacheCheck())
|
||||
*/
|
||||
router.get('*', cacheCheck, ngApp);
|
||||
|
||||
server.use(environment.ui.nameSpace, router);
|
||||
|
||||
@@ -215,60 +230,249 @@ export function app() {
|
||||
*/
|
||||
function ngApp(req, res) {
|
||||
if (environment.universal.preboot) {
|
||||
res.render(indexHtml, {
|
||||
req,
|
||||
res,
|
||||
preboot: environment.universal.preboot,
|
||||
async: environment.universal.async,
|
||||
time: environment.universal.time,
|
||||
baseUrl: environment.ui.nameSpace,
|
||||
originUrl: environment.ui.baseUrl,
|
||||
requestUrl: req.originalUrl,
|
||||
providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }]
|
||||
}, (err, data) => {
|
||||
if (hasNoValue(err) && hasValue(data)) {
|
||||
res.locals.ssr = true; // mark response as SSR
|
||||
res.send(data);
|
||||
} else if (hasValue(err) && err.code === 'ERR_HTTP_HEADERS_SENT') {
|
||||
// When this error occurs we can't fall back to CSR because the response has already been
|
||||
// sent. These errors occur for various reasons in universal, not all of which are in our
|
||||
// control to solve.
|
||||
console.warn('Warning [ERR_HTTP_HEADERS_SENT]: Tried to set headers after they were sent to the client');
|
||||
} else {
|
||||
console.warn('Error in SSR, serving for direct CSR.');
|
||||
if (hasValue(err)) {
|
||||
console.warn('Error details : ', err);
|
||||
}
|
||||
res.render(indexHtml, {
|
||||
req,
|
||||
providers: [{
|
||||
provide: APP_BASE_HREF,
|
||||
useValue: req.baseUrl
|
||||
}]
|
||||
});
|
||||
}
|
||||
});
|
||||
// Render the page to user via SSR (server side rendering)
|
||||
serverSideRender(req, res);
|
||||
} else {
|
||||
// If preboot is disabled, just serve the client
|
||||
console.log('Universal off, serving for direct CSR');
|
||||
res.render(indexHtml, {
|
||||
req,
|
||||
providers: [{
|
||||
provide: APP_BASE_HREF,
|
||||
useValue: req.baseUrl
|
||||
}]
|
||||
console.log('Universal off, serving for direct client-side rendering (CSR)');
|
||||
clientSideRender(req, res);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render page content on server side using Angular SSR. By default this page content is
|
||||
* returned to the user.
|
||||
* @param req current request
|
||||
* @param res current response
|
||||
* @param sendToUser if true (default), send the rendered content to the user.
|
||||
* If false, then only save this rendered content to the in-memory cache (to refresh cache).
|
||||
*/
|
||||
function serverSideRender(req, res, sendToUser: boolean = true) {
|
||||
// Render the page via SSR (server side rendering)
|
||||
res.render(indexHtml, {
|
||||
req,
|
||||
res,
|
||||
preboot: environment.universal.preboot,
|
||||
async: environment.universal.async,
|
||||
time: environment.universal.time,
|
||||
baseUrl: environment.ui.nameSpace,
|
||||
originUrl: environment.ui.baseUrl,
|
||||
requestUrl: req.originalUrl,
|
||||
providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }]
|
||||
}, (err, data) => {
|
||||
if (hasNoValue(err) && hasValue(data)) {
|
||||
// save server side rendered page to cache (if any are enabled)
|
||||
saveToCache(req, data);
|
||||
if (sendToUser) {
|
||||
res.locals.ssr = true; // mark response as SSR (enables text compression)
|
||||
// send rendered page to user
|
||||
res.send(data);
|
||||
}
|
||||
} else if (hasValue(err) && err.code === 'ERR_HTTP_HEADERS_SENT') {
|
||||
// When this error occurs we can't fall back to CSR because the response has already been
|
||||
// sent. These errors occur for various reasons in universal, not all of which are in our
|
||||
// control to solve.
|
||||
console.warn('Warning [ERR_HTTP_HEADERS_SENT]: Tried to set headers after they were sent to the client');
|
||||
} else {
|
||||
console.warn('Error in server-side rendering (SSR)');
|
||||
if (hasValue(err)) {
|
||||
console.warn('Error details : ', err);
|
||||
}
|
||||
if (sendToUser) {
|
||||
console.warn('Falling back to serving direct client-side rendering (CSR).');
|
||||
clientSideRender(req, res);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send back response to user to trigger direct client-side rendering (CSR)
|
||||
* @param req current request
|
||||
* @param res current response
|
||||
*/
|
||||
function clientSideRender(req, res) {
|
||||
res.render(indexHtml, {
|
||||
req,
|
||||
providers: [{
|
||||
provide: APP_BASE_HREF,
|
||||
useValue: req.baseUrl
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Adds a Cache-Control HTTP header to the response.
|
||||
* The cache control value can be configured in the config.*.yml file
|
||||
* Defaults to max-age=604,800 seconds (1 week)
|
||||
*/
|
||||
function addCacheControl(req, res, next) {
|
||||
// instruct browser to revalidate
|
||||
res.header('Cache-Control', environment.cache.control || 'max-age=604800');
|
||||
next();
|
||||
}
|
||||
|
||||
/*
|
||||
* Initialize server-side caching of pages rendered via SSR.
|
||||
*/
|
||||
function initCache() {
|
||||
if (botCacheEnabled()) {
|
||||
// Initialize a new "least-recently-used" item cache (where least recently used pages are removed first)
|
||||
// See https://www.npmjs.com/package/lru-cache
|
||||
// When enabled, each page defaults to expiring after 1 day
|
||||
botCache = new LRU( {
|
||||
max: environment.cache.serverSide.botCache.max,
|
||||
ttl: environment.cache.serverSide.botCache.timeToLive || 24 * 60 * 60 * 1000, // 1 day
|
||||
allowStale: environment.cache.serverSide.botCache.allowStale ?? true // if object is stale, return stale value before deleting
|
||||
});
|
||||
}
|
||||
|
||||
if (anonymousCacheEnabled()) {
|
||||
// NOTE: While caches may share SSR pages, this cache must be kept separately because the timeToLive
|
||||
// may expire pages more frequently.
|
||||
// When enabled, each page defaults to expiring after 10 seconds (to minimize anonymous users seeing out-of-date content)
|
||||
anonymousCache = new LRU( {
|
||||
max: environment.cache.serverSide.anonymousCache.max,
|
||||
ttl: environment.cache.serverSide.anonymousCache.timeToLive || 10 * 1000, // 10 seconds
|
||||
allowStale: environment.cache.serverSide.anonymousCache.allowStale ?? true // if object is stale, return stale value before deleting
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Adds a cache control header to the response
|
||||
* The cache control value can be configured in the environments file and defaults to max-age=60
|
||||
/**
|
||||
* Return whether bot-specific server side caching is enabled in configuration.
|
||||
*/
|
||||
function cacheControl(req, res, next) {
|
||||
// instruct browser to revalidate
|
||||
res.header('Cache-Control', environment.cache.control || 'max-age=60');
|
||||
next();
|
||||
function botCacheEnabled(): boolean {
|
||||
// Caching is only enabled if SSR is enabled AND
|
||||
// "max" pages to cache is greater than zero
|
||||
return environment.universal.preboot && environment.cache.serverSide.botCache.max && (environment.cache.serverSide.botCache.max > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return whether anonymous user server side caching is enabled in configuration.
|
||||
*/
|
||||
function anonymousCacheEnabled(): boolean {
|
||||
// Caching is only enabled if SSR is enabled AND
|
||||
// "max" pages to cache is greater than zero
|
||||
return environment.universal.preboot && environment.cache.serverSide.anonymousCache.max && (environment.cache.serverSide.anonymousCache.max > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the currently requested page is in our server-side, in-memory cache.
|
||||
* Caching is ONLY done for SSR requests. Pages are cached base on their path (e.g. /home or /search?query=test)
|
||||
*/
|
||||
function cacheCheck(req, res, next) {
|
||||
// Cached copy of page (if found)
|
||||
let cachedCopy;
|
||||
|
||||
// If the bot cache is enabled and this request looks like a bot, check the bot cache for a cached page.
|
||||
if (botCacheEnabled() && isbot(req.get('user-agent'))) {
|
||||
cachedCopy = checkCacheForRequest('bot', botCache, req, res);
|
||||
} else if (anonymousCacheEnabled() && !isUserAuthenticated(req)) {
|
||||
cachedCopy = checkCacheForRequest('anonymous', anonymousCache, req, res);
|
||||
}
|
||||
|
||||
// If cached copy exists, return it to the user.
|
||||
if (cachedCopy) {
|
||||
res.locals.ssr = true; // mark response as SSR-generated (enables text compression)
|
||||
res.send(cachedCopy);
|
||||
|
||||
// Tell Express to skip all other handlers for this path
|
||||
// This ensures we don't try to re-render the page since we've already returned the cached copy
|
||||
next('router');
|
||||
} else {
|
||||
// If nothing found in cache, just continue with next handler
|
||||
// (This should send the request on to the handler that rerenders the page via SSR
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the current request (i.e. page) is found in the given cache. If it is found,
|
||||
* the cached copy is returned. When found, this method also triggers a re-render via
|
||||
* SSR if the cached copy is now expired (i.e. timeToLive has passed for this cached copy).
|
||||
* @param cacheName name of cache (just useful for debug logging)
|
||||
* @param cache LRU cache to check
|
||||
* @param req current request to look for in the cache
|
||||
* @param res current response
|
||||
* @returns cached copy (if found) or undefined (if not found)
|
||||
*/
|
||||
function checkCacheForRequest(cacheName: string, cache: LRU<string, any>, req, res): any {
|
||||
// Get the cache key for this request
|
||||
const key = getCacheKey(req);
|
||||
|
||||
// Check if this page is in our cache
|
||||
let cachedCopy = cache.get(key);
|
||||
if (cachedCopy) {
|
||||
if (environment.cache.serverSide.debug) { console.log(`CACHE HIT FOR ${key} in ${cacheName} cache`); }
|
||||
|
||||
// Check if cached copy is expired (If expired, the key will now be gone from cache)
|
||||
// NOTE: This will only occur when "allowStale=true", as it means the "get(key)" above returned a stale value.
|
||||
if (!cache.has(key)) {
|
||||
if (environment.cache.serverSide.debug) { console.log(`CACHE EXPIRED FOR ${key} in ${cacheName} cache. Re-rendering...`); }
|
||||
// Update cached copy by rerendering server-side
|
||||
// NOTE: In this scenario the currently cached copy will be returned to the current user.
|
||||
// This re-render is peformed behind the scenes to update cached copy for next user.
|
||||
serverSideRender(req, res, false);
|
||||
}
|
||||
} else {
|
||||
if (environment.cache.serverSide.debug) { console.log(`CACHE MISS FOR ${key} in ${cacheName} cache.`); }
|
||||
}
|
||||
|
||||
// return page from cache
|
||||
return cachedCopy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a cache key from the current request.
|
||||
* The cache key is the URL path (NOTE: this key will also include any querystring params).
|
||||
* E.g. "/home" or "/search?query=test"
|
||||
* @param req current request
|
||||
* @returns cache key to use for this page
|
||||
*/
|
||||
function getCacheKey(req): string {
|
||||
// NOTE: this will return the URL path *without* any baseUrl
|
||||
return req.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save page to server side cache(s), if enabled. If caching is not enabled or a user is authenticated, this is a noop
|
||||
* If multiple caches are enabled, the page will be saved to any caches where it does not yet exist (or is expired).
|
||||
* (This minimizes the number of times we need to run SSR on the same page.)
|
||||
* @param req current page request
|
||||
* @param page page data to save to cache
|
||||
*/
|
||||
function saveToCache(req, page: any) {
|
||||
// Only cache if no one is currently authenticated. This means ONLY public pages can be cached.
|
||||
// NOTE: It's not safe to save page data to the cache when a user is authenticated. In that situation,
|
||||
// the page may include sensitive or user-specific materials. As the cache is shared across all users, it can only contain public info.
|
||||
if (!isUserAuthenticated(req)) {
|
||||
const key = getCacheKey(req);
|
||||
// Avoid caching "/reload/[random]" paths (these are hard refreshes after logout)
|
||||
if (key.startsWith('/reload')) { return; }
|
||||
|
||||
// If bot cache is enabled, save it to that cache if it doesn't exist or is expired
|
||||
// (NOTE: has() will return false if page is expired in cache)
|
||||
if (botCacheEnabled() && !botCache.has(key)) {
|
||||
botCache.set(key, page);
|
||||
if (environment.cache.serverSide.debug) { console.log(`CACHE SAVE FOR ${key} in bot cache.`); }
|
||||
}
|
||||
|
||||
// If anonymous cache is enabled, save it to that cache if it doesn't exist or is expired
|
||||
if (anonymousCacheEnabled() && !anonymousCache.has(key)) {
|
||||
anonymousCache.set(key, page);
|
||||
if (environment.cache.serverSide.debug) { console.log(`CACHE SAVE FOR ${key} in anonymous cache.`); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a user is authenticated or not
|
||||
*/
|
||||
function isUserAuthenticated(req): boolean {
|
||||
// Check whether our DSpace authentication Cookie exists or not
|
||||
return req.cookies[TOKENITEM];
|
||||
}
|
||||
|
||||
/*
|
||||
|
@@ -47,6 +47,12 @@ import { BatchImportPageComponent } from './admin-import-batch-page/batch-import
|
||||
component: BatchImportPageComponent,
|
||||
data: { title: 'admin.batch-import.title', breadcrumbKey: 'admin.batch-import' }
|
||||
},
|
||||
{
|
||||
path: 'system-wide-alert',
|
||||
resolve: { breadcrumb: I18nBreadcrumbResolver },
|
||||
loadChildren: () => import('../system-wide-alert/system-wide-alert.module').then((m) => m.SystemWideAlertModule),
|
||||
data: {title: 'admin.system-wide-alert.title', breadcrumbKey: 'admin.system-wide-alert'}
|
||||
},
|
||||
])
|
||||
],
|
||||
providers: [
|
||||
|
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);
|
||||
}
|
||||
|
||||
|
||||
}
|
@@ -17,6 +17,7 @@ import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-con
|
||||
import { APP_CONFIG, AppConfig } from '../../../config/app-config.interface';
|
||||
import { isPlatformBrowser } from '@angular/common';
|
||||
import { setPlaceHolderAttributes } from '../../shared/utils/object-list-utils';
|
||||
import { DSpaceObjectType } from '../../core/shared/dspace-object-type.model';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-recent-item-list',
|
||||
@@ -67,6 +68,7 @@ export class RecentItemListComponent implements OnInit {
|
||||
this.itemRD$ = this.searchService.search(
|
||||
new PaginatedSearchOptions({
|
||||
pagination: this.paginationConfig,
|
||||
dsoTypes: [DSpaceObjectType.ITEM],
|
||||
sort: this.sortConfig,
|
||||
}),
|
||||
undefined,
|
||||
|
@@ -607,6 +607,18 @@ export class MenuResolver implements Resolve<boolean> {
|
||||
icon: 'user-check',
|
||||
index: 11
|
||||
},
|
||||
{
|
||||
id: 'system_wide_alert',
|
||||
active: false,
|
||||
visible: authorized,
|
||||
model: {
|
||||
type: MenuItemType.LINK,
|
||||
text: 'menu.section.system-wide-alert',
|
||||
link: '/admin/system-wide-alert'
|
||||
} as LinkMenuItemModel,
|
||||
icon: 'exclamation-circle',
|
||||
index: 12
|
||||
},
|
||||
];
|
||||
|
||||
menuList.forEach((menuSection) => this.menuService.addSection(MenuID.ADMIN, Object.assign(menuSection, {
|
||||
|
@@ -43,11 +43,13 @@ import {
|
||||
import { ThemedPageErrorComponent } from './page-error/themed-page-error.component';
|
||||
import { PageErrorComponent } from './page-error/page-error.component';
|
||||
import { ContextHelpToggleComponent } from './header/context-help-toggle/context-help-toggle.component';
|
||||
import { SystemWideAlertModule } from './system-wide-alert/system-wide-alert.module';
|
||||
|
||||
const IMPORTS = [
|
||||
CommonModule,
|
||||
SharedModule.withEntryComponents(),
|
||||
NavbarModule,
|
||||
SystemWideAlertModule,
|
||||
NgbModule,
|
||||
];
|
||||
|
||||
|
@@ -4,6 +4,7 @@
|
||||
}">
|
||||
<ds-themed-admin-sidebar></ds-themed-admin-sidebar>
|
||||
<div class="inner-wrapper">
|
||||
<ds-system-wide-alert-banner></ds-system-wide-alert-banner>
|
||||
<ds-themed-header-navbar-wrapper></ds-themed-header-navbar-wrapper>
|
||||
<main class="main-content">
|
||||
<ds-themed-breadcrumbs></ds-themed-breadcrumbs>
|
||||
|
@@ -0,0 +1,27 @@
|
||||
<div *ngIf="(systemWideAlert$ |async)?.active">
|
||||
<div class="rounded-0 alert alert-warning w100">
|
||||
<div class="container">
|
||||
<span class="font-weight-bold">
|
||||
<span *ngIf="(countDownDays|async) > 0 || (countDownHours| async) > 0 || (countDownMinutes|async) > 0 ">
|
||||
{{'system-wide-alert-banner.countdown.prefix' | translate }}
|
||||
</span>
|
||||
<span *ngIf="(countDownDays|async) > 0">
|
||||
{{'system-wide-alert-banner.countdown.days' | translate: {
|
||||
days: countDownDays|async
|
||||
} }}
|
||||
</span>
|
||||
<span *ngIf="(countDownDays|async) > 0 || (countDownHours| async) > 0 ">
|
||||
{{'system-wide-alert-banner.countdown.hours' | translate: {
|
||||
hours: countDownHours| async
|
||||
} }}
|
||||
</span>
|
||||
<span *ngIf="(countDownDays|async) > 0 || (countDownHours| async) > 0 || (countDownMinutes|async) > 0 ">
|
||||
{{'system-wide-alert-banner.countdown.minutes' | translate: {
|
||||
minutes: countDownMinutes|async
|
||||
} }}
|
||||
</span>
|
||||
</span>
|
||||
<span>{{(systemWideAlert$ |async)?.message}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@@ -0,0 +1,114 @@
|
||||
import { ComponentFixture, discardPeriodicTasks, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing';
|
||||
import { SystemWideAlertBannerComponent } from './system-wide-alert-banner.component';
|
||||
import { SystemWideAlertDataService } from '../../core/data/system-wide-alert-data.service';
|
||||
import { SystemWideAlert } from '../system-wide-alert.model';
|
||||
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
|
||||
import { utcToZonedTime } from 'date-fns-tz';
|
||||
import { createPaginatedList } from '../../shared/testing/utils.test';
|
||||
import { TestScheduler } from 'rxjs/testing';
|
||||
import { getTestScheduler } from 'jasmine-marbles';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub';
|
||||
|
||||
|
||||
describe('SystemWideAlertBannerComponent', () => {
|
||||
let comp: SystemWideAlertBannerComponent;
|
||||
let fixture: ComponentFixture<SystemWideAlertBannerComponent>;
|
||||
let systemWideAlertDataService: SystemWideAlertDataService;
|
||||
|
||||
let systemWideAlert: SystemWideAlert;
|
||||
let scheduler: TestScheduler;
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
scheduler = getTestScheduler();
|
||||
|
||||
const countDownDate = new Date();
|
||||
countDownDate.setDate(countDownDate.getDate() + 1);
|
||||
countDownDate.setHours(countDownDate.getHours() + 1);
|
||||
countDownDate.setMinutes(countDownDate.getMinutes() + 1);
|
||||
|
||||
systemWideAlert = Object.assign(new SystemWideAlert(), {
|
||||
alertId: 1,
|
||||
message: 'Test alert message',
|
||||
active: true,
|
||||
countdownTo: utcToZonedTime(countDownDate, 'UTC').toISOString()
|
||||
});
|
||||
|
||||
systemWideAlertDataService = jasmine.createSpyObj('systemWideAlertDataService', {
|
||||
searchBy: createSuccessfulRemoteDataObject$(createPaginatedList([systemWideAlert])),
|
||||
});
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TranslateModule.forRoot()],
|
||||
declarations: [SystemWideAlertBannerComponent],
|
||||
providers: [
|
||||
{provide: SystemWideAlertDataService, useValue: systemWideAlertDataService},
|
||||
{provide: NotificationsService, useValue: new NotificationsServiceStub()},
|
||||
]
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(SystemWideAlertBannerComponent);
|
||||
comp = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
describe('init', () => {
|
||||
it('should init the comp', () => {
|
||||
expect(comp).toBeTruthy();
|
||||
});
|
||||
it('should set the time countdown parts in their respective behaviour subjects', fakeAsync(() => {
|
||||
spyOn(comp.countDownDays, 'next');
|
||||
spyOn(comp.countDownHours, 'next');
|
||||
spyOn(comp.countDownMinutes, 'next');
|
||||
comp.ngOnInit();
|
||||
tick(2000);
|
||||
expect(comp.countDownDays.next).toHaveBeenCalled();
|
||||
expect(comp.countDownHours.next).toHaveBeenCalled();
|
||||
expect(comp.countDownMinutes.next).toHaveBeenCalled();
|
||||
discardPeriodicTasks();
|
||||
|
||||
}));
|
||||
});
|
||||
|
||||
describe('banner', () => {
|
||||
it('should display the alert message and the timer', () => {
|
||||
comp.countDownDays.next(1);
|
||||
comp.countDownHours.next(1);
|
||||
comp.countDownMinutes.next(1);
|
||||
fixture.detectChanges();
|
||||
|
||||
const banner = fixture.debugElement.queryAll(By.css('span'));
|
||||
expect(banner.length).toEqual(6);
|
||||
|
||||
expect(banner[0].nativeElement.innerHTML).toContain('system-wide-alert-banner.countdown.prefix');
|
||||
expect(banner[0].nativeElement.innerHTML).toContain('system-wide-alert-banner.countdown.days');
|
||||
expect(banner[0].nativeElement.innerHTML).toContain('system-wide-alert-banner.countdown.hours');
|
||||
expect(banner[0].nativeElement.innerHTML).toContain('system-wide-alert-banner.countdown.minutes');
|
||||
|
||||
expect(banner[5].nativeElement.innerHTML).toContain(systemWideAlert.message);
|
||||
});
|
||||
|
||||
it('should display the alert message but no timer when no timer is present', () => {
|
||||
comp.countDownDays.next(0);
|
||||
comp.countDownHours.next(0);
|
||||
comp.countDownMinutes.next(0);
|
||||
fixture.detectChanges();
|
||||
|
||||
const banner = fixture.debugElement.queryAll(By.css('span'));
|
||||
expect(banner.length).toEqual(2);
|
||||
expect(banner[1].nativeElement.innerHTML).toContain(systemWideAlert.message);
|
||||
});
|
||||
|
||||
it('should not display an alert when none is present', () => {
|
||||
comp.systemWideAlert$.next(null);
|
||||
fixture.detectChanges();
|
||||
|
||||
const banner = fixture.debugElement.queryAll(By.css('span'));
|
||||
expect(banner.length).toEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
@@ -0,0 +1,125 @@
|
||||
import { Component, Inject, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core';
|
||||
import { SystemWideAlertDataService } from '../../core/data/system-wide-alert-data.service';
|
||||
import {
|
||||
getAllSucceededRemoteDataPayload
|
||||
} from '../../core/shared/operators';
|
||||
import { filter, map, switchMap } from 'rxjs/operators';
|
||||
import { PaginatedList } from '../../core/data/paginated-list.model';
|
||||
import { SystemWideAlert } from '../system-wide-alert.model';
|
||||
import { hasValue, isNotEmpty } from '../../shared/empty.util';
|
||||
import { BehaviorSubject, EMPTY, interval, Subscription } from 'rxjs';
|
||||
import { zonedTimeToUtc } from 'date-fns-tz';
|
||||
import { isPlatformBrowser } from '@angular/common';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
|
||||
/**
|
||||
* Component responsible for rendering a banner and the countdown for an active system-wide alert
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-system-wide-alert-banner',
|
||||
styleUrls: ['./system-wide-alert-banner.component.scss'],
|
||||
templateUrl: './system-wide-alert-banner.component.html'
|
||||
})
|
||||
export class SystemWideAlertBannerComponent implements OnInit, OnDestroy {
|
||||
|
||||
/**
|
||||
* BehaviorSubject that keeps track of the currently configured system-wide alert
|
||||
*/
|
||||
systemWideAlert$ = new BehaviorSubject<SystemWideAlert>(undefined);
|
||||
|
||||
/**
|
||||
* BehaviorSubject that keeps track of the amount of minutes left to count down to
|
||||
*/
|
||||
countDownMinutes = new BehaviorSubject<number>(0);
|
||||
|
||||
/**
|
||||
* BehaviorSubject that keeps track of the amount of hours left to count down to
|
||||
*/
|
||||
countDownHours = new BehaviorSubject<number>(0);
|
||||
|
||||
/**
|
||||
* BehaviorSubject that keeps track of the amount of days left to count down to
|
||||
*/
|
||||
countDownDays = new BehaviorSubject<number>(0);
|
||||
|
||||
/**
|
||||
* List of subscriptions
|
||||
*/
|
||||
subscriptions: Subscription[] = [];
|
||||
|
||||
constructor(
|
||||
@Inject(PLATFORM_ID) protected platformId: Object,
|
||||
protected systemWideAlertDataService: SystemWideAlertDataService,
|
||||
protected notificationsService: NotificationsService,
|
||||
) {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.subscriptions.push(this.systemWideAlertDataService.searchBy('active').pipe(
|
||||
getAllSucceededRemoteDataPayload(),
|
||||
map((payload: PaginatedList<SystemWideAlert>) => payload.page),
|
||||
filter((page) => isNotEmpty(page)),
|
||||
map((page) => page[0])
|
||||
).subscribe((alert: SystemWideAlert) => {
|
||||
this.systemWideAlert$.next(alert);
|
||||
}));
|
||||
|
||||
this.subscriptions.push(this.systemWideAlert$.pipe(
|
||||
switchMap((alert: SystemWideAlert) => {
|
||||
if (hasValue(alert) && hasValue(alert.countdownTo)) {
|
||||
const date = zonedTimeToUtc(alert.countdownTo, 'UTC');
|
||||
const timeDifference = date.getTime() - new Date().getTime();
|
||||
if (timeDifference > 0) {
|
||||
this.allocateTimeUnits(timeDifference);
|
||||
if (isPlatformBrowser(this.platformId)) {
|
||||
return interval(1000);
|
||||
} else {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
// Reset the countDown times to 0 and return EMPTY to prevent unnecessary countdown calculations
|
||||
this.countDownDays.next(0);
|
||||
this.countDownHours.next(0);
|
||||
this.countDownMinutes.next(0);
|
||||
return EMPTY;
|
||||
})
|
||||
).subscribe(() => {
|
||||
this.setTimeDifference(this.systemWideAlert$.getValue().countdownTo);
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to calculate the time difference between the countdown date from the system-wide alert and "now"
|
||||
* @param countdownTo - The date to count down to
|
||||
*/
|
||||
private setTimeDifference(countdownTo: string) {
|
||||
const date = zonedTimeToUtc(countdownTo, 'UTC');
|
||||
|
||||
const timeDifference = date.getTime() - new Date().getTime();
|
||||
this.allocateTimeUnits(timeDifference);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to push how many days, hours and minutes are left in the countdown to their respective behaviour subject
|
||||
* @param timeDifference - The time difference to calculate and push the time units for
|
||||
*/
|
||||
private allocateTimeUnits(timeDifference) {
|
||||
const minutes = Math.floor((timeDifference) / (1000 * 60) % 60);
|
||||
const hours = Math.floor((timeDifference) / (1000 * 60 * 60) % 24);
|
||||
const days = Math.floor((timeDifference) / (1000 * 60 * 60 * 24));
|
||||
|
||||
this.countDownMinutes.next(minutes);
|
||||
this.countDownHours.next(hours);
|
||||
this.countDownDays.next(days);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.subscriptions.forEach((sub: Subscription) => {
|
||||
if (hasValue(sub)) {
|
||||
sub.unsubscribe();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@@ -0,0 +1,114 @@
|
||||
<div class="container">
|
||||
<h2 id="header">{{'system-wide-alert.form.header' | translate}}</h2>
|
||||
<div [formGroup]="alertForm" [class]="'ng-invalid'">
|
||||
<div class="form-group">
|
||||
<div class="row mb-2">
|
||||
<div class="col">
|
||||
<ui-switch [checkedLabel]="'system-wide-alert.form.label.active' | translate"
|
||||
[uncheckedLabel]="'system-wide-alert.form.label.inactive' | translate"
|
||||
[checked]="formActive.value"
|
||||
(change)="setActive($event)"></ui-switch>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<label for="formMessage">{{ 'system-wide-alert.form.label.message' | translate }}</label>
|
||||
<textarea id="formMessage" rows="5"
|
||||
[className]="(formMessage.invalid) && (formMessage.dirty || formMessage.touched) ? 'form-control is-invalid' :'form-control'"
|
||||
formControlName="formMessage">
|
||||
</textarea>
|
||||
<div *ngIf="formMessage.invalid && (formMessage.dirty || formMessage.touched)"
|
||||
class="invalid-feedback show-feedback">
|
||||
<span *ngIf="formMessage.errors">
|
||||
{{ 'system-wide-alert.form.error.message' | translate }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col mb-2 d-flex align-items-end">
|
||||
<ui-switch size="small"
|
||||
[checked]="counterEnabled$ |async"
|
||||
(change)="setCounterEnabled($event)"></ui-switch>
|
||||
<span class="ml-2">{{ 'system-wide-alert.form.label.countdownTo.enable' | translate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="counterEnabled$ |async">
|
||||
<div class="row">
|
||||
<div class="col-sm-12 col-md-6">
|
||||
<div class="input-group">
|
||||
<input
|
||||
class="form-control"
|
||||
placeholder="yyyy-mm-dd"
|
||||
name="dp"
|
||||
[(ngModel)]="date"
|
||||
[minDate]="minDate"
|
||||
ngbDatepicker
|
||||
#d="ngbDatepicker"
|
||||
(ngModelChange)="updatePreviewTime()"
|
||||
/>
|
||||
<button class="btn btn-outline-secondary fas fa-calendar" (click)="d.toggle()"
|
||||
type="button"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 d-md-none">
|
||||
<div class="input-group">
|
||||
<ngb-timepicker [(ngModel)]="time" (ngModelChange)="updatePreviewTime()"></ngb-timepicker>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-none d-md-block col-md-6 timepicker-margin">
|
||||
<div class="input-group">
|
||||
<ngb-timepicker [(ngModel)]="time" (ngModelChange)="updatePreviewTime()"></ngb-timepicker>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<span class="text-muted"> {{'system-wide-alert.form.label.countdownTo.hint' | translate}}</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div *ngIf="formMessage.value">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<label>{{ 'system-wide-alert.form.label.preview' | translate }}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-0 alert alert-warning">
|
||||
<span class="font-weight-bold">
|
||||
<span *ngIf="previewDays > 0 || previewHours > 0 || previewMinutes > 0 ">
|
||||
{{'system-wide-alert-banner.countdown.prefix' | translate }}
|
||||
</span>
|
||||
<span *ngIf="previewDays > 0">
|
||||
{{'system-wide-alert-banner.countdown.days' | translate: {
|
||||
days: previewDays
|
||||
} }}
|
||||
</span>
|
||||
<span *ngIf="previewDays > 0 || previewHours > 0 ">
|
||||
{{'system-wide-alert-banner.countdown.hours' | translate: {
|
||||
hours: previewHours
|
||||
} }}
|
||||
</span>
|
||||
<span *ngIf="previewDays > 0 || previewHours > 0 || previewMinutes > 0 ">
|
||||
{{'system-wide-alert-banner.countdown.minutes' | translate: {
|
||||
minutes: previewMinutes
|
||||
} }}
|
||||
</span>
|
||||
</span>
|
||||
<span>{{formMessage.value}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-row float-right space-children-mr mt-2">
|
||||
<button (click)="back()"
|
||||
class="btn btn-outline-secondary"><i
|
||||
class="fas fa-arrow-left"></i> {{'system-wide-alert.form.cancel' | translate}}</button>
|
||||
<button class="btn btn-primary" [disabled]="alertForm.invalid"
|
||||
(click)="save()">
|
||||
<i class="fa fa-save"></i> {{ 'system-wide-alert.form.save' | translate}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
@@ -0,0 +1,4 @@
|
||||
.timepicker-margin {
|
||||
// Negative margin to offset the time picker arrows and ensure the date and time are correctly aligned
|
||||
margin-top: -38px;
|
||||
}
|
@@ -0,0 +1,314 @@
|
||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
import { SystemWideAlertDataService } from '../../core/data/system-wide-alert-data.service';
|
||||
import { SystemWideAlert } from '../system-wide-alert.model';
|
||||
import { utcToZonedTime, zonedTimeToUtc } from 'date-fns-tz';
|
||||
import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
|
||||
import { createPaginatedList } from '../../shared/testing/utils.test';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { SystemWideAlertFormComponent } from './system-wide-alert-form.component';
|
||||
import { RequestService } from '../../core/data/request.service';
|
||||
import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub';
|
||||
import { RouterStub } from '../../shared/testing/router.stub';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { Router } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { UiSwitchModule } from 'ngx-ui-switch';
|
||||
import { SystemWideAlertModule } from '../system-wide-alert.module';
|
||||
|
||||
describe('SystemWideAlertFormComponent', () => {
|
||||
let comp: SystemWideAlertFormComponent;
|
||||
let fixture: ComponentFixture<SystemWideAlertFormComponent>;
|
||||
let systemWideAlertDataService: SystemWideAlertDataService;
|
||||
|
||||
let systemWideAlert: SystemWideAlert;
|
||||
let requestService: RequestService;
|
||||
let notificationsService;
|
||||
let router;
|
||||
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
|
||||
const countDownDate = new Date();
|
||||
countDownDate.setDate(countDownDate.getDate() + 1);
|
||||
countDownDate.setHours(countDownDate.getHours() + 1);
|
||||
countDownDate.setMinutes(countDownDate.getMinutes() + 1);
|
||||
|
||||
systemWideAlert = Object.assign(new SystemWideAlert(), {
|
||||
alertId: 1,
|
||||
message: 'Test alert message',
|
||||
active: true,
|
||||
countdownTo: utcToZonedTime(countDownDate, 'UTC').toISOString()
|
||||
});
|
||||
|
||||
systemWideAlertDataService = jasmine.createSpyObj('systemWideAlertDataService', {
|
||||
findAll: createSuccessfulRemoteDataObject$(createPaginatedList([systemWideAlert])),
|
||||
put: createSuccessfulRemoteDataObject$(systemWideAlert),
|
||||
create: createSuccessfulRemoteDataObject$(systemWideAlert)
|
||||
});
|
||||
|
||||
requestService = jasmine.createSpyObj('requestService', ['setStaleByHrefSubstring']);
|
||||
|
||||
notificationsService = new NotificationsServiceStub();
|
||||
router = new RouterStub();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [FormsModule, SystemWideAlertModule, UiSwitchModule, TranslateModule.forRoot()],
|
||||
declarations: [SystemWideAlertFormComponent],
|
||||
providers: [
|
||||
{provide: SystemWideAlertDataService, useValue: systemWideAlertDataService},
|
||||
{provide: NotificationsService, useValue: notificationsService},
|
||||
{provide: Router, useValue: router},
|
||||
{provide: RequestService, useValue: requestService},
|
||||
]
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(SystemWideAlertFormComponent);
|
||||
comp = fixture.componentInstance;
|
||||
|
||||
spyOn(comp, 'createForm').and.callThrough();
|
||||
spyOn(comp, 'initFormValues').and.callThrough();
|
||||
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
describe('init', () => {
|
||||
it('should init the comp', () => {
|
||||
expect(comp).toBeTruthy();
|
||||
});
|
||||
it('should create the form and init the values based on an existing alert', () => {
|
||||
expect(comp.createForm).toHaveBeenCalled();
|
||||
expect(comp.initFormValues).toHaveBeenCalledWith(systemWideAlert);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createForm', () => {
|
||||
it('should create the form', () => {
|
||||
const now = new Date();
|
||||
|
||||
comp.createForm();
|
||||
expect(comp.formMessage.value).toEqual('');
|
||||
expect(comp.formActive.value).toEqual(false);
|
||||
expect(comp.time).toEqual({hour: now.getHours(), minute: now.getMinutes()});
|
||||
expect(comp.date).toEqual({year: now.getFullYear(), month: now.getMonth() + 1, day: now.getDate()});
|
||||
});
|
||||
});
|
||||
|
||||
describe('initFormValues', () => {
|
||||
it('should fill in the form based on the provided system-wide alert', () => {
|
||||
comp.initFormValues(systemWideAlert);
|
||||
|
||||
const countDownTo = zonedTimeToUtc(systemWideAlert.countdownTo, 'UTC');
|
||||
|
||||
expect(comp.formMessage.value).toEqual(systemWideAlert.message);
|
||||
expect(comp.formActive.value).toEqual(true);
|
||||
expect(comp.time).toEqual({hour: countDownTo.getHours(), minute: countDownTo.getMinutes()});
|
||||
expect(comp.date).toEqual({
|
||||
year: countDownTo.getFullYear(),
|
||||
month: countDownTo.getMonth() + 1,
|
||||
day: countDownTo.getDate()
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('setCounterEnabled', () => {
|
||||
it('should set the preview time on enable and update the behaviour subject', () => {
|
||||
spyOn(comp, 'updatePreviewTime');
|
||||
comp.setCounterEnabled(true);
|
||||
|
||||
expect(comp.updatePreviewTime).toHaveBeenCalled();
|
||||
expect(comp.counterEnabled$.value).toBeTrue();
|
||||
});
|
||||
it('should reset the preview time on disable and update the behaviour subject', () => {
|
||||
spyOn(comp, 'updatePreviewTime');
|
||||
comp.setCounterEnabled(false);
|
||||
|
||||
expect(comp.updatePreviewTime).not.toHaveBeenCalled();
|
||||
expect(comp.previewDays).toEqual(0);
|
||||
expect(comp.previewHours).toEqual(0);
|
||||
expect(comp.previewMinutes).toEqual(0);
|
||||
expect(comp.counterEnabled$.value).toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updatePreviewTime', () => {
|
||||
it('should calculate the difference between the current date and the date configured in the form', () => {
|
||||
const countDownDate = new Date();
|
||||
countDownDate.setDate(countDownDate.getDate() + 1);
|
||||
countDownDate.setHours(countDownDate.getHours() + 1);
|
||||
countDownDate.setMinutes(countDownDate.getMinutes() + 1);
|
||||
|
||||
comp.time = {hour: countDownDate.getHours(), minute: countDownDate.getMinutes()};
|
||||
comp.date = {year: countDownDate.getFullYear(), month: countDownDate.getMonth() + 1, day: countDownDate.getDate()};
|
||||
|
||||
comp.updatePreviewTime();
|
||||
|
||||
expect(comp.previewDays).toEqual(1);
|
||||
expect(comp.previewHours).toEqual(1);
|
||||
expect(comp.previewDays).toEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setActive', () => {
|
||||
it('should set whether the alert is active and save the current alert', () => {
|
||||
spyOn(comp, 'save');
|
||||
spyOn(comp.formActive, 'patchValue');
|
||||
comp.setActive(true);
|
||||
|
||||
expect(comp.formActive.patchValue).toHaveBeenCalledWith(true);
|
||||
expect(comp.save).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('save', () => {
|
||||
it('should update the exising alert with the form values and show a success notification on success and navigate back', () => {
|
||||
spyOn(comp, 'back');
|
||||
comp.currentAlert = systemWideAlert;
|
||||
|
||||
comp.formMessage.patchValue('New message');
|
||||
comp.formActive.patchValue(true);
|
||||
comp.time = {hour: 4, minute: 26};
|
||||
comp.date = {year: 2023, month: 1, day: 25};
|
||||
|
||||
const expectedAlert = new SystemWideAlert();
|
||||
expectedAlert.alertId = systemWideAlert.alertId;
|
||||
expectedAlert.message = 'New message';
|
||||
expectedAlert.active = true;
|
||||
const countDownTo = new Date(2023, 0, 25, 4, 26);
|
||||
expectedAlert.countdownTo = utcToZonedTime(countDownTo, 'UTC').toUTCString();
|
||||
|
||||
comp.save();
|
||||
|
||||
expect(systemWideAlertDataService.put).toHaveBeenCalledWith(expectedAlert);
|
||||
expect(notificationsService.success).toHaveBeenCalled();
|
||||
expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledWith('systemwidealerts');
|
||||
expect(comp.back).toHaveBeenCalled();
|
||||
});
|
||||
it('should update the exising alert with the form values and show a success notification on success and not navigate back when false is provided to the save method', () => {
|
||||
spyOn(comp, 'back');
|
||||
comp.currentAlert = systemWideAlert;
|
||||
|
||||
comp.formMessage.patchValue('New message');
|
||||
comp.formActive.patchValue(true);
|
||||
comp.time = {hour: 4, minute: 26};
|
||||
comp.date = {year: 2023, month: 1, day: 25};
|
||||
|
||||
const expectedAlert = new SystemWideAlert();
|
||||
expectedAlert.alertId = systemWideAlert.alertId;
|
||||
expectedAlert.message = 'New message';
|
||||
expectedAlert.active = true;
|
||||
const countDownTo = new Date(2023, 0, 25, 4, 26);
|
||||
expectedAlert.countdownTo = utcToZonedTime(countDownTo, 'UTC').toUTCString();
|
||||
|
||||
comp.save(false);
|
||||
|
||||
expect(systemWideAlertDataService.put).toHaveBeenCalledWith(expectedAlert);
|
||||
expect(notificationsService.success).toHaveBeenCalled();
|
||||
expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledWith('systemwidealerts');
|
||||
expect(comp.back).not.toHaveBeenCalled();
|
||||
});
|
||||
it('should update the exising alert with the form values but add an empty countdown date when disabled and show a success notification on success', () => {
|
||||
spyOn(comp, 'back');
|
||||
comp.currentAlert = systemWideAlert;
|
||||
|
||||
comp.formMessage.patchValue('New message');
|
||||
comp.formActive.patchValue(true);
|
||||
comp.time = {hour: 4, minute: 26};
|
||||
comp.date = {year: 2023, month: 1, day: 25};
|
||||
comp.counterEnabled$.next(false);
|
||||
|
||||
const expectedAlert = new SystemWideAlert();
|
||||
expectedAlert.alertId = systemWideAlert.alertId;
|
||||
expectedAlert.message = 'New message';
|
||||
expectedAlert.active = true;
|
||||
expectedAlert.countdownTo = null;
|
||||
|
||||
comp.save();
|
||||
|
||||
expect(systemWideAlertDataService.put).toHaveBeenCalledWith(expectedAlert);
|
||||
expect(notificationsService.success).toHaveBeenCalled();
|
||||
expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledWith('systemwidealerts');
|
||||
expect(comp.back).toHaveBeenCalled();
|
||||
});
|
||||
it('should update the exising alert with the form values and show a error notification on error', () => {
|
||||
spyOn(comp, 'back');
|
||||
(systemWideAlertDataService.put as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$());
|
||||
comp.currentAlert = systemWideAlert;
|
||||
|
||||
comp.formMessage.patchValue('New message');
|
||||
comp.formActive.patchValue(true);
|
||||
comp.time = {hour: 4, minute: 26};
|
||||
comp.date = {year: 2023, month: 1, day: 25};
|
||||
|
||||
const expectedAlert = new SystemWideAlert();
|
||||
expectedAlert.alertId = systemWideAlert.alertId;
|
||||
expectedAlert.message = 'New message';
|
||||
expectedAlert.active = true;
|
||||
const countDownTo = new Date(2023, 0, 25, 4, 26);
|
||||
expectedAlert.countdownTo = utcToZonedTime(countDownTo, 'UTC').toUTCString();
|
||||
|
||||
comp.save();
|
||||
|
||||
expect(systemWideAlertDataService.put).toHaveBeenCalledWith(expectedAlert);
|
||||
expect(notificationsService.error).toHaveBeenCalled();
|
||||
expect(requestService.setStaleByHrefSubstring).not.toHaveBeenCalledWith('systemwidealerts');
|
||||
expect(comp.back).not.toHaveBeenCalled();
|
||||
});
|
||||
it('should create a new alert with the form values and show a success notification on success', () => {
|
||||
spyOn(comp, 'back');
|
||||
comp.currentAlert = undefined;
|
||||
|
||||
comp.formMessage.patchValue('New message');
|
||||
comp.formActive.patchValue(true);
|
||||
comp.time = {hour: 4, minute: 26};
|
||||
comp.date = {year: 2023, month: 1, day: 25};
|
||||
|
||||
const expectedAlert = new SystemWideAlert();
|
||||
expectedAlert.message = 'New message';
|
||||
expectedAlert.active = true;
|
||||
const countDownTo = new Date(2023, 0, 25, 4, 26);
|
||||
expectedAlert.countdownTo = utcToZonedTime(countDownTo, 'UTC').toUTCString();
|
||||
|
||||
comp.save();
|
||||
|
||||
expect(systemWideAlertDataService.create).toHaveBeenCalledWith(expectedAlert);
|
||||
expect(notificationsService.success).toHaveBeenCalled();
|
||||
expect(requestService.setStaleByHrefSubstring).toHaveBeenCalledWith('systemwidealerts');
|
||||
expect(comp.back).toHaveBeenCalled();
|
||||
|
||||
});
|
||||
it('should create a new alert with the form values and show a error notification on error', () => {
|
||||
spyOn(comp, 'back');
|
||||
(systemWideAlertDataService.create as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$());
|
||||
|
||||
comp.currentAlert = undefined;
|
||||
|
||||
comp.formMessage.patchValue('New message');
|
||||
comp.formActive.patchValue(true);
|
||||
comp.time = {hour: 4, minute: 26};
|
||||
comp.date = {year: 2023, month: 1, day: 25};
|
||||
|
||||
const expectedAlert = new SystemWideAlert();
|
||||
expectedAlert.message = 'New message';
|
||||
expectedAlert.active = true;
|
||||
const countDownTo = new Date(2023, 0, 25, 4, 26);
|
||||
expectedAlert.countdownTo = utcToZonedTime(countDownTo, 'UTC').toUTCString();
|
||||
|
||||
comp.save();
|
||||
|
||||
expect(systemWideAlertDataService.create).toHaveBeenCalledWith(expectedAlert);
|
||||
expect(notificationsService.error).toHaveBeenCalled();
|
||||
expect(requestService.setStaleByHrefSubstring).not.toHaveBeenCalledWith('systemwidealerts');
|
||||
expect(comp.back).not.toHaveBeenCalled();
|
||||
|
||||
});
|
||||
});
|
||||
describe('back', () => {
|
||||
it('should navigate back to the home page', () => {
|
||||
comp.back();
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/home']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
});
|
@@ -0,0 +1,254 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { SystemWideAlertDataService } from '../../core/data/system-wide-alert-data.service';
|
||||
import { getFirstCompletedRemoteData } from '../../core/shared/operators';
|
||||
import { filter, map } from 'rxjs/operators';
|
||||
import { PaginatedList } from '../../core/data/paginated-list.model';
|
||||
import { SystemWideAlert } from '../system-wide-alert.model';
|
||||
import { hasValue, isNotEmpty } from '../../shared/empty.util';
|
||||
import { BehaviorSubject, Observable } from 'rxjs';
|
||||
import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms';
|
||||
import { NgbDateStruct } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { utcToZonedTime, zonedTimeToUtc } from 'date-fns-tz';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { Router } from '@angular/router';
|
||||
import { RequestService } from '../../core/data/request.service';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
||||
|
||||
/**
|
||||
* Component responsible for rendering the form to update a system-wide alert
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-system-wide-alert-form',
|
||||
styleUrls: ['./system-wide-alert-form.component.scss'],
|
||||
templateUrl: './system-wide-alert-form.component.html'
|
||||
})
|
||||
export class SystemWideAlertFormComponent implements OnInit {
|
||||
|
||||
/**
|
||||
* Observable to track an existing system-wide alert
|
||||
*/
|
||||
systemWideAlert$: Observable<SystemWideAlert>;
|
||||
|
||||
/**
|
||||
* The currently configured system-wide alert
|
||||
*/
|
||||
currentAlert: SystemWideAlert;
|
||||
|
||||
/**
|
||||
* The form group representing the system-wide alert
|
||||
*/
|
||||
alertForm: FormGroup;
|
||||
|
||||
/**
|
||||
* Date object to store the countdown date part
|
||||
*/
|
||||
date: NgbDateStruct;
|
||||
|
||||
/**
|
||||
* The minimum date for the countdown timer
|
||||
*/
|
||||
minDate: NgbDateStruct;
|
||||
|
||||
/**
|
||||
* Object to store the countdown time part
|
||||
*/
|
||||
time;
|
||||
|
||||
/**
|
||||
* Behaviour subject to track whether the counter is enabled
|
||||
*/
|
||||
counterEnabled$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
|
||||
|
||||
/**
|
||||
* The amount of minutes to be used in the banner preview
|
||||
*/
|
||||
previewMinutes: number;
|
||||
|
||||
/**
|
||||
* The amount of hours to be used in the banner preview
|
||||
*/
|
||||
previewHours: number;
|
||||
|
||||
/**
|
||||
* The amount of days to be used in the banner preview
|
||||
*/
|
||||
previewDays: number;
|
||||
|
||||
|
||||
constructor(
|
||||
protected systemWideAlertDataService: SystemWideAlertDataService,
|
||||
protected notificationsService: NotificationsService,
|
||||
protected router: Router,
|
||||
protected requestService: RequestService,
|
||||
protected translateService: TranslateService
|
||||
) {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.systemWideAlert$ = this.systemWideAlertDataService.findAll().pipe(
|
||||
getFirstCompletedRemoteData(),
|
||||
map((rd) => {
|
||||
if (rd.hasSucceeded) {
|
||||
return rd.payload;
|
||||
} else {
|
||||
this.notificationsService.error('system-wide-alert-form.retrieval.error');
|
||||
}
|
||||
}),
|
||||
map((payload: PaginatedList<SystemWideAlert>) => payload.page),
|
||||
filter((page) => isNotEmpty(page)),
|
||||
map((page) => page[0])
|
||||
);
|
||||
this.createForm();
|
||||
|
||||
const currentDate = new Date();
|
||||
this.minDate = {year: currentDate.getFullYear(), month: currentDate.getMonth() + 1, day: currentDate.getDate()};
|
||||
|
||||
|
||||
this.systemWideAlert$.subscribe((alert) => {
|
||||
this.currentAlert = alert;
|
||||
this.initFormValues(alert);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the form with empty values
|
||||
*/
|
||||
createForm() {
|
||||
this.alertForm = new FormBuilder().group({
|
||||
formMessage: new FormControl('', {
|
||||
validators: [Validators.required],
|
||||
}),
|
||||
formActive: new FormControl(false),
|
||||
}
|
||||
);
|
||||
this.setDateTime(new Date());
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the form values based on the values retrieve from the provided system-wide alert
|
||||
* @param alert - System-wide alert to use to init the form
|
||||
*/
|
||||
initFormValues(alert: SystemWideAlert) {
|
||||
this.formMessage.patchValue(alert.message);
|
||||
this.formActive.patchValue(alert.active);
|
||||
const countDownTo = zonedTimeToUtc(alert.countdownTo, 'UTC');
|
||||
if (countDownTo.getTime() - new Date().getTime() > 0) {
|
||||
this.counterEnabled$.next(true);
|
||||
this.setDateTime(countDownTo);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Set whether the system-wide alert is active
|
||||
* Will also save the info in the current system-wide alert
|
||||
* @param active
|
||||
*/
|
||||
setActive(active: boolean) {
|
||||
this.formActive.patchValue(active);
|
||||
this.save(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set whether the countdown timer is enabled or disabled. This will also update the counter in the preview
|
||||
* @param enabled - Whether the countdown timer is enabled or disabled.
|
||||
*/
|
||||
setCounterEnabled(enabled: boolean) {
|
||||
this.counterEnabled$.next(enabled);
|
||||
if (!enabled) {
|
||||
this.previewMinutes = 0;
|
||||
this.previewHours = 0;
|
||||
this.previewDays = 0;
|
||||
} else {
|
||||
this.updatePreviewTime();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private setDateTime(dateToSet) {
|
||||
this.time = {hour: dateToSet.getHours(), minute: dateToSet.getMinutes()};
|
||||
this.date = {year: dateToSet.getFullYear(), month: dateToSet.getMonth() + 1, day: dateToSet.getDate()};
|
||||
|
||||
this.updatePreviewTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the preview time based on the configured countdown date and the current time
|
||||
*/
|
||||
updatePreviewTime() {
|
||||
const countDownTo = new Date(this.date.year, this.date.month - 1, this.date.day, this.time.hour, this.time.minute);
|
||||
const timeDifference = countDownTo.getTime() - new Date().getTime();
|
||||
this.allocateTimeUnits(timeDifference);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to push how many days, hours and minutes are left in the countdown to their respective behaviour subject
|
||||
* @param timeDifference - The time difference to calculate and push the time units for
|
||||
*/
|
||||
private allocateTimeUnits(timeDifference) {
|
||||
this.previewMinutes = Math.floor((timeDifference) / (1000 * 60) % 60);
|
||||
this.previewHours = Math.floor((timeDifference) / (1000 * 60 * 60) % 24);
|
||||
this.previewDays = Math.floor((timeDifference) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
|
||||
get formMessage() {
|
||||
return this.alertForm.get('formMessage');
|
||||
}
|
||||
|
||||
get formActive() {
|
||||
return this.alertForm.get('formActive');
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the system-wide alert present in the form
|
||||
* When no alert is present yet on the server, a new one will be created
|
||||
* When one already exists, the existing one will be updated
|
||||
*
|
||||
* @param navigateToHomePage - Whether the user should be navigated back on successful save or not
|
||||
*/
|
||||
save(navigateToHomePage = true) {
|
||||
const alert = new SystemWideAlert();
|
||||
alert.message = this.formMessage.value;
|
||||
alert.active = this.formActive.value;
|
||||
if (this.counterEnabled$.getValue()) {
|
||||
const countDownTo = new Date(this.date.year, this.date.month - 1, this.date.day, this.time.hour, this.time.minute);
|
||||
alert.countdownTo = utcToZonedTime(countDownTo, 'UTC').toUTCString();
|
||||
} else {
|
||||
alert.countdownTo = null;
|
||||
}
|
||||
if (hasValue(this.currentAlert)) {
|
||||
const updatedAlert = Object.assign(new SystemWideAlert(), this.currentAlert, alert);
|
||||
this.handleResponse(this.systemWideAlertDataService.put(updatedAlert), 'system-wide-alert.form.update', navigateToHomePage);
|
||||
} else {
|
||||
this.handleResponse(this.systemWideAlertDataService.create(alert), 'system-wide-alert.form.create', navigateToHomePage);
|
||||
}
|
||||
}
|
||||
|
||||
private handleResponse(response$: Observable<RemoteData<SystemWideAlert>>, messagePrefix, navigateToHomePage: boolean) {
|
||||
response$.pipe(
|
||||
getFirstCompletedRemoteData()
|
||||
).subscribe((response: RemoteData<SystemWideAlert>) => {
|
||||
if (response.hasSucceeded) {
|
||||
this.notificationsService.success(this.translateService.get(`${messagePrefix}.success`));
|
||||
this.requestService.setStaleByHrefSubstring('systemwidealerts');
|
||||
if (navigateToHomePage) {
|
||||
this.back();
|
||||
}
|
||||
} else {
|
||||
this.notificationsService.error(this.translateService.get(`${messagePrefix}.error`, response.errorMessage));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate back to the homepage
|
||||
*/
|
||||
back() {
|
||||
this.router.navigate(['/home']);
|
||||
}
|
||||
|
||||
|
||||
}
|
@@ -0,0 +1,22 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import {
|
||||
SiteAdministratorGuard
|
||||
} from '../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard';
|
||||
import { SystemWideAlertFormComponent } from './alert-form/system-wide-alert-form.component';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild([
|
||||
{
|
||||
path: '',
|
||||
canActivate: [SiteAdministratorGuard],
|
||||
component: SystemWideAlertFormComponent,
|
||||
},
|
||||
|
||||
])
|
||||
]
|
||||
})
|
||||
export class SystemWideAlertRoutingModule {
|
||||
|
||||
}
|
55
src/app/system-wide-alert/system-wide-alert.model.ts
Normal file
55
src/app/system-wide-alert/system-wide-alert.model.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { autoserialize, deserialize } from 'cerialize';
|
||||
import { typedObject } from '../core/cache/builders/build-decorators';
|
||||
import { CacheableObject } from '../core/cache/cacheable-object.model';
|
||||
import { HALLink } from '../core/shared/hal-link.model';
|
||||
import { ResourceType } from '../core/shared/resource-type';
|
||||
import { excludeFromEquals } from '../core/utilities/equals.decorators';
|
||||
import { SYSTEMWIDEALERT } from './system-wide-alert.resource-type';
|
||||
|
||||
/**
|
||||
* Object representing a system-wide alert
|
||||
*/
|
||||
@typedObject
|
||||
export class SystemWideAlert implements CacheableObject {
|
||||
static type = SYSTEMWIDEALERT;
|
||||
|
||||
/**
|
||||
* The object type
|
||||
*/
|
||||
@excludeFromEquals
|
||||
@autoserialize
|
||||
type: ResourceType;
|
||||
|
||||
/**
|
||||
* The identifier for this system-wide alert
|
||||
*/
|
||||
@autoserialize
|
||||
alertId: string;
|
||||
|
||||
/**
|
||||
* The message for this system-wide alert
|
||||
*/
|
||||
@autoserialize
|
||||
message: string;
|
||||
|
||||
/**
|
||||
* A string representation of the date to which this system-wide alert will count down when active
|
||||
*/
|
||||
@autoserialize
|
||||
countdownTo: string;
|
||||
|
||||
/**
|
||||
* Whether the system-wide alert is active
|
||||
*/
|
||||
@autoserialize
|
||||
active: boolean;
|
||||
|
||||
|
||||
/**
|
||||
* The {@link HALLink}s for this system-wide alert
|
||||
*/
|
||||
@deserialize
|
||||
_links: {
|
||||
self: HALLink,
|
||||
};
|
||||
}
|
33
src/app/system-wide-alert/system-wide-alert.module.ts
Normal file
33
src/app/system-wide-alert/system-wide-alert.module.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { SystemWideAlertBannerComponent } from './alert-banner/system-wide-alert-banner.component';
|
||||
import { SystemWideAlertFormComponent } from './alert-form/system-wide-alert-form.component';
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
import { SystemWideAlertDataService } from '../core/data/system-wide-alert-data.service';
|
||||
import { SystemWideAlertRoutingModule } from './system-wide-alert-routing.module';
|
||||
import { UiSwitchModule } from 'ngx-ui-switch';
|
||||
import { NgbDatepickerModule, NgbTimepickerModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
FormsModule,
|
||||
SharedModule,
|
||||
UiSwitchModule,
|
||||
SystemWideAlertRoutingModule,
|
||||
NgbTimepickerModule,
|
||||
NgbDatepickerModule,
|
||||
],
|
||||
exports: [
|
||||
SystemWideAlertBannerComponent
|
||||
],
|
||||
declarations: [
|
||||
SystemWideAlertBannerComponent,
|
||||
SystemWideAlertFormComponent
|
||||
],
|
||||
providers: [
|
||||
SystemWideAlertDataService
|
||||
]
|
||||
})
|
||||
export class SystemWideAlertModule {
|
||||
|
||||
}
|
10
src/app/system-wide-alert/system-wide-alert.resource-type.ts
Normal file
10
src/app/system-wide-alert/system-wide-alert.resource-type.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* The resource type for SystemWideAlert
|
||||
*
|
||||
* Needs to be in a separate file to prevent circular
|
||||
* dependencies in webpack.
|
||||
*/
|
||||
|
||||
import { ResourceType } from '../core/shared/resource-type';
|
||||
|
||||
export const SYSTEMWIDEALERT = new ResourceType('systemwidealert');
|
@@ -4922,4 +4922,53 @@
|
||||
"home.recent-submissions.head": "Recent Submissions",
|
||||
|
||||
"listable-notification-object.default-message": "This object couldn't be retrieved",
|
||||
|
||||
|
||||
"system-wide-alert-banner.retrieval.error": "Something went wrong retrieving the system-wide alert banner",
|
||||
|
||||
"system-wide-alert-banner.countdown.prefix": "In",
|
||||
|
||||
"system-wide-alert-banner.countdown.days": "{{days}} day(s),",
|
||||
|
||||
"system-wide-alert-banner.countdown.hours": "{{hours}} hour(s) and",
|
||||
|
||||
"system-wide-alert-banner.countdown.minutes": "{{minutes}} minute(s):",
|
||||
|
||||
|
||||
|
||||
"menu.section.system-wide-alert": "System-wide Alert",
|
||||
|
||||
"system-wide-alert.form.header": "System-wide Alert",
|
||||
|
||||
"system-wide-alert-form.retrieval.error": "Something went wrong retrieving the system-wide alert",
|
||||
|
||||
"system-wide-alert.form.cancel": "Cancel",
|
||||
|
||||
"system-wide-alert.form.save": "Save",
|
||||
|
||||
"system-wide-alert.form.label.active": "ACTIVE",
|
||||
|
||||
"system-wide-alert.form.label.inactive": "INACTIVE",
|
||||
|
||||
"system-wide-alert.form.error.message": "The system wide alert must have a message",
|
||||
|
||||
"system-wide-alert.form.label.message": "Alert message",
|
||||
|
||||
"system-wide-alert.form.label.countdownTo.enable": "Enable a countdown timer",
|
||||
|
||||
"system-wide-alert.form.label.countdownTo.hint": "Hint: Set a countdown timer. When enabled, a date can be set in the future and the system-wide alert banner will perform a countdown to the set date. When this timer ends, it will disappear from the alert. The server will NOT be automatically stopped.",
|
||||
|
||||
"system-wide-alert.form.label.preview": "System-wide alert preview",
|
||||
|
||||
"system-wide-alert.form.update.success": "The system-wide alert was successfully updated",
|
||||
|
||||
"system-wide-alert.form.update.error": "Something went wrong when updating the system-wide alert",
|
||||
|
||||
"system-wide-alert.form.create.success": "The system-wide alert was successfully created",
|
||||
|
||||
"system-wide-alert.form.create.error": "Something went wrong when creating the system-wide alert",
|
||||
|
||||
"admin.system-wide-alert.breadcrumbs": "System-wide Alerts",
|
||||
|
||||
"admin.system-wide-alert.title": "System-wide Alerts",
|
||||
}
|
||||
|
@@ -5,6 +5,31 @@ export interface CacheConfig extends Config {
|
||||
msToLive: {
|
||||
default: number;
|
||||
};
|
||||
// Cache-Control HTTP Header
|
||||
control: string;
|
||||
autoSync: AutoSyncConfig;
|
||||
// In-memory caches of server-side rendered (SSR) content. These caches can be used to limit the frequency
|
||||
// of re-generating SSR pages to improve performance.
|
||||
serverSide: {
|
||||
// Debug server-side caching. Set to true to see cache hits/misses/refreshes in console logs.
|
||||
debug: boolean,
|
||||
// Cache specific to known bots. Allows you to serve cached contents to bots only.
|
||||
botCache: {
|
||||
// Maximum number of pages (rendered via SSR) to cache. Setting max=0 disables the cache.
|
||||
max: number;
|
||||
// Amount of time after which cached pages are considered stale (in ms)
|
||||
timeToLive: number;
|
||||
// true = return page from cache after timeToLive expires. false = return a fresh page after timeToLive expires
|
||||
allowStale: boolean;
|
||||
},
|
||||
// Cache specific to anonymous users. Allows you to serve cached content to non-authenticated users.
|
||||
anonymousCache: {
|
||||
// Maximum number of pages (rendered via SSR) to cache. Setting max=0 disables the cache.
|
||||
max: number;
|
||||
// Amount of time after which cached pages are considered stale (in ms)
|
||||
timeToLive: number;
|
||||
// true = return page from cache after timeToLive expires. false = return a fresh page after timeToLive expires
|
||||
allowStale: boolean;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -67,11 +67,34 @@ export class DefaultAppConfig implements AppConfig {
|
||||
msToLive: {
|
||||
default: 15 * 60 * 1000 // 15 minutes
|
||||
},
|
||||
control: 'max-age=60', // revalidate browser
|
||||
// Cache-Control HTTP Header
|
||||
control: 'max-age=604800', // revalidate browser
|
||||
autoSync: {
|
||||
defaultTime: 0,
|
||||
maxBufferSize: 100,
|
||||
timePerMethod: { [RestRequestMethod.PATCH]: 3 } as any // time in seconds
|
||||
},
|
||||
// In-memory cache of server-side rendered content
|
||||
serverSide: {
|
||||
debug: false,
|
||||
// Cache specific to known bots. Allows you to serve cached contents to bots only.
|
||||
// Defaults to caching 1,000 pages. Each page expires after 1 day
|
||||
botCache: {
|
||||
// Maximum number of pages (rendered via SSR) to cache. Setting max=0 disables the cache.
|
||||
max: 1000,
|
||||
// Amount of time after which cached pages are considered stale (in ms)
|
||||
timeToLive: 24 * 60 * 60 * 1000, // 1 day
|
||||
allowStale: true,
|
||||
},
|
||||
// Cache specific to anonymous users. Allows you to serve cached content to non-authenticated users.
|
||||
// Defaults to caching 0 pages. But, when enabled, each page expires after 10 seconds (to minimize anonymous users seeing out-of-date content)
|
||||
anonymousCache: {
|
||||
// Maximum number of pages (rendered via SSR) to cache. Setting max=0 disables the cache.
|
||||
max: 0, // disabled by default
|
||||
// Amount of time after which cached pages are considered stale (in ms)
|
||||
timeToLive: 10 * 1000, // 10 seconds
|
||||
allowStale: true,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
@@ -55,6 +55,20 @@ export const environment: BuildConfig = {
|
||||
defaultTime: 0,
|
||||
maxBufferSize: 100,
|
||||
timePerMethod: { [RestRequestMethod.PATCH]: 3 } as any // time in seconds
|
||||
},
|
||||
// In-memory cache of server-side rendered pages. Disabled in test environment (max=0)
|
||||
serverSide: {
|
||||
debug: false,
|
||||
botCache: {
|
||||
max: 0,
|
||||
timeToLive: 24 * 60 * 60 * 1000, // 1 day
|
||||
allowStale: true,
|
||||
},
|
||||
anonymousCache: {
|
||||
max: 0,
|
||||
timeToLive: 10 * 1000, // 10 seconds
|
||||
allowStale: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
@@ -6,6 +6,7 @@
|
||||
<base href="/">
|
||||
<title>DSpace</title>
|
||||
<meta name="viewport" content="width=device-width,minimum-scale=1">
|
||||
<meta http-equiv="cache-control" content="no-store">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
@@ -123,6 +123,7 @@ import { ItemSharedModule } from '../../app/item-page/item-shared.module';
|
||||
import { ResultsBackButtonComponent } from './app/shared/results-back-button/results-back-button.component';
|
||||
import { DsoEditMetadataComponent } from './app/dso-shared/dso-edit-metadata/dso-edit-metadata.component';
|
||||
import { DsoSharedModule } from '../../app/dso-shared/dso-shared.module';
|
||||
import { SystemWideAlertModule } from '../../app/system-wide-alert/system-wide-alert.module';
|
||||
import { DsoPageModule } from '../../app/shared/dso-page/dso-page.module';
|
||||
|
||||
const DECLARATIONS = [
|
||||
@@ -236,6 +237,7 @@ const DECLARATIONS = [
|
||||
ResourcePoliciesModule,
|
||||
ComcolModule,
|
||||
DsoSharedModule,
|
||||
SystemWideAlertModule
|
||||
],
|
||||
declarations: DECLARATIONS,
|
||||
exports: [
|
||||
|
@@ -6749,6 +6749,11 @@ isbinaryfile@^4.0.8:
|
||||
resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-4.0.10.tgz#0c5b5e30c2557a2f06febd37b7322946aaee42b3"
|
||||
integrity sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==
|
||||
|
||||
isbot@^3.6.5:
|
||||
version "3.6.5"
|
||||
resolved "https://registry.yarnpkg.com/isbot/-/isbot-3.6.5.tgz#a749980d9dfba9ebcc03ee7b548d1f24dd8c9f1e"
|
||||
integrity sha512-BchONELXt6yMad++BwGpa0oQxo/uD0keL7N15cYVf0A1oMIoNQ79OqeYdPMFWDrNhCqCbRuw9Y9F3QBjvAxZ5g==
|
||||
|
||||
isexe@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
|
||||
@@ -7468,7 +7473,7 @@ lru-cache@^6.0.0:
|
||||
dependencies:
|
||||
yallist "^4.0.0"
|
||||
|
||||
lru-cache@^7.7.1:
|
||||
lru-cache@^7.14.1, lru-cache@^7.7.1:
|
||||
version "7.14.1"
|
||||
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.14.1.tgz#8da8d2f5f59827edb388e63e459ac23d6d408fea"
|
||||
integrity sha512-ysxwsnTKdAx96aTRdhDOCQfDgbHnt8SK0KY8SEjO0wHinhWOFTESbjVCMPbU1uGXg/ch4lifqx0wfjOawU2+WA==
|
||||
|
Reference in New Issue
Block a user