mirror of
https://github.com/nasa/openmct.git
synced 2024-12-18 20:57:53 +00:00
feat: Mission Status for Situational Awareness (#7418)
* refactor: `UserIndicator` use vue component directly * style(WIP): filler styles for user-indicator * feat(WIP): working on mission status indicators * feat: support mission statuses * feat(WIP): can display mission statuses now * feat(WIP): add composables and dynamically calculate popup position * feat(WIP): dismissible popup, use moar compositionAPI * Closes #7420 - Styling and markup for mission status control panel. - Tweaks and additions to some common style elements. * feat: set/unset mission status for role * refactor: rename some functions * feat: more renaming, get mission role statuses working * refactor: more method renaming * fix: remove dupe method * feat: hook up event listeners * refactor: convert to CompositionAPI and listen to events * fix: add that back in, woops * test: fix some existing tests * lint: words for the word god * refactor: rename * fix: setting mission statuses * style: fix go styling * style: add mission status button * refactor: rename `MissionRole` -> `MissionAction` * test: fix most existing tests * test: remove integration tests already covered by e2e - These tests are going to be wonky since they depend on the View. Any unit tests depending on Vue / the View will become increasingly volatile over time as we migrate more of the app into the main Vue app. Since these LOC are already covered by e2e, I'm going to remove them. We will need to move towards a more component / Vue-friendly testing framework to stabilize all of this. * docs: add documentation * refactor: rename * fix: a comma * refactor: a word * fix: emit parameter format * fix: correct emit for `missionStatusActionChange` --------- Co-authored-by: Charles Hacskaylo <charles.f.hacskaylo@nasa.gov> Co-authored-by: John Hill <john.c.hill@nasa.gov>
This commit is contained in:
parent
ee5081f807
commit
82fa4c1597
@ -496,6 +496,7 @@
|
||||
"unnnormalized",
|
||||
"checksnapshots",
|
||||
"specced",
|
||||
"composables",
|
||||
"countup"
|
||||
],
|
||||
"dictionaries": ["npm", "softwareTerms", "node", "html", "css", "bash", "en_US", "en-gb", "misc"],
|
||||
|
@ -110,6 +110,11 @@ export default class ExampleUserProvider extends EventEmitter {
|
||||
canSetPollQuestion() {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
canSetMissionStatus() {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
hasRole(roleId) {
|
||||
if (!this.loggedIn) {
|
||||
Promise.resolve(undefined);
|
||||
|
@ -32,6 +32,7 @@ export default class StatusAPI extends EventEmitter {
|
||||
|
||||
this.onProviderStatusChange = this.onProviderStatusChange.bind(this);
|
||||
this.onProviderPollQuestionChange = this.onProviderPollQuestionChange.bind(this);
|
||||
this.onMissionActionStatusChange = this.onMissionActionStatusChange.bind(this);
|
||||
this.listenToStatusEvents = this.listenToStatusEvents.bind(this);
|
||||
|
||||
this.#openmct.once('destroy', () => {
|
||||
@ -40,6 +41,7 @@ export default class StatusAPI extends EventEmitter {
|
||||
if (typeof provider?.off === 'function') {
|
||||
provider.off('statusChange', this.onProviderStatusChange);
|
||||
provider.off('pollQuestionChange', this.onProviderPollQuestionChange);
|
||||
provider.off('missionActionStatusChange', this.onMissionActionStatusChange);
|
||||
}
|
||||
});
|
||||
|
||||
@ -100,6 +102,67 @@ export default class StatusAPI extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Can the currently logged in user set the mission status.
|
||||
* @returns {Promise<Boolean>} true if the currently logged in user can set the mission status, false otherwise.
|
||||
*/
|
||||
canSetMissionStatus() {
|
||||
const provider = this.#userAPI.getProvider();
|
||||
|
||||
if (provider.canSetMissionStatus) {
|
||||
return provider.canSetMissionStatus();
|
||||
} else {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the current status for the given mission action
|
||||
* @param {MissionAction} action
|
||||
* @returns {string}
|
||||
*/
|
||||
getStatusForMissionAction(action) {
|
||||
const provider = this.#userAPI.getProvider();
|
||||
|
||||
if (provider.getStatusForMissionAction) {
|
||||
return provider.getStatusForMissionAction(action);
|
||||
} else {
|
||||
this.#userAPI.error('User provider does not support getting mission action status');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the list of possible mission status options (GO, NO-GO, etc.)
|
||||
* @returns {Promise<MissionStatusOption[]>} the complete list of possible mission statuses
|
||||
*/
|
||||
async getPossibleMissionActionStatuses() {
|
||||
const provider = this.#userAPI.getProvider();
|
||||
|
||||
if (provider.getPossibleMissionActionStatuses) {
|
||||
const possibleOptions = await provider.getPossibleMissionActionStatuses();
|
||||
|
||||
return possibleOptions;
|
||||
} else {
|
||||
this.#userAPI.error('User provider does not support mission status options');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the list of possible mission actions
|
||||
* @returns {Promise<string[]>} the list of possible mission actions
|
||||
*/
|
||||
async getPossibleMissionActions() {
|
||||
const provider = this.#userAPI.getProvider();
|
||||
|
||||
if (provider.getPossibleMissionActions) {
|
||||
const possibleActions = await provider.getPossibleMissionActions();
|
||||
|
||||
return possibleActions;
|
||||
} else {
|
||||
this.#userAPI.error('User provider does not support mission statuses');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<Array<Status>>} the complete list of possible states that an operator can reply to a poll question with.
|
||||
*/
|
||||
@ -166,6 +229,21 @@ export default class StatusAPI extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MissionAction} action
|
||||
* @param {MissionStatusOption} status
|
||||
* @returns {Promise<Boolean>} true if operation was successful, otherwise false.
|
||||
*/
|
||||
setStatusForMissionAction(action, status) {
|
||||
const provider = this.#userAPI.getProvider();
|
||||
|
||||
if (provider.setStatusForMissionAction) {
|
||||
return provider.setStatusForMissionAction(action, status);
|
||||
} else {
|
||||
this.#userAPI.error('User provider does not support setting mission role status');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the status of the provided role back to its default status.
|
||||
* @param {import("./UserAPI").Role} role The role to set the status for.
|
||||
@ -245,6 +323,7 @@ export default class StatusAPI extends EventEmitter {
|
||||
if (typeof provider.on === 'function') {
|
||||
provider.on('statusChange', this.onProviderStatusChange);
|
||||
provider.on('pollQuestionChange', this.onProviderPollQuestionChange);
|
||||
provider.on('missionActionStatusChange', this.onMissionActionStatusChange);
|
||||
}
|
||||
}
|
||||
|
||||
@ -261,14 +340,23 @@ export default class StatusAPI extends EventEmitter {
|
||||
onProviderPollQuestionChange(pollQuestion) {
|
||||
this.emit('pollQuestionChange', pollQuestion);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
onMissionActionStatusChange({ action, status }) {
|
||||
this.emit('missionActionStatusChange', { action, status });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {import('./UserProvider')} UserProvider
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {import('./StatusUserProvider')} StatusUserProvider
|
||||
*/
|
||||
|
||||
/**
|
||||
* The PollQuestion type
|
||||
* @typedef {Object} PollQuestion
|
||||
@ -276,6 +364,19 @@ export default class StatusAPI extends EventEmitter {
|
||||
* @property {Number} timestamp - The time that the poll question was set.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The MissionStatus type
|
||||
* @typedef {Object} MissionStatusOption
|
||||
* @extends {Status}
|
||||
* @property {String} color A color to be used when displaying the mission status
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} MissionAction
|
||||
* @property {String} key A unique identifier for this action
|
||||
* @property {String} label A human readable label for this action
|
||||
*/
|
||||
|
||||
/**
|
||||
* The Status type
|
||||
* @typedef {Object} Status
|
||||
|
@ -23,12 +23,12 @@ import UserProvider from './UserProvider.js';
|
||||
|
||||
export default class StatusUserProvider extends UserProvider {
|
||||
/**
|
||||
* @param {('statusChange'|'pollQuestionChange')} event the name of the event to listen to
|
||||
* @param {('statusChange'|'pollQuestionChange'|'missionActionStatusChange')} event the name of the event to listen to
|
||||
* @param {Function} callback a function to invoke when this event occurs
|
||||
*/
|
||||
on(event, callback) {}
|
||||
/**
|
||||
* @param {('statusChange'|'pollQuestionChange')} event the name of the event to stop listen to
|
||||
* @param {('statusChange'|'pollQuestionChange'|'missionActionStatusChange')} event the name of the event to stop listen to
|
||||
* @param {Function} callback the callback function used to register the listener
|
||||
*/
|
||||
off(event, callback) {}
|
||||
|
@ -24,9 +24,6 @@ import ExampleUserProvider from '../../../example/exampleUser/ExampleUserProvide
|
||||
import { createOpenMct, resetApplicationState } from '../../utils/testing.js';
|
||||
import { MULTIPLE_PROVIDER_ERROR } from './constants.js';
|
||||
|
||||
const USERNAME = 'Test User';
|
||||
const EXAMPLE_ROLE = 'flight';
|
||||
|
||||
describe('The User API', () => {
|
||||
let openmct;
|
||||
|
||||
@ -65,48 +62,4 @@ describe('The User API', () => {
|
||||
expect(openmct.user.hasProvider()).toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
describe('provides the ability', () => {
|
||||
let provider;
|
||||
|
||||
beforeEach(() => {
|
||||
provider = new ExampleUserProvider(openmct);
|
||||
provider.autoLogin(USERNAME);
|
||||
});
|
||||
|
||||
it('to check if a user (not specific) is logged in', (done) => {
|
||||
expect(openmct.user.isLoggedIn()).toBeFalse();
|
||||
|
||||
openmct.user.on('providerAdded', () => {
|
||||
expect(openmct.user.isLoggedIn()).toBeTrue();
|
||||
done();
|
||||
});
|
||||
|
||||
// this will trigger the user indicator plugin,
|
||||
// which will in turn login the user
|
||||
openmct.user.setProvider(provider);
|
||||
});
|
||||
|
||||
it('to get the current user', (done) => {
|
||||
openmct.user.setProvider(provider);
|
||||
openmct.user
|
||||
.getCurrentUser()
|
||||
.then((apiUser) => {
|
||||
expect(apiUser.name).toEqual(USERNAME);
|
||||
})
|
||||
.finally(done);
|
||||
});
|
||||
|
||||
it('to check if a user has a specific role (by id)', (done) => {
|
||||
openmct.user.setProvider(provider);
|
||||
let junkIdCheckPromise = openmct.user.hasRole('junk-id').then((hasRole) => {
|
||||
expect(hasRole).toBeFalse();
|
||||
});
|
||||
let realIdCheckPromise = openmct.user.hasRole(EXAMPLE_ROLE).then((hasRole) => {
|
||||
expect(hasRole).toBeTrue();
|
||||
});
|
||||
|
||||
Promise.all([junkIdCheckPromise, realIdCheckPromise]).finally(done);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -20,11 +20,7 @@
|
||||
at runtime from the About dialog for additional information.
|
||||
-->
|
||||
<template>
|
||||
<div
|
||||
:style="position"
|
||||
class="c-status-poll-panel c-status-poll-panel--operator"
|
||||
@click.stop="noop"
|
||||
>
|
||||
<div :style="position" class="c-status-poll-panel c-status-poll-panel--operator" @click.stop>
|
||||
<div class="c-status-poll-panel__section c-status-poll-panel__top">
|
||||
<div class="c-status-poll-panel__title">Status Poll</div>
|
||||
<div class="c-status-poll-panel__user-role icon-person">{{ role }}</div>
|
||||
@ -191,8 +187,7 @@ export default {
|
||||
} else {
|
||||
return status;
|
||||
}
|
||||
},
|
||||
noop() {}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
132
src/plugins/userIndicator/components/MissionStatusPopup.vue
Normal file
132
src/plugins/userIndicator/components/MissionStatusPopup.vue
Normal file
@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<div class="c-user-control-panel__component">
|
||||
<div class="c-user-control-panel__header">
|
||||
<div class="c-user-control-panel__title">Mission Status</div>
|
||||
<button
|
||||
aria-label="Close"
|
||||
class="c-icon-button c-icon-button--sm t-close-btn icon-x"
|
||||
@click.stop="onDismiss"
|
||||
></button>
|
||||
</div>
|
||||
<div class="c-ucp-mission-status">
|
||||
<template v-for="action in missionActions" :key="action">
|
||||
<label class="c-ucp-mission-status__label" :for="action">{{ action }}</label>
|
||||
<div class="c-ucp-mission-status__widget" :class="getMissionActionStatusClass(action)">
|
||||
{{ missionActionStatusOptions[missionActionStatusMap[action]]?.label }}
|
||||
</div>
|
||||
<div class="c-ucp-mission-status__select">
|
||||
<select
|
||||
:id="action"
|
||||
v-model="missionActionStatusMap[action]"
|
||||
name="setMissionActionStatus"
|
||||
@change="onChangeStatus(action)"
|
||||
>
|
||||
<option
|
||||
v-for="option in missionActionStatusOptions"
|
||||
:key="option.key"
|
||||
:value="option.key"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { inject, ref } from 'vue';
|
||||
|
||||
import { useEventEmitter } from '../../../ui/composables/event';
|
||||
|
||||
export default {
|
||||
inject: ['openmct'],
|
||||
emits: ['dismiss'],
|
||||
async setup() {
|
||||
const openmct = inject('openmct');
|
||||
let missionActions = ref([]);
|
||||
let missionActionStatusOptions = ref([]);
|
||||
let missionActionStatusMap = ref({});
|
||||
|
||||
try {
|
||||
// Listen for missionActionStatusChange events
|
||||
useEventEmitter(openmct.user.status, 'missionActionStatusChange', ({ action, status }) => {
|
||||
missionActionStatusMap.value[action] = status.key; // Update the reactive property
|
||||
});
|
||||
// Fetch missionStatuses and missionActionStatuses simultaneously
|
||||
const [fetchedMissionActions, fetchedMissionActionStatusOptions] = await Promise.all([
|
||||
openmct.user.status.getPossibleMissionActions(),
|
||||
openmct.user.status.getPossibleMissionActionStatuses()
|
||||
]);
|
||||
|
||||
// Assign the results to the reactive variables
|
||||
missionActions.value = fetchedMissionActions;
|
||||
missionActionStatusOptions.value = fetchedMissionActionStatusOptions;
|
||||
|
||||
const statusPromises = missionActions.value.map((action) =>
|
||||
openmct.user.status.getStatusForMissionAction(action)
|
||||
);
|
||||
|
||||
// Fetch all mission action statuses simultaneously
|
||||
const statuses = await Promise.all(statusPromises);
|
||||
|
||||
// Reduce to a map of mission action to status
|
||||
missionActionStatusMap.value = missionActions.value.reduce((acc, action, index) => {
|
||||
acc[action] = statuses[index].key;
|
||||
return acc;
|
||||
}, {});
|
||||
} catch (error) {
|
||||
console.error('Error fetching mission statuses:', error);
|
||||
}
|
||||
|
||||
return {
|
||||
missionActions,
|
||||
missionActionStatusOptions,
|
||||
missionActionStatusMap
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
onDismiss() {
|
||||
this.$emit('dismiss');
|
||||
},
|
||||
async onChangeStatus(action) {
|
||||
if (!this.openmct.user.status.canSetMissionStatus()) {
|
||||
this.openmct.notifications.error('Selected user role is ineligible to set mission status');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.missionActionStatusMap !== undefined) {
|
||||
const statusObject = this.findOptionByKey(this.missionActionStatusMap[action]);
|
||||
|
||||
const result = await this.openmct.user.status.setStatusForMissionAction(
|
||||
action,
|
||||
statusObject
|
||||
);
|
||||
if (result === true) {
|
||||
this.openmct.notifications.info('Successfully set mission status');
|
||||
} else {
|
||||
this.openmct.notifications.error('Unable to set mission status');
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @param {number} optionKey
|
||||
*/
|
||||
findOptionByKey(optionKey) {
|
||||
return this.missionActionStatusOptions.find(
|
||||
(possibleMatch) => possibleMatch.key === optionKey
|
||||
);
|
||||
},
|
||||
getMissionActionStatusClass(status) {
|
||||
const statusValue =
|
||||
this.missionActionStatusOptions[this.missionActionStatusMap[status]]?.label;
|
||||
return {
|
||||
'--is-no-go': statusValue === 'NO GO',
|
||||
'--is-go': statusValue === 'GO'
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
@ -21,18 +21,67 @@
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="c-indicator icon-person c-indicator--clickable">
|
||||
<div
|
||||
ref="userIndicatorRef"
|
||||
class="c-indicator c-indicator--user icon-person"
|
||||
:class="canSetMissionStatus ? 'clickable' : ''"
|
||||
v-bind="$attrs"
|
||||
@click.stop="togglePopup"
|
||||
>
|
||||
<span class="label c-indicator__label" aria-label="User Role">
|
||||
{{ role ? `${userName}: ${role}` : userName }}
|
||||
<button v-if="availableRoles?.length > 1" @click="promptForRoleSelection">Change Role</button>
|
||||
<button v-if="availableRoles?.length > 1" @click.stop="promptForRoleSelection">
|
||||
Change Role
|
||||
</button>
|
||||
<button v-if="canSetMissionStatus" @click.stop="togglePopup">Mission Status</button>
|
||||
</span>
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
<div v-show="isPopupVisible" ref="popupRef" class="c-user-control-panel" :style="popupStyle">
|
||||
<Suspense>
|
||||
<template #default>
|
||||
<MissionStatusPopup v-if="canSetMissionStatus" @dismiss="togglePopup" />
|
||||
</template>
|
||||
<template #fallback>
|
||||
<div>Loading...</div>
|
||||
</template>
|
||||
</Suspense>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref } from 'vue';
|
||||
|
||||
import ActiveRoleSynchronizer from '../../../api/user/ActiveRoleSynchronizer.js';
|
||||
import { useEventListener } from '../../../ui/composables/event.js';
|
||||
import { useWindowResize } from '../../../ui/composables/resize.js';
|
||||
import MissionStatusPopup from './MissionStatusPopup.vue';
|
||||
|
||||
export default {
|
||||
name: 'UserIndicator',
|
||||
components: {
|
||||
MissionStatusPopup
|
||||
},
|
||||
inject: ['openmct'],
|
||||
inheritAttrs: false,
|
||||
setup() {
|
||||
const { windowSize } = useWindowResize();
|
||||
const isPopupVisible = ref(false);
|
||||
const userIndicatorRef = ref(null);
|
||||
const popupRef = ref(null);
|
||||
|
||||
// eslint-disable-next-line func-style
|
||||
const handleOutsideClick = (event) => {
|
||||
if (isPopupVisible.value && popupRef.value && !popupRef.value.contains(event.target)) {
|
||||
isPopupVisible.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
useEventListener(document, 'click', handleOutsideClick);
|
||||
|
||||
return { windowSize, isPopupVisible, popupRef, userIndicatorRef };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
userName: undefined,
|
||||
@ -40,16 +89,50 @@ export default {
|
||||
availableRoles: [],
|
||||
loggedIn: false,
|
||||
inputRoleSelection: undefined,
|
||||
roleSelectionDialog: undefined
|
||||
roleSelectionDialog: undefined,
|
||||
canSetMissionStatus: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
popupStyle() {
|
||||
return {
|
||||
top: `${this.position.top}px`,
|
||||
left: `${this.position.left}px`
|
||||
};
|
||||
},
|
||||
position() {
|
||||
if (!this.isPopupVisible) {
|
||||
return { top: 0, left: 0 };
|
||||
}
|
||||
const indicator = this.userIndicatorRef;
|
||||
const indicatorRect = indicator.getBoundingClientRect();
|
||||
let top = indicatorRect.bottom;
|
||||
let left = indicatorRect.left;
|
||||
|
||||
async mounted() {
|
||||
const popupRect = this.popupRef.getBoundingClientRect();
|
||||
const popupWidth = popupRect.width;
|
||||
const popupHeight = popupRect.height;
|
||||
|
||||
// Check if the popup goes beyond the right edge of the window
|
||||
if (left + popupWidth > this.windowSize.width) {
|
||||
left = this.windowSize.width - popupWidth; // Adjust left to fit within the window
|
||||
}
|
||||
|
||||
// Check if the popup goes beyond the bottom edge of the window
|
||||
if (top + popupHeight > this.windowSize.height) {
|
||||
top = indicatorRect.top - popupHeight; // Place popup above the indicator
|
||||
}
|
||||
|
||||
return { top, left };
|
||||
}
|
||||
},
|
||||
async created() {
|
||||
await this.getUserInfo();
|
||||
},
|
||||
mounted() {
|
||||
// need to wait for openmct to be loaded before using openmct.overlays.selection
|
||||
// as document.body could be null
|
||||
this.openmct.on('start', this.fetchOrPromptForRole);
|
||||
|
||||
await this.getUserInfo();
|
||||
this.roleChannel = new ActiveRoleSynchronizer(this.openmct);
|
||||
this.roleChannel.subscribeToRoleChanges(this.onRoleChange);
|
||||
},
|
||||
@ -60,6 +143,7 @@ export default {
|
||||
methods: {
|
||||
async getUserInfo() {
|
||||
const user = await this.openmct.user.getCurrentUser();
|
||||
this.canSetMissionStatus = await this.openmct.user.status.canSetMissionStatus();
|
||||
this.userName = user.getName();
|
||||
this.role = this.openmct.user.getActiveRole();
|
||||
this.loggedIn = this.openmct.user.isLoggedIn();
|
||||
@ -138,6 +222,9 @@ export default {
|
||||
this.openmct.user.setActiveRole(role);
|
||||
// update other tabs through broadcast channel
|
||||
this.roleChannel.broadcastNewRole(role);
|
||||
},
|
||||
togglePopup() {
|
||||
this.isPopupVisible = !this.isPopupVisible && this.canSetMissionStatus;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -20,32 +20,14 @@
|
||||
* at runtime from the About dialog for additional information.
|
||||
*****************************************************************************/
|
||||
|
||||
import mount from 'utils/mount';
|
||||
|
||||
import UserIndicator from './components/UserIndicator.vue';
|
||||
|
||||
export default function UserIndicatorPlugin() {
|
||||
function addIndicator(openmct) {
|
||||
const { vNode, destroy } = mount(
|
||||
{
|
||||
components: {
|
||||
UserIndicator
|
||||
},
|
||||
provide: {
|
||||
openmct: openmct
|
||||
},
|
||||
template: '<UserIndicator />'
|
||||
},
|
||||
{
|
||||
app: openmct.app
|
||||
}
|
||||
);
|
||||
|
||||
openmct.indicators.add({
|
||||
key: 'user-indicator',
|
||||
element: vNode.el,
|
||||
priority: openmct.priority.HIGH,
|
||||
destroy: destroy
|
||||
vueComponent: UserIndicator,
|
||||
priority: openmct.priority.HIGH
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -75,7 +75,7 @@ describe('The User Indicator plugin', () => {
|
||||
it('exists', () => {
|
||||
userIndicator = openmct.indicators.indicatorObjects.find(
|
||||
(indicator) => indicator.key === 'user-indicator'
|
||||
).element;
|
||||
).vueComponent;
|
||||
|
||||
const hasClockIndicator = userIndicator !== null && userIndicator !== undefined;
|
||||
expect(hasClockIndicator).toBe(true);
|
||||
@ -89,9 +89,11 @@ describe('The User Indicator plugin', () => {
|
||||
|
||||
userIndicator = openmct.indicators.indicatorObjects.find(
|
||||
(indicator) => indicator.key === 'user-indicator'
|
||||
).element;
|
||||
).vueComponent;
|
||||
|
||||
const userName = userIndicator.textContent.trim();
|
||||
expect(userIndicator).toBeDefined();
|
||||
expect(userIndicator).not.toBeNull();
|
||||
const userName = document.querySelector('[aria-label="User Role"]').textContent.trim();
|
||||
|
||||
expect(user.name).toEqual(USERNAME);
|
||||
expect(userName).toContain(USERNAME);
|
||||
|
194
src/plugins/userIndicator/user-indicator.scss
Normal file
194
src/plugins/userIndicator/user-indicator.scss
Normal file
@ -0,0 +1,194 @@
|
||||
/*****************************************************************************
|
||||
* 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.
|
||||
*****************************************************************************/
|
||||
|
||||
.c-indicator {
|
||||
&:before {
|
||||
// Indicator icon
|
||||
color: $colorKey;
|
||||
}
|
||||
|
||||
&--user {
|
||||
max-width: max-content;
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
|
||||
@include hover() {
|
||||
background: $colorIndicatorBgHov;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$statusCountWidth: 30px;
|
||||
|
||||
.c-user-control-panel {
|
||||
@include menuOuter();
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: $interiorMarginLg;
|
||||
min-width: max-content;
|
||||
max-width: 35%;
|
||||
|
||||
> * + * {
|
||||
margin-top: $interiorMarginLg;
|
||||
}
|
||||
|
||||
*:before {
|
||||
font-size: 0.8em;
|
||||
margin-right: $interiorMarginSm; // WTF - this is BAD!
|
||||
}
|
||||
|
||||
&__section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
|
||||
> * + * {
|
||||
margin-left: $interiorMarginLg;
|
||||
}
|
||||
}
|
||||
|
||||
&__header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
column-gap: $interiorMargin;
|
||||
text-transform: uppercase;
|
||||
|
||||
> * {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
[class*='title'] {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.t-close-btn {
|
||||
&:before {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__component {
|
||||
// General classes for new control panel component
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $interiorMargin;
|
||||
}
|
||||
|
||||
&__top {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&__user-role,
|
||||
&__updated {
|
||||
opacity: 50%;
|
||||
}
|
||||
|
||||
&__updated {
|
||||
flex: 1 1 auto;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
&__poll-question {
|
||||
background: $colorBodyFg;
|
||||
color: $colorBodyBg;
|
||||
border-radius: $controlCr;
|
||||
font-weight: bold;
|
||||
padding: $interiorMarginSm $interiorMargin;
|
||||
|
||||
.c-user-control-panel--admin & {
|
||||
background: rgba($colorBodyFg, 0.1);
|
||||
color: $colorBodyFg;
|
||||
}
|
||||
}
|
||||
|
||||
/*************************************************** ADMIN INTERFACE */
|
||||
&__content {
|
||||
$m: $interiorMargin;
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
grid-column-gap: $m;
|
||||
grid-row-gap: $m;
|
||||
|
||||
[class*='__label'] {
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
[class*='__label'] {
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
[class*='__poll-table'] {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
[class*='new-question'] {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
> * + * {
|
||||
margin-left: $interiorMargin;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1 1 auto;
|
||||
height: $btnStdH;
|
||||
}
|
||||
|
||||
button {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**************** STYLES FOR THE MISSION STATUS USER CONTROL PANEL */
|
||||
.c-ucp-mission-status {
|
||||
$bg: rgba(black, 0.7);
|
||||
display: grid;
|
||||
grid-template-columns: max-content max-content 1fr;
|
||||
align-items: center;
|
||||
grid-column-gap: $interiorMarginLg;
|
||||
grid-row-gap: $interiorMargin;
|
||||
|
||||
&__widget {
|
||||
border-radius: $basicCr;
|
||||
background: $bg;
|
||||
border: 1px solid transparent;
|
||||
padding: $interiorMarginSm $interiorMarginLg;
|
||||
text-align: center;
|
||||
|
||||
&.--is-go {
|
||||
$c: #2c7527;
|
||||
background: $c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.--is-no-go {
|
||||
$c: #fbc147;
|
||||
background: $bg;
|
||||
border: 1px solid $c;
|
||||
color: $c;
|
||||
}
|
||||
}
|
||||
}
|
@ -221,6 +221,10 @@ button {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
}
|
||||
|
||||
&--sm {
|
||||
padding: $interiorMarginSm $interiorMargin;
|
||||
}
|
||||
}
|
||||
|
||||
.c-list-button {
|
||||
|
@ -98,6 +98,7 @@ body.desktop {
|
||||
}
|
||||
|
||||
div,
|
||||
ul,
|
||||
span {
|
||||
// Firefox
|
||||
scrollbar-color: $scrollbarThumbColor $scrollbarTrackColorBg;
|
||||
|
@ -57,7 +57,8 @@
|
||||
@import '../plugins/notebook/components/sidebar.scss';
|
||||
@import '../plugins/gauge/gauge.scss';
|
||||
@import '../plugins/faultManagement/fault-manager.scss';
|
||||
@import '../plugins/operatorStatus/operator-status';
|
||||
@import '../plugins/operatorStatus/operator-status.scss';
|
||||
@import '../plugins/userIndicator/user-indicator.scss';
|
||||
@import '../plugins/inspectorDataVisualization/inspector-data-visualization.scss';
|
||||
|
||||
#splash-screen {
|
||||
|
77
src/ui/composables/event.js
Normal file
77
src/ui/composables/event.js
Normal file
@ -0,0 +1,77 @@
|
||||
/*****************************************************************************
|
||||
* 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.
|
||||
*****************************************************************************/
|
||||
/* eslint-disable func-style */
|
||||
|
||||
import { isRef, onBeforeMount, onBeforeUnmount, onMounted, watch } from 'vue';
|
||||
|
||||
/**
|
||||
* Registers an event listener on the specified target and automatically removes it when the
|
||||
* component is unmounted.
|
||||
* This is a Vue composition API utility function.
|
||||
* @param {EventTarget} target - The target to attach the event listener to.
|
||||
* @param {string} event - The name of the event to listen for.
|
||||
* @param {Function} handler - The callback function to execute when the event is triggered.
|
||||
*/
|
||||
export function useEventListener(target, event, handler) {
|
||||
const addListener = (el) => {
|
||||
if (el) {
|
||||
el.addEventListener(event, handler);
|
||||
}
|
||||
};
|
||||
|
||||
const removeListener = (el) => {
|
||||
if (el) {
|
||||
el.removeEventListener(event, handler);
|
||||
}
|
||||
};
|
||||
|
||||
// If target is a reactive ref, watch it for changes
|
||||
if (isRef(target)) {
|
||||
watch(
|
||||
target,
|
||||
(newTarget, oldTarget) => {
|
||||
if (newTarget !== oldTarget) {
|
||||
removeListener(oldTarget);
|
||||
addListener(newTarget);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
} else {
|
||||
// Otherwise use lifecycle hooks to add/remove listener
|
||||
onMounted(() => addListener(target));
|
||||
onBeforeUnmount(() => removeListener(target));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers an event listener on the specified EventEmitter instance and automatically removes it
|
||||
* when the component is unmounted.
|
||||
* This is a Vue composition API utility function.
|
||||
* @param {import('eventemitter3').EventEmitter} emitter - The EventEmitter instance to attach the event listener to.
|
||||
* @param {string} event - The name of the event to listen for.
|
||||
* @param {Function} callback - The callback function to execute when the event is triggered.
|
||||
*/
|
||||
export function useEventEmitter(emitter, event, callback) {
|
||||
onBeforeMount(() => emitter.on(event, callback));
|
||||
onBeforeUnmount(() => emitter.off(event, callback));
|
||||
}
|
80
src/ui/composables/resize.js
Normal file
80
src/ui/composables/resize.js
Normal file
@ -0,0 +1,80 @@
|
||||
/*****************************************************************************
|
||||
* 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.
|
||||
*****************************************************************************/
|
||||
/* eslint-disable func-style */
|
||||
import { onBeforeUnmount, reactive } from 'vue';
|
||||
|
||||
import throttle from '../../utils/throttle.js';
|
||||
import { useEventListener } from './event.js';
|
||||
|
||||
/**
|
||||
* A composable which provides a function to begin observing the size of the passed-in element,
|
||||
* and a reactive object containing the width and height of the observed element. The ResizeObserver
|
||||
* is automatically disconnected before the component is unmounted.
|
||||
* @returns {{size: {width: number, height: number}, startObserving: (element: HTMLElement) => void}}
|
||||
*/
|
||||
export function useResizeObserver() {
|
||||
const size = reactive({ width: 0, height: 0 });
|
||||
let observer;
|
||||
|
||||
const startObserving = (element) => {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
observer = new ResizeObserver((entries) => {
|
||||
if (entries[0]) {
|
||||
const { width, height } = entries[0].contentRect;
|
||||
size.width = width;
|
||||
size.height = height;
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(element);
|
||||
};
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
return { size, startObserving };
|
||||
}
|
||||
|
||||
/**
|
||||
* A composable function which can be used to listen to and handle window resize events.
|
||||
* Throttles the resize event to prevent performance issues.
|
||||
* @param {number} [throttleMs=100] The number of milliseconds to throttle the resize event.
|
||||
* @returns {Ref<{ width: number, height: number }>} windowSize
|
||||
*/
|
||||
export function useWindowResize(throttleMs = 100) {
|
||||
const windowSize = reactive({ width: window.innerWidth, height: window.innerHeight });
|
||||
|
||||
const handleResize = throttle(() => {
|
||||
windowSize.width = window.innerWidth;
|
||||
windowSize.height = window.innerHeight;
|
||||
}, throttleMs);
|
||||
|
||||
useEventListener(window, 'resize', handleResize);
|
||||
|
||||
return { windowSize };
|
||||
}
|
Loading…
Reference in New Issue
Block a user