diff --git a/common-lib/src/main/resources/application.conf b/common-lib/src/main/resources/application.conf
index a94c989b9e..31be9e1401 100644
--- a/common-lib/src/main/resources/application.conf
+++ b/common-lib/src/main/resources/application.conf
@@ -104,6 +104,26 @@ usageRightsConfigProvider = {
}
}
+# -------------------------------------------------------------
+# Announcements - notifications to be seen by users
+# Format:
+# [ (array)
+# { (json object)
+# announceId: (string) the unique id of the announcement - should be unique among all active announcements
+# description: (string) the main text to display in the notification notification_banner
+# endDate: (string, optional, format="yyyy-mm-dd") the date beyond which the announcement should not be seen, if not present set as today + 1 year
+# url: (string, optional) a link to a page/document providing further details regarding announcement
+# urlText: (string, optional) text to be included in a-tag hyperlink (will revert to default if not present)
+# category: (string) the type of announcement - will control styling and display, Enum=announcement, information, warning, error, success
+# lifespan: (string) the lifecycle behaviour Enum=transient (message disappears on any click etc),
+# session (message must be acknowledged but action NOT stored in client cookie - used for current session messages)
+# persistent (message must be acknowledged and action stored in client cookie - used for long running announcements)
+# },
+# ...
+# ]
+# -----------------------------------------------------------------
+announcements = []
+
domainMetadata.specifications = []
metadata.templates = []
diff --git a/kahuna/app/controllers/KahunaController.scala b/kahuna/app/controllers/KahunaController.scala
index cc57a69d80..a9d447ae16 100644
--- a/kahuna/app/controllers/KahunaController.scala
+++ b/kahuna/app/controllers/KahunaController.scala
@@ -53,6 +53,7 @@ class KahunaController(
val domainMetadataSpecs: String = Json.toJson(config.domainMetadataSpecs).toString()
val fieldAliases: String = Json.toJson(config.fieldAliasConfigs).toString()
val metadataTemplates: String = Json.toJson(config.metadataTemplates).toString()
+ val announcements: String = Json.toJson(config.announcements).toString()
val returnUri = config.rootUri + okPath
val costFilterLabel = config.costFilterLabel.getOrElse("Free to use only")
val costFilterChargeable = config.costFilterChargeable.getOrElse(false)
@@ -68,6 +69,7 @@ class KahunaController(
scriptsToLoad,
domainMetadataSpecs,
metadataTemplates,
+ announcements,
additionalNavigationLinks,
costFilterLabel,
costFilterChargeable,
@@ -81,6 +83,11 @@ class KahunaController(
Ok(views.html.quotas(config.mediaApiUri))
}
+ def notifications = authentication { req =>
+ val announcements: String = Json.toJson(config.announcements).toString()
+ Ok(announcements)
+ }
+
def ok = Action { implicit request =>
Ok("ok")
}
diff --git a/kahuna/app/lib/AnnouncementsConfig.scala b/kahuna/app/lib/AnnouncementsConfig.scala
new file mode 100644
index 0000000000..01aacc82b9
--- /dev/null
+++ b/kahuna/app/lib/AnnouncementsConfig.scala
@@ -0,0 +1,67 @@
+package lib
+
+import play.api.ConfigLoader
+import play.api.libs.json._
+import scala.collection.JavaConverters._
+import scala.util.{Try, Success, Failure}
+import java.time.{LocalDate, Period}
+
+case class Announcement(
+ announceId: String,
+ description: String,
+ endDate: LocalDate,
+ url: String,
+ urlText: String,
+ category: String,
+ lifespan: String
+)
+
+object Announcement {
+
+ val announceCategory = Set("announcement", "information", "warning", "error", "success")
+ val announceLifespan = Set("transient", "session", "persistent")
+
+ implicit val writes: Writes[Announcement] = Json.writes[Announcement]
+
+ implicit val configLoader: ConfigLoader[Seq[Announcement]] = {
+ ConfigLoader(_.getConfigList).map(
+ _.asScala.map(config => {
+
+ val endDate = if (config.hasPath("endDate")) {
+ val dte = Try(LocalDate.parse(config.getString("endDate")))
+ dte match {
+ case Success(value) => value
+ case Failure(_) => LocalDate.now().plus(Period.ofYears(1))
+ }
+ } else {
+ LocalDate.now().plus(Period.ofYears(1))
+ }
+
+ val announceUrl = if (config.hasPath("url")) {
+ config.getString("url")
+ } else ""
+
+ val urlText = if (config.hasPath("urlText")) {
+ config.getString("urlText")
+ } else ""
+
+ val category = if (announceCategory.contains(config.getString("category"))) {
+ config.getString("category")
+ } else "announcement" // the expected category applicationConf announcements
+
+ val lifespan = if (announceLifespan.contains(config.getString("lifespan"))) {
+ config.getString("lifespan")
+ } else "persistent" // the expected lifespan for applicationConf announcements
+
+ Announcement(config.getString("announceId"),
+ config.getString("description"),
+ endDate,
+ announceUrl,
+ urlText,
+ category,
+ lifespan
+ )
+ }))
+ }
+
+}
diff --git a/kahuna/app/lib/KahunaConfig.scala b/kahuna/app/lib/KahunaConfig.scala
index 82953614d1..15bb398da9 100644
--- a/kahuna/app/lib/KahunaConfig.scala
+++ b/kahuna/app/lib/KahunaConfig.scala
@@ -60,6 +60,8 @@ class KahunaConfig(resources: GridConfigResources) extends CommonConfig(resource
val metadataTemplates: Seq[MetadataTemplate] = configuration.get[Seq[MetadataTemplate]]("metadata.templates")
+ val announcements: Seq[Announcement] = configuration.getOptional[Seq[Announcement]]("announcements").getOrElse(Seq.empty)
+
//BBC custom warning text
val warningTextHeader: String = configuration.getOptional[String]("warningText.header")
.getOrElse("This image can be used, but has warnings:")
diff --git a/kahuna/app/views/main.scala.html b/kahuna/app/views/main.scala.html
index 4a091f9ea8..dfcfec13a9 100644
--- a/kahuna/app/views/main.scala.html
+++ b/kahuna/app/views/main.scala.html
@@ -7,6 +7,7 @@
scriptsToLoad: List[ScriptToLoad],
domainMetadataSpecs: String,
metadataTemplates: String,
+ announcements: String,
additionalNavigationLinks: String,
costFilterLabel: String,
costFilterChargeable: Boolean,
@@ -77,6 +78,7 @@
telemetryUri: '@kahunaConfig.telemetryUri.getOrElse("")',
defaultShouldBlurGraphicImages: @kahunaConfig.defaultShouldBlurGraphicImages,
shouldUploadStraightToBucket: @kahunaConfig.shouldUploadStraightToBucket,
+ announcements: @Html(announcements),
}
@@ -86,6 +88,7 @@
+
diff --git a/kahuna/conf/routes b/kahuna/conf/routes
index 3de2853014..b8ad420d3c 100644
--- a/kahuna/conf/routes
+++ b/kahuna/conf/routes
@@ -8,6 +8,7 @@ GET /images/$id<[0-9a-z]+>/crop controllers.KahunaControll
GET /search controllers.KahunaController.index(ignored="")
GET /upload controllers.KahunaController.index(ignored="")
GET /quotas controllers.KahunaController.quotas
+GET /notifications controllers.KahunaController.notifications
# Empty page to return to the same domain as the app
GET /ok controllers.KahunaController.ok
diff --git a/kahuna/public/js/components/gr-notifications-banner/gr-notifications-banner.css b/kahuna/public/js/components/gr-notifications-banner/gr-notifications-banner.css
new file mode 100644
index 0000000000..2ab9a364a2
--- /dev/null
+++ b/kahuna/public/js/components/gr-notifications-banner/gr-notifications-banner.css
@@ -0,0 +1,93 @@
+.outer-notifications {
+ width: 100%;
+}
+
+.notification-container {
+ width: 100%;
+ display: flex;
+ border-bottom: 1px solid black;
+}
+
+.notification-container-last {
+ width: 100%;
+ display: flex;
+}
+
+.notification-start {
+ flex: 0 0 initial;
+ display: flex;
+ align-items: center;
+ margin-top: 6px;
+ padding: 8px 16px 8px 24px;
+ justify-content: center;
+}
+
+.notification-start-icon {
+ text-align: center;
+}
+
+.notification {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ padding: 8px 0px 8px 0px;
+ min-height: 28px;
+}
+
+.notification-end {
+ flex: 0 0 initial;
+ display: flex;
+ align-items: center;
+ padding: 8px 24px 8px 24px;
+}
+
+.notification-url {
+ color: black;
+ border-bottom: 1px solid black;
+ font-weight: 500;
+}
+
+.notification-url:hover {
+ color: black;
+ font-weight: 500;
+}
+
+.notification-button {
+ width: 80px;
+ padding: 8px 0 0 0;
+ text-align: center;
+}
+
+.notification-button:hover {
+ cursor: pointer;
+}
+
+.notification-announcement {
+ background: #A8CFFF;
+ color: black;
+ stroke: #A8CFFF;
+}
+
+.notification-information {
+ background: #A8CFFF;
+ color: black;
+ stroke: #A8CFFF;
+}
+
+.notification-warning {
+ background: #FEDB8B;
+ color: black;
+ stroke: #FEDB8B;
+}
+
+.notification-error {
+ background: #EEBFBE;
+ color: black;
+ stroke: #EEBFBE;
+}
+
+.notification-success {
+ background: #6FE290;
+ color: black;
+ stroke: black;
+}
diff --git a/kahuna/public/js/components/gr-notifications-banner/gr-notifications-banner.tsx b/kahuna/public/js/components/gr-notifications-banner/gr-notifications-banner.tsx
new file mode 100644
index 0000000000..e009744a85
--- /dev/null
+++ b/kahuna/public/js/components/gr-notifications-banner/gr-notifications-banner.tsx
@@ -0,0 +1,249 @@
+import * as React from "react";
+import * as angular from "angular";
+import {react2angular} from "react2angular";
+import {useEffect, useState} from "react";
+
+import "./gr-notifications-banner.css";
+
+const NOTIFICATION_LABEL = "Acknowledge and Close Notification";
+const DEFAULT_URL_TEXT = "Click here for further information";
+const PERSISTENT = "persistent";
+const TRANSIENT = "transient";
+const NOTIFICATION_COOKIE = "notification_cookie";
+const cookie_age = 31536000;
+const checkNotificationsUri = window._clientConfig.rootUri + "/notifications";
+const checkNotificationsInterval = 60000; // in ms
+
+const tickIcon = () =>
+ ;
+
+const emptyIcon = () =>
+ ;
+
+const triangleIcon = () =>
+ ;
+
+const crossIcon = () =>
+ ;
+
+const circleIcon = () =>
+ ;
+
+export interface Notification {
+ announceId: string,
+ description: string,
+ endDate: string,
+ url: string,
+ urlText: string,
+ category: string,
+ lifespan: string
+}
+
+const todayStr = (): string => {
+ const today = new Date();
+ const year = today.getFullYear();
+ const month = today.getMonth() + 1;
+ const day = today.getDate();
+ const mthStr = (month < 10) ? `0${month}` : `${month}`;
+ const dayStr = (day < 10) ? `0${day}` : `${day}`;
+ return (`${year}-${mthStr}-${dayStr}`);
+};
+
+const getCookie = (cookieName: string): string => {
+ const decodedCookie = decodeURIComponent(document.cookie);
+ const cookieArray = decodedCookie.split(';');
+ return cookieArray.find((cookie) => cookie.trim().startsWith(cookieName));
+};
+
+const mergeArraysByKey = (array1: Notification[], array2: Notification[], key: keyof Notification): Notification[] => {
+ const merged = new Map();
+ const addOrUpdate = (item: Notification) => {
+ merged.set(item[key], item);
+ };
+
+ array1.forEach(addOrUpdate);
+ array2.forEach(addOrUpdate);
+ return Array.from(merged.values());
+};
+
+const getIcon = (notification: Notification): JSX.Element => {
+ switch (notification.category) {
+ case "success":
+ return tickIcon();
+ case "error":
+ case "warning":
+ case "information":
+ return triangleIcon();
+ case "announcement":
+ return circleIcon();
+ default:
+ return emptyIcon();
+ }
+};
+
+const NotificationsBanner: React.FC = () => {
+ const [notifications, setNotifications] = useState([]);
+
+ const autoHideListener = (event: any) => {
+ if (event.type === "keydown" && event.key === "Escape") {
+ setNotifications(prevNotifs => prevNotifs.filter(n => n.lifespan !== TRANSIENT));
+ } else if (event.type !== "keydown") {
+ if (event.target.className !== "notification-url") {
+ setNotifications(prevNotifs => prevNotifs.filter(n => n.lifespan !== TRANSIENT));
+ }
+ }
+ };
+
+ const checkNotifications = () => {
+ fetch(checkNotificationsUri)
+ .then(response => {
+ if (!response.ok) {
+ throw new Error(response.statusText);
+ }
+ return response.json();
+ })
+ .then(data => {
+ const announce: Notification[] = data;
+ const tdy = todayStr();
+ let notif_cookie = getCookie(NOTIFICATION_COOKIE);
+ if (!notif_cookie) {
+ notif_cookie = "";
+ }
+ const current_notifs = announce.filter(ann => ann.endDate > tdy)
+ .filter(ann => !notif_cookie.includes(ann.announceId));
+
+ setNotifications(prev_notifs => mergeArraysByKey(prev_notifs, current_notifs, 'announceId'));
+ })
+ .catch(error => {
+ console.error('There was a problem checking for Notifications:', error);
+ });
+ };
+
+ useEffect(() => {
+ const announce = window._clientConfig.announcements;
+ const tdy = todayStr();
+ let notif_cookie = getCookie(NOTIFICATION_COOKIE);
+ if (!notif_cookie) {
+ notif_cookie = "";
+ }
+ const current_notifs = announce.filter(ann => ann.endDate > tdy)
+ .filter(ann => !notif_cookie.includes(ann.announceId));
+
+ setNotifications(current_notifs);
+
+ // trigger server call to check notifications
+ const checkNotificationsRef:NodeJS.Timeout = setInterval(checkNotifications, checkNotificationsInterval);
+
+ document.addEventListener("mouseup", autoHideListener);
+ document.addEventListener("scroll", autoHideListener);
+ document.addEventListener("keydown", autoHideListener);
+
+ // clean up cookie
+ if (notif_cookie) {
+ const current_notif_ids = announce.map(ann => ann.announceId).join(",");
+ const notif_ids = notif_cookie.split(',');
+ const new_notif_ids = notif_ids.filter(n_id => current_notif_ids.includes(n_id)).join(",");
+ document.cookie = (`${NOTIFICATION_COOKIE}=${new_notif_ids}; max-age=${cookie_age}`);
+ }
+
+ // Clean up the event listener when the component unmounts
+ return () => {
+ document.removeEventListener("mouseup", autoHideListener);
+ document.removeEventListener("scroll", autoHideListener);
+ document.removeEventListener("keydown", autoHideListener);
+ clearInterval(checkNotificationsRef);
+ };
+
+ }, []);
+
+ const handleNotificationClick = (notif: Notification) => {
+ const ns = notifications.filter(n => n.announceId !== notif.announceId);
+
+ // persistent management
+ if (notif.lifespan == PERSISTENT) {
+ const current_cookie = getCookie(NOTIFICATION_COOKIE);
+ let new_cookie = notif.announceId;
+ if (current_cookie) {
+ new_cookie = current_cookie + "," + notif.announceId;
+ }
+ document.cookie = `${NOTIFICATION_COOKIE}=${new_cookie}; max-age=${cookie_age}`;
+ }
+
+ setNotifications(ns);
+ };
+
+ return (
+
+ {notifications.map((notification, index, array) => (
+
+
+
+ {getIcon(notification)}
+
+
+
+
+
handleNotificationClick(notification)}>
+ {crossIcon()}
+
+
+
+ ))}
+
+ );
+};
+
+export const notificationsBanner = angular.module('gr.notificationsBanner', [])
+ .component('notificationsBanner', react2angular(NotificationsBanner));
diff --git a/kahuna/public/js/main.js b/kahuna/public/js/main.js
index d6765027a4..f64adee360 100644
--- a/kahuna/public/js/main.js
+++ b/kahuna/public/js/main.js
@@ -27,6 +27,8 @@ import {userActions} from './common/user-actions';
import {httpErrors} from './errors/http';
import {globalErrors} from './errors/global';
+import {notifications} from './notifications/notifications';
+
import {icon} from './components/gr-icon/gr-icon';
import {tooltip} from './components/gr-tooltip/gr-tooltip';
@@ -80,6 +82,7 @@ var kahuna = angular.module('kahuna', [
userActions.name,
httpErrors.name,
globalErrors.name,
+ notifications.name,
// directives used throughout
imageFade.name,
diff --git a/kahuna/public/js/notifications/notifications.html b/kahuna/public/js/notifications/notifications.html
new file mode 100644
index 0000000000..a76de08d96
--- /dev/null
+++ b/kahuna/public/js/notifications/notifications.html
@@ -0,0 +1,3 @@
+
+
+
diff --git a/kahuna/public/js/notifications/notifications.js b/kahuna/public/js/notifications/notifications.js
new file mode 100644
index 0000000000..4d607d52cf
--- /dev/null
+++ b/kahuna/public/js/notifications/notifications.js
@@ -0,0 +1,35 @@
+import angular from 'angular';
+import template from './notifications.html';
+import '../components/gr-notifications-banner/gr-notifications-banner';
+
+import 'angular-messages';
+import 'pandular';
+import '../sentry/sentry';
+
+export var notifications = angular.module(
+ 'kahuna.notifications',
+ ['ngMessages', 'pandular.session', 'gr.notificationsBanner']
+);
+
+notifications.controller('NotificationsCtrl',
+ ['$location',
+ function ($location) {
+ const ctrl = this;
+
+ ctrl.$onInit = () => {
+ ctrl.getCurrentLocation = () => $location.url();
+ ctrl.notifications = window._clientConfig.announcements;
+ ctrl.hasNotifications = ctrl.notifications.length > 0;
+ };
+ }
+]);
+
+notifications.directive('uiNotifications', [function() {
+ return {
+ restrict: 'E',
+ controller: 'NotificationsCtrl',
+ controllerAs: 'ctrl',
+ bindToController: true,
+ template: template
+ };
+}]);
diff --git a/kahuna/public/js/search/index.js b/kahuna/public/js/search/index.js
index 4dcbb2484a..0a6782c0ef 100644
--- a/kahuna/public/js/search/index.js
+++ b/kahuna/public/js/search/index.js
@@ -85,6 +85,7 @@ search.config(['$stateProvider', '$urlMatcherFactoryProvider',
const ctrl = this;
ctrl.canUpload = false;
+ ctrl.hasNotifications = window._clientConfig.announcements.length > 0;
mediaApi.canUserUpload().then(canUpload => {
ctrl.canUpload = canUpload;
diff --git a/kahuna/public/js/window.ts b/kahuna/public/js/window.ts
index c0a40de958..f015022300 100644
--- a/kahuna/public/js/window.ts
+++ b/kahuna/public/js/window.ts
@@ -1,11 +1,14 @@
import { FeatureSwitchData } from "./components/gr-feature-switch-panel/gr-feature-switch-panel";
+import { Notification } from "./components/gr-notifications-banner/gr-notifications-banner";
declare global {
interface Window {
_clientConfig: {
+ rootUri: string;
telemetryUri: string;
- featureSwitches: Array
+ featureSwitches: Array;
maybeOrgOwnedValue: string | undefined;
+ announcements: Array;
}
}
}
diff --git a/kahuna/public/stylesheets/fonts/opensans-semibold-webfont.woff2 b/kahuna/public/stylesheets/fonts/opensans-semibold-webfont.woff2
new file mode 100644
index 0000000000..85c2c419ee
Binary files /dev/null and b/kahuna/public/stylesheets/fonts/opensans-semibold-webfont.woff2 differ
diff --git a/kahuna/public/stylesheets/fonts/opensans-semibolditalic-webfont.woff2 b/kahuna/public/stylesheets/fonts/opensans-semibolditalic-webfont.woff2
new file mode 100644
index 0000000000..fc2a591e25
Binary files /dev/null and b/kahuna/public/stylesheets/fonts/opensans-semibolditalic-webfont.woff2 differ
diff --git a/kahuna/public/stylesheets/main.css b/kahuna/public/stylesheets/main.css
index da1cd0c2d5..a3ca79deb0 100644
--- a/kahuna/public/stylesheets/main.css
+++ b/kahuna/public/stylesheets/main.css
@@ -305,6 +305,25 @@ z-index
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
+/* latin - medium */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: normal;
+ font-weight: 500;
+ font-stretch: 87.5%;
+ src: url("fonts/opensans-semibold-webfont.woff2") format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+/* latin - italic - medium */
+@font-face {
+ font-family: 'Open Sans';
+ font-style: italic;
+ font-weight: 500;
+ font-stretch: 87.5%;
+ src: url("fonts/opensans-semibolditalic-webfont.woff2") format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+
/* see http://google.github.io/material-design-icons/#icon-font-for-the-web */
@font-face {
font-family: 'Material Icons';
@@ -804,6 +823,19 @@ textarea.ng-invalid {
padding-top: 10px;
}
+/* ================================
+ Notifications
+ ================================ */
+.global-notifications {
+ position: fixed;
+ margin: 0 auto;
+ left: 0;
+ right: 0;
+ top: 87px;
+ z-index: 40;
+ text-align: left;
+ max-width: 100%;
+}
/* ==========================================================================
Errors / status