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)} +
+
+
+
+ + {notification.description}  + + {(notification.url != "") && + +   + + {notification.urlText ? notification.urlText : DEFAULT_URL_TEXT} + + + } +
+
+
+
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