diff --git a/e2e/tests/plugins/timer/timer.e2e.spec.js b/e2e/tests/plugins/timer/timer.e2e.spec.js new file mode 100644 index 0000000000..c910b746f2 --- /dev/null +++ b/e2e/tests/plugins/timer/timer.e2e.spec.js @@ -0,0 +1,184 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2022, 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. + *****************************************************************************/ + +const { test } = require('../../../fixtures.js'); +const { expect } = require('@playwright/test'); + +test.describe('Timer', () => { + + test.beforeEach(async ({ page }) => { + //Go to baseURL + await page.goto('/', { waitUntil: 'networkidle' }); + + //Click the Create button + await page.click('button:has-text("Create")'); + + // Click 'Timer' + await page.click('text=Timer'); + + // Click text=OK + await Promise.all([ + page.waitForNavigation({waitUntil: 'networkidle'}), + page.click('text=OK') + ]); + + await expect(page.locator('.l-browse-bar__object-name')).toContainText('Unnamed Timer'); + }); + + test('Can perform actions on the Timer', async ({ page }) => { + test.info().annotations.push({ + type: 'issue', + description: 'https://github.com/nasa/openmct/issues/4313' + }); + + await test.step("From the tree context menu", async () => { + await triggerTimerContextMenuAction(page, 'Start'); + await triggerTimerContextMenuAction(page, 'Pause'); + await triggerTimerContextMenuAction(page, 'Restart at 0'); + await triggerTimerContextMenuAction(page, 'Stop'); + }); + + await test.step("From the 3dot menu", async () => { + await triggerTimer3dotMenuAction(page, 'Start'); + await triggerTimer3dotMenuAction(page, 'Pause'); + await triggerTimer3dotMenuAction(page, 'Restart at 0'); + await triggerTimer3dotMenuAction(page, 'Stop'); + }); + + await test.step("From the object view", async () => { + await triggerTimerViewAction(page, 'Start'); + await triggerTimerViewAction(page, 'Pause'); + await triggerTimerViewAction(page, 'Restart at 0'); + }); + }); +}); + +/** + * Actions that can be performed on a timer from context menus. + * @typedef {'Start' | 'Stop' | 'Pause' | 'Restart at 0'} TimerAction + */ + +/** + * Actions that can be performed on a timer from the object view. + * @typedef {'Start' | 'Pause' | 'Restart at 0'} TimerViewAction + */ + +/** + * Open the timer context menu from the object tree. + * Expands the 'My Items' folder if it is not already expanded. + * @param {import('@playwright/test').Page} page + */ +async function openTimerContextMenu(page) { + const myItemsFolder = page.locator('text=Open MCT My Items >> span').nth(3); + const className = await myItemsFolder.getAttribute('class'); + if (!className.includes('c-disclosure-triangle--expanded')) { + await myItemsFolder.click(); + } + + await page.locator(`a:has-text("Unnamed Timer")`).click({ + button: 'right' + }); +} + +/** + * Trigger a timer action from the tree context menu + * @param {import('@playwright/test').Page} page + * @param {TimerAction} action + */ +async function triggerTimerContextMenuAction(page, action) { + const menuAction = `.c-menu ul li >> text="${action}"`; + await openTimerContextMenu(page); + await page.locator(menuAction).click(); + assertTimerStateAfterAction(page, action); +} + +/** + * Trigger a timer action from the 3dot menu + * @param {import('@playwright/test').Page} page + * @param {TimerAction} action + */ +async function triggerTimer3dotMenuAction(page, action) { + const menuAction = `.c-menu ul li >> text="${action}"`; + const threeDotMenuButton = 'button[title="More options"]'; + let isActionAvailable = false; + let iterations = 0; + // Dismiss/open the 3dot menu until the action is available + // or a maxiumum number of iterations is reached + while (!isActionAvailable && iterations <= 20) { + await page.click('.c-object-view'); + await page.click(threeDotMenuButton); + isActionAvailable = await page.locator(menuAction).isVisible(); + iterations++; + } + + await page.locator(menuAction).click(); + assertTimerStateAfterAction(page, action); +} + +/** + * Trigger a timer action from the object view + * @param {import('@playwright/test').Page} page + * @param {TimerViewAction} action + */ +async function triggerTimerViewAction(page, action) { + const buttonTitle = buttonTitleFromAction(action); + await page.click(`button[title="${buttonTitle}"]`); + assertTimerStateAfterAction(page, action); +} + +/** + * Takes in a TimerViewAction and returns the button title + * @param {TimerViewAction} action + */ +function buttonTitleFromAction(action) { + switch (action) { + case 'Start': + return 'Start'; + case 'Pause': + return 'Pause'; + case 'Restart at 0': + return 'Reset'; + } +} + +/** + * Verify the timer state after a timer action has been performed. + * @param {import('@playwright/test').Page} page + * @param {TimerAction} action + */ +async function assertTimerStateAfterAction(page, action) { + let timerStateClass; + switch (action) { + case 'Start': + case 'Restart at 0': + timerStateClass = "is-started"; + break; + case 'Stop': + timerStateClass = 'is-stopped'; + break; + case 'Pause': + timerStateClass = 'is-paused'; + break; + } + + await expect.soft(page.locator('.c-timer')).toHaveClass(new RegExp(timerStateClass)); +} diff --git a/src/api/actions/ActionCollection.js b/src/api/actions/ActionCollection.js index eed8be625b..6606616b01 100644 --- a/src/api/actions/ActionCollection.js +++ b/src/api/actions/ActionCollection.js @@ -85,8 +85,6 @@ class ActionCollection extends EventEmitter { } destroy() { - super.removeAllListeners(); - if (!this.skipEnvironmentObservers) { this.objectUnsubscribes.forEach(unsubscribe => { unsubscribe(); @@ -96,6 +94,7 @@ class ActionCollection extends EventEmitter { } this.emit('destroy', this.view); + this.removeAllListeners(); } getVisibleActions() { diff --git a/src/plugins/timer/actions/PauseTimerAction.js b/src/plugins/timer/actions/PauseTimerAction.js index 532e2a7cd2..d729e8b21e 100644 --- a/src/plugins/timer/actions/PauseTimerAction.js +++ b/src/plugins/timer/actions/PauseTimerAction.js @@ -43,14 +43,19 @@ export default class PauseTimerAction { this.openmct.objects.mutate(domainObject, 'configuration', newConfiguration); } - appliesTo(objectPath) { + appliesTo(objectPath, view = {}) { const domainObject = objectPath[0]; if (!domainObject || !domainObject.configuration) { return; } + // Use object configuration timerState for viewless context menus, + // otherwise manually show/hide based on the view's timerState + const viewKey = view.key; const { timerState } = domainObject.configuration; - return domainObject.type === 'timer' && timerState === 'started'; + return viewKey + ? domainObject.type === 'timer' + : domainObject.type === 'timer' && timerState === 'started'; } } diff --git a/src/plugins/timer/actions/RestartTimerAction.js b/src/plugins/timer/actions/RestartTimerAction.js index 81411f0890..23faf4db28 100644 --- a/src/plugins/timer/actions/RestartTimerAction.js +++ b/src/plugins/timer/actions/RestartTimerAction.js @@ -44,14 +44,19 @@ export default class RestartTimerAction { this.openmct.objects.mutate(domainObject, 'configuration', newConfiguration); } - appliesTo(objectPath) { + appliesTo(objectPath, view = {}) { const domainObject = objectPath[0]; if (!domainObject || !domainObject.configuration) { return; } + // Use object configuration timerState for viewless context menus, + // otherwise manually show/hide based on the view's timerState + const viewKey = view.key; const { timerState } = domainObject.configuration; - return domainObject.type === 'timer' && timerState !== 'stopped'; + return viewKey + ? domainObject.type === 'timer' + : domainObject.type === 'timer' && timerState !== 'stopped'; } } diff --git a/src/plugins/timer/actions/StartTimerAction.js b/src/plugins/timer/actions/StartTimerAction.js index 8313fba4cd..cdd01f3e66 100644 --- a/src/plugins/timer/actions/StartTimerAction.js +++ b/src/plugins/timer/actions/StartTimerAction.js @@ -63,14 +63,19 @@ export default class StartTimerAction { newConfiguration.pausedTime = undefined; this.openmct.objects.mutate(domainObject, 'configuration', newConfiguration); } - appliesTo(objectPath) { + appliesTo(objectPath, view = {}) { const domainObject = objectPath[0]; if (!domainObject || !domainObject.configuration) { return; } + // Use object configuration timerState for viewless context menus, + // otherwise manually show/hide based on the view's timerState + const viewKey = view.key; const { timerState } = domainObject.configuration; - return domainObject.type === 'timer' && timerState !== 'started'; + return viewKey + ? domainObject.type === 'timer' + : domainObject.type === 'timer' && timerState !== 'started'; } } diff --git a/src/plugins/timer/actions/StopTimerAction.js b/src/plugins/timer/actions/StopTimerAction.js index badf4b2bf9..6ed672bb5c 100644 --- a/src/plugins/timer/actions/StopTimerAction.js +++ b/src/plugins/timer/actions/StopTimerAction.js @@ -44,14 +44,19 @@ export default class StopTimerAction { this.openmct.objects.mutate(domainObject, 'configuration', newConfiguration); } - appliesTo(objectPath) { + appliesTo(objectPath, view = {}) { const domainObject = objectPath[0]; if (!domainObject || !domainObject.configuration) { return; } + // Use object configuration timerState for viewless context menus, + // otherwise manually show/hide based on the view's timerState + const viewKey = view.key; const { timerState } = domainObject.configuration; - return domainObject.type === 'timer' && timerState !== 'stopped'; + return viewKey + ? domainObject.type === 'timer' + : domainObject.type === 'timer' && timerState !== 'stopped'; } } diff --git a/src/plugins/timer/components/Timer.vue b/src/plugins/timer/components/Timer.vue index 6154eac9e7..b137649f42 100644 --- a/src/plugins/timer/components/Timer.vue +++ b/src/plugins/timer/components/Timer.vue @@ -179,6 +179,15 @@ export default { return timerSign; } }, + watch: { + timerState() { + if (!this.viewActionsCollection) { + return; + } + + this.showOrHideAvailableActions(); + } + }, mounted() { this.$nextTick(() => { if (this.configuration && this.configuration.timerState === undefined) { @@ -190,6 +199,9 @@ export default { this.unlisten = ticker.listen(() => { this.openmct.objects.refresh(this.domainObject); }); + + this.viewActionsCollection = this.openmct.actions.getActionsCollection(this.objectPath, this.currentView); + this.showOrHideAvailableActions(); }); }, beforeDestroy() { @@ -228,6 +240,22 @@ export default { if (action) { action.invoke(this.objectPath, this.currentView); } + }, + showOrHideAvailableActions() { + switch (this.timerState) { + case 'started': + this.viewActionsCollection.hide(['timer.start']); + this.viewActionsCollection.show(['timer.stop', 'timer.pause', 'timer.restart']); + break; + case 'paused': + this.viewActionsCollection.hide(['timer.pause']); + this.viewActionsCollection.show(['timer.stop', 'timer.start', 'timer.restart']); + break; + case 'stopped': + this.viewActionsCollection.hide(['timer.stop', 'timer.pause', 'timer.restart']); + this.viewActionsCollection.show(['timer.start']); + break; + } } } };