diff --git a/e2e/appActions.js b/e2e/appActions.js index 2464d2df44..50e56edbf0 100644 --- a/e2e/appActions.js +++ b/e2e/appActions.js @@ -45,6 +45,14 @@ * @property {string} url the relative url to the object (for use with `page.goto()`) */ +/** + * Defines parameters to be used in the creation of a notification. + * @typedef {Object} CreateNotificationOptions + * @property {string} message the message + * @property {'info' | 'alert' | 'error'} severity the severity + * @property {import('../src/api/notifications/NotificationAPI').NotificationOptions} [notificationOptions] additional options + */ + const Buffer = require('buffer').Buffer; const genUuid = require('uuid').v4; @@ -112,6 +120,25 @@ async function createDomainObjectWithDefaults(page, { type, name, parent = 'mine }; } +/** + * Generate a notification with the given options. + * @param {import('@playwright/test').Page} page + * @param {CreateNotificationOptions} createNotificationOptions + */ +async function createNotification(page, createNotificationOptions) { + await page.evaluate((_createNotificationOptions) => { + const { message, severity, options } = _createNotificationOptions; + const notificationApi = window.openmct.notifications; + if (severity === 'info') { + notificationApi.info(message, options); + } else if (severity === 'alert') { + notificationApi.alert(message, options); + } else { + notificationApi.error(message, options); + } + }, createNotificationOptions); +} + /** * @param {import('@playwright/test').Page} page * @param {string} name @@ -333,6 +360,7 @@ async function setEndOffset(page, offset) { // eslint-disable-next-line no-undef module.exports = { createDomainObjectWithDefaults, + createNotification, expandTreePaneItemByName, createPlanFromJSON, openObjectTreeContextMenu, diff --git a/e2e/tests/framework/appActions.e2e.spec.js b/e2e/tests/framework/appActions.e2e.spec.js index cb8772e274..b83f017bda 100644 --- a/e2e/tests/framework/appActions.e2e.spec.js +++ b/e2e/tests/framework/appActions.e2e.spec.js @@ -21,7 +21,7 @@ *****************************************************************************/ const { test, expect } = require('../../pluginFixtures.js'); -const { createDomainObjectWithDefaults } = require('../../appActions.js'); +const { createDomainObjectWithDefaults, createNotification } = require('../../appActions.js'); test.describe('AppActions', () => { test('createDomainObjectsWithDefaults', async ({ page }) => { @@ -85,4 +85,28 @@ test.describe('AppActions', () => { expect(folder3.url).toBe(`${e2eFolder.url}/${folder1.uuid}/${folder2.uuid}/${folder3.uuid}`); }); }); + test("createNotification", async ({ page }) => { + await page.goto('./', { waitUntil: 'networkidle' }); + await createNotification(page, { + message: 'Test info notification', + severity: 'info' + }); + await expect(page.locator('.c-message-banner__message')).toHaveText('Test info notification'); + await expect(page.locator('.c-message-banner')).toHaveClass(/info/); + await page.locator('[aria-label="Dismiss"]').click(); + await createNotification(page, { + message: 'Test alert notification', + severity: 'alert' + }); + await expect(page.locator('.c-message-banner__message')).toHaveText('Test alert notification'); + await expect(page.locator('.c-message-banner')).toHaveClass(/alert/); + await page.locator('[aria-label="Dismiss"]').click(); + await createNotification(page, { + message: 'Test error notification', + severity: 'error' + }); + await expect(page.locator('.c-message-banner__message')).toHaveText('Test error notification'); + await expect(page.locator('.c-message-banner')).toHaveClass(/error/); + await page.locator('[aria-label="Dismiss"]').click(); + }); }); diff --git a/e2e/tests/functional/notification.e2e.spec.js b/e2e/tests/functional/notification.e2e.spec.js new file mode 100644 index 0000000000..39b7dd68a1 --- /dev/null +++ b/e2e/tests/functional/notification.e2e.spec.js @@ -0,0 +1,39 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2023, 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 test suite is dedicated to tests which verify Open MCT's Notification functionality +*/ + +// FIXME: Remove this eslint exception once tests are implemented +// eslint-disable-next-line no-unused-vars +const { test, expect } = require('../../pluginFixtures'); + +test.describe('Notifications List', () => { + test.fixme('Notifications can be dismissed individually', async ({ page }) => { + // Create some persistent notifications + // Verify that they are present in the notifications list + // Dismiss one of the notifications + // Verify that it is no longer present in the notifications list + // Verify that the other notifications are still present in the notifications list + }); +}); diff --git a/e2e/tests/visual/notification.visual.spec.js b/e2e/tests/visual/notification.visual.spec.js new file mode 100644 index 0000000000..2f6a22bcb2 --- /dev/null +++ b/e2e/tests/visual/notification.visual.spec.js @@ -0,0 +1,58 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2023, 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 test is dedicated to test notification banner functionality and its accessibility attributes. + */ + +const { test, expect } = require('../../pluginFixtures'); +const percySnapshot = require('@percy/playwright'); +const { createDomainObjectWithDefaults } = require('../../appActions'); + +test.describe('Visual - Check Notification Info Banner of \'Save successful\'', () => { + test.beforeEach(async ({ page }) => { + // Go to baseURL and Hide Tree + await page.goto('./', { waitUntil: 'networkidle' }); + }); + + test('Create a clock, click on \'Save successful\' banner and dismiss it', async ({ page }) => { + // Create a clock domain object + await createDomainObjectWithDefaults(page, { type: 'Clock' }); + // Verify there is a button with aria-label="Review 1 Notification" + expect(await page.locator('button[aria-label="Review 1 Notification"]').isVisible()).toBe(true); + // Verify there is a button with aria-label="Clear all notifications" + expect(await page.locator('button[aria-label="Clear all notifications"]').isVisible()).toBe(true); + // Click on the div with role="alert" that has "Save successful" text + await page.locator('div[role="alert"]:has-text("Save successful")').click(); + // Verify there is a div with role="dialog" + expect(await page.locator('div[role="dialog"]').isVisible()).toBe(true); + // Verify the div with role="dialog" contains text "Save successful" + expect(await page.locator('div[role="dialog"]').innerText()).toContain('Save successful'); + await percySnapshot(page, 'Notification banner'); + // Verify there is a button with text "Dismiss" + expect(await page.locator('button:has-text("Dismiss")').isVisible()).toBe(true); + // Click on button with text "Dismiss" + await page.locator('button:has-text("Dismiss")').click(); + // Verify there is no div with role="dialog" + expect(await page.locator('div[role="dialog"]').isVisible()).toBe(false); + }); +}); diff --git a/package.json b/package.json index 8e90620339..0a923e8084 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "@percy/cli": "1.16.0", "@percy/playwright": "1.0.4", "@playwright/test": "1.29.0", + "@types/eventemitter3": "1.2.0", "@types/jasmine": "4.3.1", "@types/lodash": "4.14.191", "babel-loader": "9.1.0", diff --git a/src/api/notifications/NotificationAPI.js b/src/api/notifications/NotificationAPI.js index 5319e2cb97..de13eeda70 100644 --- a/src/api/notifications/NotificationAPI.js +++ b/src/api/notifications/NotificationAPI.js @@ -31,7 +31,31 @@ * @namespace platform/api/notifications */ import moment from 'moment'; -import EventEmitter from 'EventEmitter'; +import EventEmitter from 'eventemitter3'; + +/** + * @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 {NotificationLink} [link] A link for the notification + */ /** * A representation of a banner notification. Banner notifications @@ -40,13 +64,17 @@ import EventEmitter from 'EventEmitter'; * 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. - - * @see DialogModel + * @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; @@ -55,18 +83,19 @@ const MINIMIZE_ANIMATION_TIMEOUT = 300; /** * The notification service is responsible for informing the user of * events via the use of banner notifications. - * @memberof ui/notification - * @constructor */ - +*/ 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; } @@ -75,16 +104,12 @@ export default class NotificationAPI extends EventEmitter { * 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 {Object} [options] object with following properties - * autoDismissTimeout: {number} in miliseconds 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 {InfoNotification} + * @param {NotificationOptions} [options] The notification options + * @returns {Notification} */ info(message, options = {}) { - let notificationModel = { + /** @type {NotificationModel} */ + const notificationModel = { message: message, autoDismiss: true, severity: "info", @@ -97,7 +122,7 @@ export default class NotificationAPI extends EventEmitter { /** * Present an alert to the user. * @param {string} message The message to display to the user. - * @param {Object} [options] object with following properties + * @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 @@ -106,7 +131,7 @@ export default class NotificationAPI extends EventEmitter { * @returns {Notification} */ alert(message, options = {}) { - let notificationModel = { + const notificationModel = { message: message, severity: "alert", options @@ -147,7 +172,8 @@ export default class NotificationAPI extends EventEmitter { message: message, progressPerc: progressPerc, progressText: progressText, - severity: "info" + severity: "info", + options: {} }; return this._notify(notificationModel); @@ -165,8 +191,13 @@ export default class NotificationAPI extends EventEmitter { * dismissed. * * @private + * @param {Notification | undefined} notification */ _minimize(notification) { + if (!notification) { + return; + } + //Check this is a known notification let index = this.notifications.indexOf(notification); @@ -204,8 +235,13 @@ export default class NotificationAPI extends EventEmitter { * dismiss * * @private + * @param {Notification | undefined} notification */ _dismiss(notification) { + if (!notification) { + return; + } + //Check this is a known notification let index = this.notifications.indexOf(notification); @@ -236,10 +272,11 @@ export default class NotificationAPI extends EventEmitter { * dismiss or minimize where appropriate. * * @private + * @param {Notification | undefined} notification */ _dismissOrMinimize(notification) { - let model = notification.model; - if (model.severity === "info") { + let model = notification?.model; + if (model?.severity === "info") { this._dismiss(notification); } else { this._minimize(notification); @@ -251,10 +288,11 @@ export default class NotificationAPI extends EventEmitter { */ _setHighestSeverity() { let severity = { - "info": 1, - "alert": 2, - "error": 3 + 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; @@ -312,8 +350,11 @@ export default class NotificationAPI extends EventEmitter { /** * @private + * @param {NotificationModel} notificationModel + * @returns {Notification} */ _createNotification(notificationModel) { + /** @type {Notification} */ let notification = new EventEmitter(); notification.model = notificationModel; notification.dismiss = () => { @@ -333,6 +374,7 @@ export default class NotificationAPI extends EventEmitter { /** * @private + * @param {Notification | undefined} notification */ _setActiveNotification(notification) { this.activeNotification = notification; diff --git a/src/api/overlays/components/OverlayComponent.vue b/src/api/overlays/components/OverlayComponent.vue index 9742fd7367..8c84998c04 100644 --- a/src/api/overlays/components/OverlayComponent.vue +++ b/src/api/overlays/components/OverlayComponent.vue @@ -15,6 +15,8 @@ ref="element" class="c-overlay__contents js-notebook-snapshot-item-wrapper" tabindex="0" + aria-modal="true" + role="dialog" >