/***************************************************************************** * Open MCT, Copyright (c) 2014-2024, United States Government * as represented by the Administrator of the National Aeronautics and Space * Administration. All rights reserved. * * Open MCT is licensed under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance with the License. * You may obtain a copy of the License at * http://www.apache.org/licenses/LICENSE-2.0. * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations * under the License. * * Open MCT includes source code licensed under additional open source * licenses. See the Open Source Licenses file (LICENSES.md) included with * this source code distribution or the Licensing information page available * at runtime from the About dialog for additional information. *****************************************************************************/ /** * This bundle implements the notification service, which can be used to * show banner notifications to the user. Banner notifications * are used to inform users of events in a non-intrusive way. As * much as possible, notifications share a model with blocking * dialogs so that the same information can be provided in a dialog * and then minimized to a banner notification if needed. * * @namespace platform/api/notifications */ import EventEmitter from 'eventemitter3'; import moment from 'moment'; /** * @typedef {object} NotificationProperties * @property {function} dismiss Dismiss the notification * @property {NotificationModel} model The Notification model * @property {(progressPerc: number, progressText: string) => void} [progress] Update the progress of the notification */ /** * @typedef {EventEmitter & NotificationProperties} Notification */ /** * @typedef {object} NotificationLink * @property {function} onClick The function to be called when the link is clicked * @property {string} cssClass A CSS class name to style the link * @property {string} text The text to be displayed for the link */ /** * @typedef {object} NotificationOptions * @property {number} [autoDismissTimeout] Milliseconds to wait before automatically dismissing the notification * @property {boolean} [minimized] Allows for a notification to be minimized into the indicator by default * @property {NotificationLink} [link] A link for the notification */ /** * A representation of a banner notification. Banner notifications * are used to inform users of events in a non-intrusive way. As * much as possible, notifications share a model with blocking * dialogs so that the same information can be provided in a dialog * and then minimized to a banner notification if needed, or vice-versa. * * @see DialogModel * @typedef {object} NotificationModel * @property {string} message The message to be displayed by the notification * @property {number | 'unknown'} [progress] The progress of some ongoing task. Should be a number between 0 and 100, or * with the string literal 'unknown'. * @property {string} [progressText] A message conveying progress of some ongoing task. * @property {string} [severity] The severity of the notification. Should be one of 'info', 'alert', or 'error'. * @property {string} [timestamp] The time at which the notification was created. Should be a string in ISO 8601 format. * @property {boolean} [minimized] Whether or not the notification has been minimized * @property {boolean} [autoDismiss] Whether the notification should be automatically dismissed after a short period of time. * @property {NotificationOptions} options The notification options */ const DEFAULT_AUTO_DISMISS_TIMEOUT = 3000; const MINIMIZE_ANIMATION_TIMEOUT = 300; /** * The notification service is responsible for informing the user of * events via the use of banner notifications. */ export default class NotificationAPI extends EventEmitter { constructor() { super(); /** @type {Notification[]} */ this.notifications = []; /** @type {{severity: "info" | "alert" | "error"}} */ this.highest = { severity: 'info' }; /** * A context in which to hold the active notification and a * handle to its timeout. * @type {Notification | undefined} */ this.activeNotification = undefined; } /** * Info notifications are low priority informational messages for the user. They will be auto-destroy after a brief * period of time. * @param {string} message The message to display to the user * @param {NotificationOptions} [options] The notification options * @returns {Notification} */ info(message, options = {}) { /** @type {NotificationModel} */ const notificationModel = { message: message, autoDismiss: true, severity: 'info', options }; return this._notify(notificationModel); } /** * Present an alert to the user. * @param {string} message The message to display to the user. * @param {NotificationOptions} [options] object with following properties * autoDismissTimeout: {number} in milliseconds to automatically dismisses notification * link: {Object} Add a link to notifications for navigation * onClick: callback function * cssClass: css class name to add style on link * text: text to display for link * @returns {Notification} */ alert(message, options = {}) { const notificationModel = { message: message, severity: 'alert', options }; return this._notify(notificationModel); } /** * Present an error message to the user * @param {string} message * @param {Object} [options] object with following properties * autoDismissTimeout: {number} in milliseconds to automatically dismisses notification * link: {Object} Add a link to notifications for navigation * onClick: callback function * cssClass: css class name to add style on link * text: text to display for link * @returns {Notification} */ error(message, options = {}) { let notificationModel = { message: message, severity: 'error', options }; return this._notify(notificationModel); } /** * Create a new progress notification. These notifications will contain a progress bar. * @param {string} message * @param {number | null} progressPerc A value between 0 and 100, or null. * @param {string} [progressText] Text description of progress (eg. "10 of 20 objects copied"). */ progress(message, progressPerc, progressText) { let notificationModel = { message: message, progressPerc: progressPerc, progressText: progressText, severity: 'info', options: {} }; return this._notify(notificationModel); } dismissAllNotifications() { this.notifications = []; this.emit('dismiss-all'); } /** * Minimize a notification. The notification will still be available * from the notification list. Typically notifications with a * severity of 'info' should not be minimized, but rather * dismissed. * * @private * @param {Notification | undefined} notification */ _minimize(notification) { if (!notification) { return; } //Check this is a known notification let index = this.notifications.indexOf(notification); if (this.activeTimeout) { /* Method can be called manually (clicking dismiss) or automatically from an auto-timeout. this.activeTimeout acts as a semaphore to prevent race conditions. Cancel any timeout in progress (for the case where a manual dismiss has shortcut an active auto-dismiss), and clear the semaphore. */ clearTimeout(this.activeTimeout); delete this.activeTimeout; } if (index >= 0) { notification.model.minimized = true; notification.emit('minimized'); //Add a brief timeout before showing the next notification // in order to allow the minimize animation to run through. setTimeout(() => { notification.emit('destroy'); this._setActiveNotification(this._selectNextNotification()); }, MINIMIZE_ANIMATION_TIMEOUT); } } /** * Completely removes a notification. This will dismiss it from the * message banner and remove it from the list of notifications. * Typically only notifications with a severity of info should be * dismissed. If you're not sure whether to dismiss or minimize a * notification, use {@link Notification#dismissOrMinimize}. * dismiss * * @private * @param {Notification | undefined} notification */ _dismiss(notification) { if (!notification) { return; } //Check this is a known notification let index = this.notifications.indexOf(notification); if (this.activeTimeout) { /* Method can be called manually (clicking dismiss) or * automatically from an auto-timeout. this.activeTimeout * acts as a semaphore to prevent race conditions. Cancel any * timeout in progress (for the case where a manual dismiss * has shortcut an active auto-dismiss), and clear the * semaphore. */ clearTimeout(this.activeTimeout); delete this.activeTimeout; } if (index >= 0) { this.notifications.splice(index, 1); } this._setActiveNotification(this._selectNextNotification()); this._setHighestSeverity(); notification.emit('destroy'); } /** * Depending on the severity of the notification will selectively * dismiss or minimize where appropriate. * * @private * @param {Notification | undefined} notification */ _dismissOrMinimize(notification) { let model = notification?.model; if (model?.severity === 'info') { this._dismiss(notification); } else { this._minimize(notification); } } /** * @private */ _setHighestSeverity() { let severity = { info: 1, alert: 2, error: 3 }; this.highest.severity = this.notifications.reduce((previous, notification) => { if (severity[notification.model.severity] > severity[previous]) { return notification.model.severity; } else { return previous; } }, 'info'); } /** * Notifies the user of an event. If there is a banner notification * already active, then it will be dismissed or minimized automatically, * and the provided notification displayed in its place. * * @param {NotificationModel} notificationModel The notification to * display * @returns {Notification} the provided notification decorated with * functions to {@link Notification#dismiss} or {@link Notification#minimize} */ _notify(notificationModel) { let notification; let activeNotification = this.activeNotification; notificationModel.severity = notificationModel.severity || 'info'; notificationModel.timestamp = moment.utc().format('YYYY-MM-DD hh:mm:ss.ms'); notification = this._createNotification(notificationModel); this.notifications.push(notification); this._setHighestSeverity(); /* Check if there is already an active (ie. visible) notification */ if (!this.activeNotification && !notification?.model?.options?.minimized) { this._setActiveNotification(notification); } else if (!this.activeTimeout) { /* If there is already an active notification, time it out. If it's already got a timeout in progress (either because it has had timeout forced because of a queue of messages, or it had an autodismiss specified), leave it to run. Otherwise force a timeout. This notification has been added to queue and will be serviced as soon as possible. */ this.activeTimeout = setTimeout(() => { this._dismissOrMinimize(activeNotification); }, DEFAULT_AUTO_DISMISS_TIMEOUT); } return notification; } /** * @private * @param {NotificationModel} notificationModel * @returns {Notification} */ _createNotification(notificationModel) { /** @type {Notification} */ let notification = new EventEmitter(); notification.model = notificationModel; notification.dismiss = () => { this._dismiss(notification); }; if (Object.prototype.hasOwnProperty.call(notificationModel, 'progressPerc')) { notification.progress = (progressPerc, progressText) => { notification.model.progressPerc = progressPerc; notification.model.progressText = progressText; notification.emit('progress', progressPerc, progressText); }; } return notification; } /** * @private * @param {Notification | undefined} notification */ _setActiveNotification(notification) { this.activeNotification = notification; if (!notification) { delete this.activeTimeout; return; } this.emit('notification', notification); if (notification.model.autoDismiss || this._selectNextNotification()) { const autoDismissTimeout = notification.model.options.autoDismissTimeout || DEFAULT_AUTO_DISMISS_TIMEOUT; this.activeTimeout = setTimeout(() => { this._dismissOrMinimize(notification); }, autoDismissTimeout); } else { delete this.activeTimeout; } } /** * Used internally by the NotificationService * * @private */ _selectNextNotification() { let notification; let i = 0; /* Loop through the notifications queue and find the first one that has not already been minimized (manually or otherwise). */ for (; i < this.notifications.length; i++) { notification = this.notifications[i]; const isNotificationMinimized = notification.model.minimized || notification?.model?.options?.minimized; if (!isNotificationMinimized && notification !== this.activeNotification) { return notification; } } } }