mirror of
https://github.com/nasa/openmct.git
synced 2024-12-18 20:57:53 +00:00
Merge pull request #2 from WavesJ99/issue-7948-Notification-Manager
Issue 7948 notification manager
This commit is contained in:
commit
39e66ca8f0
@ -58,7 +58,6 @@ import ToolbarRegistry from './ui/registries/ToolbarRegistry.js';
|
||||
import ViewRegistry from './ui/registries/ViewRegistry.js';
|
||||
import ApplicationRouter from './ui/router/ApplicationRouter.js';
|
||||
import Browse from './ui/router/Browse.js';
|
||||
|
||||
/**
|
||||
* Open MCT is an extensible web application for building mission
|
||||
* control user interfaces. This module is itself an instance of
|
||||
@ -274,6 +273,13 @@ export class MCT extends EventEmitter {
|
||||
*/
|
||||
this.annotation = new AnnotationAPI(this);
|
||||
|
||||
/**
|
||||
* MCT's annotation API that enables
|
||||
* Prioritized Notifications
|
||||
* @type {NotificationAPI}
|
||||
*/
|
||||
this.notifications = new NotificationAPI();
|
||||
|
||||
// Plugins that are installed by default
|
||||
this.install(this.plugins.Plot());
|
||||
this.install(this.plugins.TelemetryTable());
|
||||
|
@ -20,69 +20,36 @@
|
||||
* 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.
|
||||
*/
|
||||
import { EventEmitter } from 'eventemitter3';
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import moment from 'moment';
|
||||
|
||||
import NotificationManager from './NotificationManager';
|
||||
|
||||
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.
|
||||
* @extends EventEmitter
|
||||
*/
|
||||
export default class NotificationAPI extends EventEmitter {
|
||||
/**
|
||||
* @constructor
|
||||
*/
|
||||
constructor() {
|
||||
super();
|
||||
/** @type {Notification[]} */
|
||||
this.manager = new NotificationManager();
|
||||
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;
|
||||
this.activeTimeout = 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',
|
||||
autoDismiss: true,
|
||||
options
|
||||
};
|
||||
|
||||
return this._notify(notificationModel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Present an alert to the user.
|
||||
* @param {string} message The message to display to the user.
|
||||
* @param {NotificationOptions} [options] The notification options
|
||||
* @returns {Notification}
|
||||
*/
|
||||
alert(message, options = {}) {
|
||||
const notificationModel = {
|
||||
message: message,
|
||||
@ -93,14 +60,8 @@ export default class NotificationAPI extends EventEmitter {
|
||||
return this._notify(notificationModel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Present an error message to the user
|
||||
* @param {string} message The error message to display
|
||||
* @param {NotificationOptions} [options] The notification options
|
||||
* @returns {Notification}
|
||||
*/
|
||||
error(message, options = {}) {
|
||||
let notificationModel = {
|
||||
const notificationModel = {
|
||||
message: message,
|
||||
severity: 'error',
|
||||
options
|
||||
@ -109,15 +70,8 @@ export default class NotificationAPI extends EventEmitter {
|
||||
return this._notify(notificationModel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new progress notification. These notifications will contain a progress bar.
|
||||
* @param {string} message The message to display
|
||||
* @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").
|
||||
* @returns {Notification}
|
||||
*/
|
||||
progress(message, progressPerc, progressText) {
|
||||
let notificationModel = {
|
||||
const notificationModel = {
|
||||
message: message,
|
||||
progressPerc: progressPerc,
|
||||
progressText: progressText,
|
||||
@ -128,114 +82,104 @@ export default class NotificationAPI extends EventEmitter {
|
||||
return this._notify(notificationModel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss all active notifications.
|
||||
*/
|
||||
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 The notification to minimize
|
||||
*/
|
||||
_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);
|
||||
}
|
||||
createGroup(groupId, options = {}) {
|
||||
return this.manager.createGroup(groupId, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 NotificationAPI#_dismissOrMinimize}.
|
||||
*
|
||||
* @private
|
||||
* @param {Notification | undefined} notification The notification to dismiss
|
||||
*/
|
||||
_dismiss(notification) {
|
||||
if (!notification) {
|
||||
return;
|
||||
}
|
||||
groupedNotification(groupId, message, options = {}) {
|
||||
const notificationModel = {
|
||||
message,
|
||||
groupId,
|
||||
...options
|
||||
};
|
||||
return this._notify(notificationModel);
|
||||
}
|
||||
|
||||
//Check this is a known notification
|
||||
let index = this.notifications.indexOf(notification);
|
||||
registerCategory(category, options = {}) {
|
||||
return this.manager.registerCategory(category, options);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
getActiveNotifications() {
|
||||
return this.notifications.filter((n) => !n.model.minimized);
|
||||
}
|
||||
|
||||
if (index >= 0) {
|
||||
this.notifications.splice(index, 1);
|
||||
}
|
||||
getGroupNotifications(groupId) {
|
||||
return this.manager.getGroupNotifications(groupId);
|
||||
}
|
||||
|
||||
this._setActiveNotification(this._selectNextNotification());
|
||||
dismissGroup(groupId) {
|
||||
const groupNotifications = this.getGroupNotifications(groupId);
|
||||
groupNotifications.forEach((notification) => {
|
||||
const matchingNotification = this.notifications.find(
|
||||
(n) => n.model.message === notification.message
|
||||
);
|
||||
if (matchingNotification) {
|
||||
this._dismiss(matchingNotification);
|
||||
}
|
||||
});
|
||||
this.manager.dismissGroup(groupId);
|
||||
}
|
||||
|
||||
dismissNotification(notification) {
|
||||
this._dismiss(notification);
|
||||
}
|
||||
|
||||
_notify(notificationModel) {
|
||||
const notification = this._createNotification(notificationModel);
|
||||
|
||||
// Add to manager
|
||||
const managerNotification = this.manager.addNotification({
|
||||
...notificationModel,
|
||||
message: notificationModel.message
|
||||
});
|
||||
|
||||
// Ensure model preserves the message and severity
|
||||
notification.model = {
|
||||
...notificationModel,
|
||||
id: managerNotification.id,
|
||||
priority: managerNotification.priority
|
||||
};
|
||||
|
||||
this.notifications.push(notification);
|
||||
this._setHighestSeverity();
|
||||
notification.emit('destroy');
|
||||
}
|
||||
|
||||
/**
|
||||
* Depending on the severity of the notification will selectively
|
||||
* dismiss or minimize where appropriate.
|
||||
*
|
||||
* @private
|
||||
* @param {Notification | undefined} notification The notification to dismiss or minimize
|
||||
*/
|
||||
_dismissOrMinimize(notification) {
|
||||
let model = notification?.model;
|
||||
if (model?.severity === 'info') {
|
||||
this._dismiss(notification);
|
||||
} else {
|
||||
this._minimize(notification);
|
||||
if (!this.activeNotification && !notification.model.options?.minimized) {
|
||||
this._setActiveNotification(notification);
|
||||
} else if (!this.activeTimeout) {
|
||||
const activeNotification = this.activeNotification;
|
||||
this.activeTimeout = setTimeout(() => {
|
||||
this._dismissOrMinimize(activeNotification);
|
||||
}, DEFAULT_AUTO_DISMISS_TIMEOUT);
|
||||
}
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
_createNotification(notificationModel) {
|
||||
const 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the highest severity notification.
|
||||
* @private
|
||||
*/
|
||||
_setHighestSeverity() {
|
||||
@ -255,87 +199,75 @@ export default class NotificationAPI extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @private
|
||||
* @param {NotificationModel} notificationModel The notification to display
|
||||
* @returns {Notification} the provided notification decorated with
|
||||
* functions to dismiss or minimize
|
||||
*/
|
||||
_notify(notificationModel) {
|
||||
let notification;
|
||||
let activeNotification = this.activeNotification;
|
||||
_minimize(notification) {
|
||||
if (!notification) {
|
||||
return;
|
||||
}
|
||||
|
||||
notificationModel.severity = notificationModel.severity || 'info';
|
||||
notificationModel.timestamp = moment.utc().format('YYYY-MM-DD hh:mm:ss.ms');
|
||||
let index = this.notifications.indexOf(notification);
|
||||
|
||||
notification = this._createNotification(notificationModel);
|
||||
if (this.activeTimeout) {
|
||||
clearTimeout(this.activeTimeout);
|
||||
delete this.activeTimeout;
|
||||
}
|
||||
|
||||
this.notifications.push(notification);
|
||||
if (index >= 0) {
|
||||
notification.model.minimized = true;
|
||||
notification.emit('minimized');
|
||||
|
||||
setTimeout(() => {
|
||||
notification.emit('destroy');
|
||||
this._setActiveNotification(this._selectNextNotification());
|
||||
}, MINIMIZE_ANIMATION_TIMEOUT);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_dismiss(notification) {
|
||||
if (!notification) {
|
||||
return;
|
||||
}
|
||||
|
||||
let index = this.notifications.indexOf(notification);
|
||||
|
||||
if (this.activeTimeout) {
|
||||
clearTimeout(this.activeTimeout);
|
||||
delete this.activeTimeout;
|
||||
}
|
||||
|
||||
if (index >= 0) {
|
||||
this.notifications.splice(index, 1);
|
||||
}
|
||||
|
||||
this._setActiveNotification(this._selectNextNotification());
|
||||
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;
|
||||
notification.emit('destroy');
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new notification object.
|
||||
* @private
|
||||
* @param {NotificationModel} notificationModel The model for the notification
|
||||
* @returns {Notification}
|
||||
*/
|
||||
_createNotification(notificationModel) {
|
||||
/** @type {Notification} */
|
||||
let notification = new EventEmitter();
|
||||
notification.model = notificationModel;
|
||||
notification.dismiss = () => {
|
||||
_dismissOrMinimize(notification) {
|
||||
let model = notification?.model;
|
||||
if (model?.severity === 'info') {
|
||||
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);
|
||||
};
|
||||
} else {
|
||||
this._minimize(notification);
|
||||
}
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the active notification.
|
||||
* @private
|
||||
* @param {Notification | undefined} notification The notification to set as active
|
||||
*/
|
||||
_setActiveNotification(notification) {
|
||||
this.activeNotification = notification;
|
||||
|
||||
if (!notification) {
|
||||
delete this.activeTimeout;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@ -353,21 +285,14 @@ export default class NotificationAPI extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the next notification to be displayed.
|
||||
* @private
|
||||
* @returns {Notification | undefined}
|
||||
*/
|
||||
_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;
|
||||
|
||||
@ -377,41 +302,3 @@ export default class NotificationAPI extends EventEmitter {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} NotificationProperties
|
||||
* @property {() => void} 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 {() => void} 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.
|
||||
* @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 'unknown'.
|
||||
* @property {string} [progressText] A message conveying progress of some ongoing task.
|
||||
* @property {'info' | 'alert' | 'error'} [severity] The severity of the notification.
|
||||
* @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
|
||||
*/
|
||||
|
@ -1,3 +1,4 @@
|
||||
/* eslint-disable no-unused-vars */
|
||||
/*****************************************************************************
|
||||
* Open MCT, Copyright (c) 2014-2024, United States Government
|
||||
* as represented by the Administrator of the National Aeronautics and Space
|
||||
@ -20,14 +21,14 @@
|
||||
* at runtime from the About dialog for additional information.
|
||||
*****************************************************************************/
|
||||
|
||||
import NotificationAPI from './NotificationAPI.js';
|
||||
import NotificationAPI from './NotificationAPI';
|
||||
|
||||
describe('The Notification API', () => {
|
||||
let notificationAPIInstance;
|
||||
let notificationAPI;
|
||||
let defaultTimeout = 4000;
|
||||
|
||||
beforeAll(() => {
|
||||
notificationAPIInstance = new NotificationAPI();
|
||||
beforeEach(() => {
|
||||
notificationAPI = new NotificationAPI();
|
||||
});
|
||||
|
||||
describe('the info method', () => {
|
||||
@ -35,12 +36,13 @@ describe('The Notification API', () => {
|
||||
let severity = 'info';
|
||||
let notificationModel;
|
||||
|
||||
beforeAll(() => {
|
||||
notificationModel = notificationAPIInstance.info(message).model;
|
||||
beforeEach(() => {
|
||||
const notification = notificationAPI.info(message);
|
||||
notificationModel = notification.model;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
notificationAPIInstance.dismissAllNotifications();
|
||||
afterEach(() => {
|
||||
notificationAPI.dismissAllNotifications();
|
||||
});
|
||||
|
||||
it('shows a string message with info severity', () => {
|
||||
@ -49,124 +51,72 @@ describe('The Notification API', () => {
|
||||
});
|
||||
|
||||
it('auto dismisses the notification after a brief timeout', (done) => {
|
||||
window.setTimeout(() => {
|
||||
expect(notificationAPIInstance.notifications.length).toEqual(0);
|
||||
setTimeout(() => {
|
||||
const activeNotifications = notificationAPI.getActiveNotifications();
|
||||
expect(activeNotifications.length).toEqual(0);
|
||||
done();
|
||||
}, defaultTimeout);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the alert method', () => {
|
||||
let message = 'Example alert message';
|
||||
let severity = 'alert';
|
||||
let notificationModel;
|
||||
describe('notification grouping', () => {
|
||||
let groupId = 'test-group';
|
||||
|
||||
beforeAll(() => {
|
||||
notificationModel = notificationAPIInstance.alert(message).model;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
notificationAPIInstance.dismissAllNotifications();
|
||||
});
|
||||
|
||||
it('shows a string message, with alert severity', () => {
|
||||
expect(notificationModel.message).toEqual(message);
|
||||
expect(notificationModel.severity).toEqual(severity);
|
||||
});
|
||||
|
||||
it('does not auto dismiss the notification', (done) => {
|
||||
window.setTimeout(() => {
|
||||
expect(notificationAPIInstance.notifications.length).toEqual(1);
|
||||
done();
|
||||
}, defaultTimeout);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the error method', () => {
|
||||
let message = 'Example error message';
|
||||
let severity = 'error';
|
||||
let notificationModel;
|
||||
|
||||
beforeAll(() => {
|
||||
notificationModel = notificationAPIInstance.error(message).model;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
notificationAPIInstance.dismissAllNotifications();
|
||||
});
|
||||
|
||||
it('shows a string message, with severity error', () => {
|
||||
expect(notificationModel.message).toEqual(message);
|
||||
expect(notificationModel.severity).toEqual(severity);
|
||||
});
|
||||
|
||||
it('does not auto dismiss the notification', (done) => {
|
||||
window.setTimeout(() => {
|
||||
expect(notificationAPIInstance.notifications.length).toEqual(1);
|
||||
done();
|
||||
}, defaultTimeout);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the error method notification', () => {
|
||||
let message = 'Minimized error message';
|
||||
|
||||
afterAll(() => {
|
||||
notificationAPIInstance.dismissAllNotifications();
|
||||
});
|
||||
|
||||
it('is not shown if configured to show minimized', (done) => {
|
||||
notificationAPIInstance.activeNotification = undefined;
|
||||
notificationAPIInstance.error(message, { minimized: true });
|
||||
window.setTimeout(() => {
|
||||
expect(notificationAPIInstance.notifications.length).toEqual(1);
|
||||
expect(notificationAPIInstance.activeNotification).toEqual(undefined);
|
||||
done();
|
||||
}, defaultTimeout);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the progress method', () => {
|
||||
let title = 'This is a progress notification';
|
||||
let message1 = 'Example progress message 1';
|
||||
let message2 = 'Example progress message 2';
|
||||
let percentage1 = 50;
|
||||
let percentage2 = 99.9;
|
||||
let severity = 'info';
|
||||
let notification;
|
||||
let updatedPercentage;
|
||||
let updatedMessage;
|
||||
|
||||
beforeAll(() => {
|
||||
notification = notificationAPIInstance.progress(title, percentage1, message1);
|
||||
notification.on('progress', (percentage, text) => {
|
||||
updatedPercentage = percentage;
|
||||
updatedMessage = text;
|
||||
beforeEach(() => {
|
||||
notificationAPI.createGroup(groupId, {
|
||||
title: 'Test Group'
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
notificationAPIInstance.dismissAllNotifications();
|
||||
it('creates notification groups', () => {
|
||||
expect(() => {
|
||||
notificationAPI.getGroupNotifications(groupId);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('shows a notification with a message, progress message, percentage and info severity', () => {
|
||||
expect(notification.model.message).toEqual(title);
|
||||
expect(notification.model.severity).toEqual(severity);
|
||||
expect(notification.model.progressText).toEqual(message1);
|
||||
expect(notification.model.progressPerc).toEqual(percentage1);
|
||||
it('adds notifications to groups', () => {
|
||||
const notification = notificationAPI.groupedNotification(groupId, 'Test message', {
|
||||
severity: 'info'
|
||||
});
|
||||
const groupNotifications = notificationAPI.getGroupNotifications(groupId);
|
||||
|
||||
expect(groupNotifications.some((n) => n.message === 'Test message')).toBe(true);
|
||||
});
|
||||
|
||||
it('allows dynamically updating the progress attributes', () => {
|
||||
notification.progress(percentage2, message2);
|
||||
it('dismisses groups of notifications', () => {
|
||||
notificationAPI.groupedNotification(groupId, 'Test 1', { severity: 'info' });
|
||||
notificationAPI.groupedNotification(groupId, 'Test 2', { severity: 'info' });
|
||||
|
||||
expect(updatedPercentage).toEqual(percentage2);
|
||||
expect(updatedMessage).toEqual(message2);
|
||||
notificationAPI.dismissGroup(groupId);
|
||||
const activeNotifications = notificationAPI.getActiveNotifications();
|
||||
|
||||
expect(activeNotifications.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('allows dynamically dismissing of progress notification', () => {
|
||||
notification.dismiss();
|
||||
describe('notification categories', () => {
|
||||
it('creates notifications with custom categories', () => {
|
||||
notificationAPI.registerCategory('custom');
|
||||
const notification = notificationAPI.info('Test message', {
|
||||
category: 'custom'
|
||||
});
|
||||
|
||||
expect(notificationAPIInstance.notifications.length).toEqual(0);
|
||||
expect(notification.model.options.category).toBe('custom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('notification management', () => {
|
||||
it('preserves persistent notifications', () => {
|
||||
const notification = notificationAPI.alert('Test', {
|
||||
persistent: true
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
notificationAPI.dismissNotification(notification);
|
||||
}).not.toThrow();
|
||||
|
||||
const activeNotifications = notificationAPI.getActiveNotifications();
|
||||
expect(activeNotifications.length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
124
src/api/notifications/NotificationManager.js
Normal file
124
src/api/notifications/NotificationManager.js
Normal file
@ -0,0 +1,124 @@
|
||||
/*****************************************************************************
|
||||
* Open MCT, Copyright (c) 2014-2024, United States Government
|
||||
* as represented by the Administrator of the National Aeronautics and Space
|
||||
* Administration. All rights reserved.
|
||||
*****************************************************************************/
|
||||
|
||||
export default class NotificationManager {
|
||||
constructor() {
|
||||
this.notifications = new Map(); // Key: notificationId, Value: notification
|
||||
this.categories = new Set(['info', 'alert', 'error', 'progress']); // Default categories
|
||||
this.groups = new Map(); // For grouping related notifications
|
||||
this.persistentNotifications = new Set(); // For notifications that should persist
|
||||
}
|
||||
|
||||
// Allow registering custom notification categories
|
||||
registerCategory(category, options = {}) {
|
||||
if (this.categories.has(category)) {
|
||||
throw new Error(`Category ${category} already exists`);
|
||||
}
|
||||
this.categories.add(category);
|
||||
}
|
||||
|
||||
// Create a notification group
|
||||
createGroup(groupId, options = {}) {
|
||||
if (this.groups.has(groupId)) {
|
||||
throw new Error(`Group ${groupId} already exists`);
|
||||
}
|
||||
this.groups.set(groupId, {
|
||||
notifications: new Set(),
|
||||
...options
|
||||
});
|
||||
}
|
||||
|
||||
// Add a notification to the system
|
||||
addNotification(notification) {
|
||||
const id = Math.random().toString(36).substr(2, 9); // Simple ID generation
|
||||
const timestamp = Date.now();
|
||||
|
||||
const enrichedNotification = {
|
||||
...notification,
|
||||
id,
|
||||
timestamp,
|
||||
status: 'active',
|
||||
priority: this._calculatePriority(notification)
|
||||
};
|
||||
|
||||
this.notifications.set(id, enrichedNotification);
|
||||
|
||||
if (notification.groupId && this.groups.has(notification.groupId)) {
|
||||
this.groups.get(notification.groupId).notifications.add(id);
|
||||
}
|
||||
|
||||
if (notification.persistent) {
|
||||
this.persistentNotifications.add(id);
|
||||
}
|
||||
|
||||
return enrichedNotification;
|
||||
}
|
||||
|
||||
_calculatePriority(notification) {
|
||||
let priority = 0;
|
||||
|
||||
const severityWeights = {
|
||||
error: 100,
|
||||
alert: 50,
|
||||
info: 10
|
||||
};
|
||||
priority += severityWeights[notification.severity] || 0;
|
||||
|
||||
if (notification.persistent) {
|
||||
priority += 20;
|
||||
}
|
||||
if (notification.groupId) {
|
||||
priority += 10;
|
||||
}
|
||||
if (notification.category === 'system') {
|
||||
priority += 30;
|
||||
}
|
||||
|
||||
return priority;
|
||||
}
|
||||
|
||||
getActiveNotifications() {
|
||||
return Array.from(this.notifications.values())
|
||||
.filter((n) => n.status === 'active')
|
||||
.sort((a, b) => b.priority - a.priority);
|
||||
}
|
||||
|
||||
getGroupNotifications(groupId) {
|
||||
const group = this.groups.get(groupId);
|
||||
if (!group) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(group.notifications)
|
||||
.map((id) => this.notifications.get(id))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
dismissNotification(id) {
|
||||
const notification = this.notifications.get(id);
|
||||
if (!notification) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.persistentNotifications.has(id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
notification.status = 'dismissed';
|
||||
return true;
|
||||
}
|
||||
|
||||
dismissGroup(groupId) {
|
||||
const group = this.groups.get(groupId);
|
||||
if (!group) {
|
||||
return;
|
||||
}
|
||||
|
||||
group.notifications.forEach((id) => {
|
||||
this.dismissNotification(id);
|
||||
});
|
||||
}
|
||||
}
|
175
src/api/notifications/NotificationManagerSpec.js
Normal file
175
src/api/notifications/NotificationManagerSpec.js
Normal file
@ -0,0 +1,175 @@
|
||||
/*****************************************************************************
|
||||
* 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.
|
||||
*****************************************************************************/
|
||||
|
||||
/*****************************************************************************
|
||||
* Open MCT, Copyright (c) 2014-2024, United States Government
|
||||
* as represented by the Administrator of the National Aeronautics and Space
|
||||
* Administration. All rights reserved.
|
||||
*****************************************************************************/
|
||||
import NotificationManager from './NotificationManager';
|
||||
|
||||
describe('NotificationManager', () => {
|
||||
let notificationManager;
|
||||
|
||||
beforeEach(() => {
|
||||
notificationManager = new NotificationManager();
|
||||
});
|
||||
|
||||
describe('category management', () => {
|
||||
it('initializes with default categories', () => {
|
||||
expect(notificationManager.categories.has('info')).toBe(true);
|
||||
expect(notificationManager.categories.has('alert')).toBe(true);
|
||||
expect(notificationManager.categories.has('error')).toBe(true);
|
||||
expect(notificationManager.categories.has('progress')).toBe(true);
|
||||
});
|
||||
|
||||
it('allows registering new categories', () => {
|
||||
notificationManager.registerCategory('custom');
|
||||
expect(notificationManager.categories.has('custom')).toBe(true);
|
||||
});
|
||||
|
||||
it('prevents registering duplicate categories', () => {
|
||||
expect(() => {
|
||||
notificationManager.registerCategory('info');
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('notification grouping', () => {
|
||||
it('creates notification groups', () => {
|
||||
notificationManager.createGroup('test-group');
|
||||
expect(notificationManager.groups.has('test-group')).toBe(true);
|
||||
});
|
||||
|
||||
it('prevents creating duplicate groups', () => {
|
||||
notificationManager.createGroup('test-group');
|
||||
expect(() => {
|
||||
notificationManager.createGroup('test-group');
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('adds notifications to groups', () => {
|
||||
notificationManager.createGroup('test-group');
|
||||
const notification = notificationManager.addNotification({
|
||||
message: 'test',
|
||||
severity: 'info',
|
||||
groupId: 'test-group'
|
||||
});
|
||||
|
||||
const groupNotifications = notificationManager.getGroupNotifications('test-group');
|
||||
expect(groupNotifications).toContain(
|
||||
jasmine.objectContaining({
|
||||
id: notification.id
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('notification management', () => {
|
||||
it('adds notifications with required properties', () => {
|
||||
const notification = notificationManager.addNotification({
|
||||
message: 'test',
|
||||
severity: 'info'
|
||||
});
|
||||
|
||||
expect(notification.id).toBeDefined();
|
||||
expect(notification.timestamp).toBeDefined();
|
||||
expect(notification.status).toBe('active');
|
||||
expect(notification.priority).toBeDefined();
|
||||
});
|
||||
|
||||
it('calculates priorities correctly', () => {
|
||||
const errorNotification = notificationManager.addNotification({
|
||||
message: 'error',
|
||||
severity: 'error'
|
||||
});
|
||||
|
||||
const infoNotification = notificationManager.addNotification({
|
||||
message: 'info',
|
||||
severity: 'info'
|
||||
});
|
||||
|
||||
expect(errorNotification.priority).toBeGreaterThan(infoNotification.priority);
|
||||
});
|
||||
|
||||
it('handles persistent notifications', () => {
|
||||
const notification = notificationManager.addNotification({
|
||||
message: 'test',
|
||||
severity: 'info',
|
||||
persistent: true
|
||||
});
|
||||
|
||||
expect(notificationManager.persistentNotifications.has(notification.id)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('notification retrieval', () => {
|
||||
beforeEach(() => {
|
||||
notificationManager.addNotification({
|
||||
message: 'test1',
|
||||
severity: 'error'
|
||||
});
|
||||
notificationManager.addNotification({
|
||||
message: 'test2',
|
||||
severity: 'info'
|
||||
});
|
||||
});
|
||||
|
||||
it('retrieves active notifications sorted by priority', () => {
|
||||
const notifications = notificationManager.getActiveNotifications();
|
||||
expect(notifications.length).toBe(2);
|
||||
expect(notifications[0].severity).toBe('error');
|
||||
expect(notifications[1].severity).toBe('info');
|
||||
});
|
||||
});
|
||||
|
||||
describe('notification dismissal', () => {
|
||||
let notification;
|
||||
|
||||
beforeEach(() => {
|
||||
notification = notificationManager.addNotification({
|
||||
message: 'test',
|
||||
severity: 'info'
|
||||
});
|
||||
});
|
||||
|
||||
it('dismisses non-persistent notifications', () => {
|
||||
const result = notificationManager.dismissNotification(notification.id);
|
||||
expect(result).toBe(true);
|
||||
expect(notificationManager.notifications.get(notification.id).status).toBe('dismissed');
|
||||
});
|
||||
|
||||
it('prevents dismissing persistent notifications', () => {
|
||||
const persistentNotification = notificationManager.addNotification({
|
||||
message: 'test',
|
||||
severity: 'info',
|
||||
persistent: true
|
||||
});
|
||||
|
||||
const result = notificationManager.dismissNotification(persistentNotification.id);
|
||||
expect(result).toBe(false);
|
||||
expect(notificationManager.notifications.get(persistentNotification.id).status).toBe(
|
||||
'active'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user