From 22621aaaf8b9b0da9ae2a51af5de19308ca668d0 Mon Sep 17 00:00:00 2001 From: Scott Bell Date: Fri, 20 Jan 2023 03:56:46 +0100 Subject: [PATCH] 6098 operator status indicator v11 improvements (#6112) * Added clear poll button to clear all statuses * Clear current poll question * Added table for operator status Co-authored-by: Michael Rogers Co-authored-by: Andrew Henry --- e2e/helper/addInitExampleUser.js | 27 +++ e2e/helper/addInitOperatorStatus.js | 27 +++ .../operatorStatus/operatorStatus.e2e.spec.js | 156 ++++++++++++++++++ example/exampleUser/ExampleUserProvider.js | 22 ++- src/api/user/StatusAPI.js | 3 +- .../operatorStatus/operator-status.scss | 14 ++ .../pollQuestion/PollQuestion.vue | 111 ++++++++++++- .../pollQuestion/PollQuestionIndicator.js | 2 +- 8 files changed, 350 insertions(+), 12 deletions(-) create mode 100644 e2e/helper/addInitExampleUser.js create mode 100644 e2e/helper/addInitOperatorStatus.js create mode 100644 e2e/tests/functional/plugins/operatorStatus/operatorStatus.e2e.spec.js diff --git a/e2e/helper/addInitExampleUser.js b/e2e/helper/addInitExampleUser.js new file mode 100644 index 0000000000..7d8efbee5c --- /dev/null +++ b/e2e/helper/addInitExampleUser.js @@ -0,0 +1,27 @@ +/***************************************************************************** + * 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 should be used to install the Example User +document.addEventListener('DOMContentLoaded', () => { + const openmct = window.openmct; + openmct.install(openmct.plugins.example.ExampleUser()); +}); diff --git a/e2e/helper/addInitOperatorStatus.js b/e2e/helper/addInitOperatorStatus.js new file mode 100644 index 0000000000..cae93daf1d --- /dev/null +++ b/e2e/helper/addInitOperatorStatus.js @@ -0,0 +1,27 @@ +/***************************************************************************** + * 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 should be used to install the Operator Status +document.addEventListener('DOMContentLoaded', () => { + const openmct = window.openmct; + openmct.install(openmct.plugins.OperatorStatus()); +}); diff --git a/e2e/tests/functional/plugins/operatorStatus/operatorStatus.e2e.spec.js b/e2e/tests/functional/plugins/operatorStatus/operatorStatus.e2e.spec.js new file mode 100644 index 0000000000..4b6f199a87 --- /dev/null +++ b/e2e/tests/functional/plugins/operatorStatus/operatorStatus.e2e.spec.js @@ -0,0 +1,156 @@ +/***************************************************************************** + * 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 testing the operator status plugin. +*/ + +const path = require('path'); +const { test, expect } = require('../../../../pluginFixtures'); + +/* + +Precondition: Inject Example User, Operator Status Plugins +Verify that user 1 sees updates from user/role 2 (Not possible without openmct-yamcs implementation) + +Clear Role Status of single user test +STUB (test.fixme) Rolling through each + +*/ + +test.describe('Operator Status', () => { + test.beforeEach(async ({ page }) => { + // FIXME: determine if plugins will be added to index.html or need to be injected + // eslint-disable-next-line no-undef + await page.addInitScript({ path: path.join(__dirname, '../../../../helper/', 'addInitExampleUser.js')}); + // eslint-disable-next-line no-undef + await page.addInitScript({ path: path.join(__dirname, '../../../../helper/', 'addInitOperatorStatus.js')}); + await page.goto('./', { waitUntil: 'networkidle' }); + }); + + // verify that operator status is visible + test('operator status is visible and expands when clicked', async ({ page }) => { + await expect(page.locator('div[title="Set my operator status"]')).toBeVisible(); + await page.locator('div[title="Set my operator status"]').click(); + + // expect default status to be 'GO' + await expect(page.locator('.c-status-poll-panel')).toBeVisible(); + }); + + test('poll question indicator remains when blank poll set', async ({ page }) => { + await expect(page.locator('div[title="Set the current poll question"]')).toBeVisible(); + await page.locator('div[title="Set the current poll question"]').click(); + // set to blank + await page.getByRole('button', { name: 'Update' }).click(); + + // should still be visible + await expect(page.locator('div[title="Set the current poll question"]')).toBeVisible(); + }); + + // Verify that user 1 sees updates from user/role 2 (Not possible without openmct-yamcs implementation) + test('operator status table reflects answered values', async ({ page }) => { + // user navigates to operator status poll + const statusPollIndicator = page.locator('div[title="Set my operator status"]'); + await statusPollIndicator.click(); + + // get user role value + const userRole = page.locator('.c-status-poll-panel__user-role'); + const userRoleText = await userRole.innerText(); + + // get selected status value + const selectStatus = page.locator('select[name="setStatus"]'); + await selectStatus.selectOption({ index: 1}); + const initialStatusValue = await selectStatus.inputValue(); + + // open manage status poll + const manageStatusPollIndicator = page.locator('div[title="Set the current poll question"]'); + await manageStatusPollIndicator.click(); + // parse the table row values + const row = page.locator(`tr:has-text("${userRoleText}")`); + const rowValues = await row.innerText(); + const rowValuesArr = rowValues.split('\t'); + const COLUMN_STATUS_INDEX = 1; + // check initial set value matches status table + expect(rowValuesArr[COLUMN_STATUS_INDEX].toLowerCase()) + .toEqual(initialStatusValue.toLowerCase()); + + // change user status + await statusPollIndicator.click(); + // FIXME: might want to grab a dynamic option instead of arbitrary + await page.locator('select[name="setStatus"]').selectOption({ index: 2}); + const updatedStatusValue = await selectStatus.inputValue(); + // verify user status is reflected in table + await manageStatusPollIndicator.click(); + + const updatedRow = page.locator(`tr:has-text("${userRoleText}")`); + const updatedRowValues = await updatedRow.innerText(); + const updatedRowValuesArr = updatedRowValues.split('\t'); + + expect(updatedRowValuesArr[COLUMN_STATUS_INDEX].toLowerCase()) + .toEqual(updatedStatusValue.toLowerCase()); + + }); + + test('clear poll button removes poll responses', async ({ page }) => { + // user navigates to operator status poll + const statusPollIndicator = page.locator('div[title="Set my operator status"]'); + await statusPollIndicator.click(); + + // get user role value + const userRole = page.locator('.c-status-poll-panel__user-role'); + const userRoleText = await userRole.innerText(); + + // get selected status value + const selectStatus = page.locator('select[name="setStatus"]'); + // FIXME: might want to grab a dynamic option instead of arbitrary + await selectStatus.selectOption({ index: 1}); + const initialStatusValue = await selectStatus.inputValue(); + + // open manage status poll + const manageStatusPollIndicator = page.locator('div[title="Set the current poll question"]'); + await manageStatusPollIndicator.click(); + // parse the table row values + const row = page.locator(`tr:has-text("${userRoleText}")`); + const rowValues = await row.innerText(); + const rowValuesArr = rowValues.split('\t'); + const COLUMN_STATUS_INDEX = 1; + // check initial set value matches status table + expect(rowValuesArr[COLUMN_STATUS_INDEX].toLowerCase()) + .toEqual(initialStatusValue.toLowerCase()); + + // clear the poll + await page.locator('button[title="Clear the previous poll question"]').click(); + + const updatedRow = page.locator(`tr:has-text("${userRoleText}")`); + const updatedRowValues = await updatedRow.innerText(); + const updatedRowValuesArr = updatedRowValues.split('\t'); + const UNSET_VALUE_LABEL = 'Not set'; + expect(updatedRowValuesArr[COLUMN_STATUS_INDEX]) + .toEqual(UNSET_VALUE_LABEL); + + }); + + test.fixme('iterate through all possible response values', async ({ page }) => { + // test all possible respone values for the poll + }); + +}); diff --git a/example/exampleUser/ExampleUserProvider.js b/example/exampleUser/ExampleUserProvider.js index 8fdd029234..43ab47c40e 100644 --- a/example/exampleUser/ExampleUserProvider.js +++ b/example/exampleUser/ExampleUserProvider.js @@ -65,7 +65,7 @@ export default class ExampleUserProvider extends EventEmitter { this.user = undefined; this.loggedIn = false; this.autoLoginUser = undefined; - this.status = STATUSES[1]; + this.status = STATUSES[0]; this.pollQuestion = undefined; this.defaultStatusRole = defaultStatusRole; @@ -124,6 +124,7 @@ export default class ExampleUserProvider extends EventEmitter { } setStatusForRole(role, status) { + status.timestamp = Date.now(); this.status = status; this.emit('statusChange', { role, @@ -133,14 +134,23 @@ export default class ExampleUserProvider extends EventEmitter { return true; } - getPollQuestion() { - return Promise.resolve({ - question: 'Set "GO" if your position is ready for a boarding action on the Klingon cruiser', - timestamp: Date.now() - }); + // eslint-disable-next-line require-await + async getPollQuestion() { + if (this.pollQuestion) { + return this.pollQuestion; + } else { + return undefined; + } } setPollQuestion(pollQuestion) { + if (!pollQuestion) { + // If the poll question is undefined, set it to a blank string. + // This behavior better reflects how other telemetry systems + // deal with undefined poll questions. + pollQuestion = ''; + } + this.pollQuestion = { question: pollQuestion, timestamp: Date.now() diff --git a/src/api/user/StatusAPI.js b/src/api/user/StatusAPI.js index 9c1318c380..90cdf6fe02 100644 --- a/src/api/user/StatusAPI.js +++ b/src/api/user/StatusAPI.js @@ -291,5 +291,6 @@ export default class StatusAPI extends EventEmitter { * The Status type * @typedef {Object} Status * @property {String} key - A unique identifier for this status - * @property {Number} label - A human readable label for this status + * @property {String} label - A human readable label for this status + * @property {Number} timestamp - The time that the status was set. */ diff --git a/src/plugins/operatorStatus/operator-status.scss b/src/plugins/operatorStatus/operator-status.scss index 9482c650fa..8e51063140 100644 --- a/src/plugins/operatorStatus/operator-status.scss +++ b/src/plugins/operatorStatus/operator-status.scss @@ -88,6 +88,14 @@ padding: 3px 0; } + [class*='__label'] { + padding: 3px 0; + } + + [class*='__poll-table'] { + grid-column: span 2; + } + [class*='new-question'] { align-items: center; display: flex; @@ -123,6 +131,12 @@ opacity: 0.6; } } + &__actions { + display:flex; + flex: auto; + flex-direction: row; + justify-content: flex-end; + } } .c-indicator { diff --git a/src/plugins/operatorStatus/pollQuestion/PollQuestion.vue b/src/plugins/operatorStatus/pollQuestion/PollQuestion.vue index ff8443cf9a..6c57a9bd92 100644 --- a/src/plugins/operatorStatus/pollQuestion/PollQuestion.vue +++ b/src/plugins/operatorStatus/pollQuestion/PollQuestion.vue @@ -58,6 +58,13 @@ {{ entry.roleCount }} +
+ +
@@ -74,6 +81,41 @@ @click="updatePollQuestion" >Update +
+ + + + + + + + + + + + + + + +
+ Position + + Status + + Age +
+ {{ statusForRole.role }} + + {{ statusForRole.status.label }} + + {{ statusForRole.age }} +
+
@@ -97,9 +139,11 @@ export default { data() { return { pollQuestionUpdated: '--', + pollQuestionTimestamp: undefined, currentPollQuestion: '--', newPollQuestion: undefined, - statusCountViewModel: [] + statusCountViewModel: [], + statusesForRolesViewModel: [] }; }, computed: { @@ -135,9 +179,17 @@ export default { this.openmct.user.status.on('pollQuestionChange', this.setPollQuestion); }, setPollQuestion(pollQuestion) { - this.currentPollQuestion = pollQuestion.question; + let pollQuestionText = pollQuestion.question; + if (!pollQuestionText || pollQuestionText === '') { + pollQuestionText = '--'; + this.indicator.text('No Poll Question'); + } else { + this.indicator.text(pollQuestionText); + } + + this.currentPollQuestion = pollQuestionText; + this.pollQuestionTimestamp = pollQuestion.timestamp; this.pollQuestionUpdated = new Date(pollQuestion.timestamp).toISOString(); - this.indicator.text(pollQuestion.question); }, async updatePollQuestion() { const result = await this.openmct.user.status.setPollQuestion(this.newPollQuestion); @@ -149,6 +201,13 @@ export default { this.newPollQuestion = undefined; }, + async clearPollQuestion() { + this.currentPollQuestion = undefined; + await Promise.all([ + this.openmct.user.status.resetAllStatuses(), + this.openmct.user.status.setPollQuestion() + ]); + }, async fetchStatusSummary() { const allStatuses = await this.openmct.user.status.getPossibleStatuses(); const statusCountMap = allStatuses.reduce((statusToCountMap, status) => { @@ -158,7 +217,6 @@ export default { }, {}); const allStatusRoles = await this.openmct.user.status.getAllStatusRoles(); const statusesForRoles = await Promise.all(allStatusRoles.map(role => this.openmct.user.status.getStatusForRole(role))); - statusesForRoles.forEach((status, i) => { const currentCount = statusCountMap[status.key]; statusCountMap[status.key] = currentCount + 1; @@ -170,6 +228,51 @@ export default { roleCount: statusCountMap[status.key] }; }); + const defaultStatuses = await Promise.all(allStatusRoles.map(role => this.openmct.user.status.getDefaultStatusForRole(role))); + this.statusesForRolesViewModel = []; + statusesForRoles.forEach((status, index) => { + const isDefaultStatus = defaultStatuses[index].key === status.key; + let statusTimestamp = status.timestamp; + if (isDefaultStatus) { + // if the default is selected, set timestamp to undefined + statusTimestamp = undefined; + } + + this.statusesForRolesViewModel.push({ + status: this.applyStyling(status), + role: allStatusRoles[index], + age: this.formatStatusAge(statusTimestamp, this.pollQuestionTimestamp) + }); + }); + }, + formatStatusAge(statusTimestamp, pollQuestionTimestamp) { + if (statusTimestamp === undefined || pollQuestionTimestamp === undefined) { + return '--'; + } + + const statusAgeInMs = statusTimestamp - pollQuestionTimestamp; + const absoluteTotalSeconds = Math.floor(Math.abs(statusAgeInMs) / 1000); + let hours = Math.floor(absoluteTotalSeconds / 3600); + let minutes = Math.floor((absoluteTotalSeconds - (hours * 3600)) / 60); + let secondsString = absoluteTotalSeconds - (hours * 3600) - (minutes * 60); + + if (statusAgeInMs > 0 || (absoluteTotalSeconds === 0)) { + hours = `+ ${hours}`; + } else { + hours = `- ${hours}`; + } + + if (minutes < 10) { + minutes = `0${minutes}`; + } + + if (secondsString < 10) { + secondsString = `0${secondsString}`; + } + + const statusAgeString = `${hours}:${minutes}:${secondsString}`; + + return statusAgeString; }, applyStyling(status) { const stylesForStatus = this.configuration?.statusStyles?.[status.label]; diff --git a/src/plugins/operatorStatus/pollQuestion/PollQuestionIndicator.js b/src/plugins/operatorStatus/pollQuestion/PollQuestionIndicator.js index ea85d5905d..3dda251ba7 100644 --- a/src/plugins/operatorStatus/pollQuestion/PollQuestionIndicator.js +++ b/src/plugins/operatorStatus/pollQuestion/PollQuestionIndicator.js @@ -51,7 +51,7 @@ export default class PollQuestionIndicator extends AbstractStatusIndicator { createIndicator() { const pollQuestionIndicator = this.openmct.indicators.simpleIndicator(); - pollQuestionIndicator.text("Poll Question"); + pollQuestionIndicator.text("No Poll Question"); pollQuestionIndicator.description("Set the current poll question"); pollQuestionIndicator.iconClass('icon-status-poll-edit'); pollQuestionIndicator.element.classList.add("c-indicator--operator-status");