fix: DisplayLayout and FlexibleLayout toolbar actions only apply to selected layout (#7184)

* refactor: convert to ES6 function

* fix: include `keyString` in event name

- This negates the need for complicated logic in determining which objectView the action was intended for

* fix: handle the case of currentView being null

* fix: add keyString to flexibleLayout toolbar events

* fix: properly unregister listeners

* fix: remove unused imports

* fix: revert parameter reorder

* refactor: replace usage of `arguments` with `...args`

* fix: add a11y to display layout + toolbar

* test: add first cut of layout toolbar suite

* test: cleanup a bit and add Image test

* test: add stubs

* fix: remove unused variable

* refactor(DisplayLayoutToolbar): convert to ES6 class

* test: generate localStorage data for display layout tests

* fix: clarify "Add" button label

* test: cleanup and don't parameterize tests

* test: fix path for recycled_local_storage.json

* fix: path to local storage file

* docs: add documentation for
utilizing localStorage in e2e

* fix: path to recycled_local_storage.json

* docs: add note hyperlink
This commit is contained in:
Jesse Mazzella 2023-11-02 13:42:37 -07:00 committed by GitHub
parent bdff210a9c
commit 02f1013770
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 1175 additions and 858 deletions

View File

@ -193,7 +193,7 @@ Current list of test tags:
|`@ipad` | Test case or test suite is compatible with Playwright's iPad support and Open MCT's read-only mobile view (i.e. no create button).| |`@ipad` | Test case or test suite is compatible with Playwright's iPad support and Open MCT's read-only mobile view (i.e. no create button).|
|`@gds` | Denotes a GDS Test Case used in the VIPER Mission.| |`@gds` | Denotes a GDS Test Case used in the VIPER Mission.|
|`@addInit` | Initializes the browser with an injected and artificial state. Useful for loading non-default plugins. Likely will not work outside of `npm start`.| |`@addInit` | Initializes the browser with an injected and artificial state. Useful for loading non-default plugins. Likely will not work outside of `npm start`.|
|`@localStorage` | Captures or generates session storage to manipulate browser state. Useful for excluding in tests which require a persistent backend (i.e. CouchDB).| |`@localStorage` | Captures or generates session storage to manipulate browser state. Useful for excluding in tests which require a persistent backend (i.e. CouchDB). See [note](#utilizing-localstorage)|
|`@snapshot` | Uses Playwright's snapshot functionality to record a copy of the DOM for direct comparison. Must be run inside of the playwright container.| |`@snapshot` | Uses Playwright's snapshot functionality to record a copy of the DOM for direct comparison. Must be run inside of the playwright container.|
|`@unstable` | A new test or test which is known to be flaky.| |`@unstable` | A new test or test which is known to be flaky.|
|`@2p` | Indicates that multiple users are involved, or multiple tabs/pages are used. Useful for testing multi-user interactivity.| |`@2p` | Indicates that multiple users are involved, or multiple tabs/pages are used. Useful for testing multi-user interactivity.|
@ -352,6 +352,28 @@ By adhering to this principle, we can create tests that are both robust and refl
1. Avoid repeated setup to test a single assertion. Write longer tests with multiple soft assertions. 1. Avoid repeated setup to test a single assertion. Write longer tests with multiple soft assertions.
This ensures that your changes will be picked up with large refactors. This ensures that your changes will be picked up with large refactors.
##### Utilizing LocalStorage
1. In order to save test runtime in the case of tests that require a decent amount of initial setup (such as in the case of testing complex displays), you may use [Playwright's `storageState` feature](https://playwright.dev/docs/api/class-browsercontext#browser-context-storage-state) to generate and load localStorage states.
1. To generate a localStorage state to be used in a test:
- Add an e2e test to our generateLocalStorageData suite which sets the initial state (creating/configuring objects, etc.), saving it in the `test-data` folder:
```js
// Save localStorage for future test execution
await context.storageState({
path: path.join(__dirname, '../../../e2e/test-data/display_layout_with_child_layouts.json')
});
```
- Load the state from file at the beginning of the desired test suite (within the `test.describe()`). (NOTE: the storage state will be used for each test in the suite, so you may need to create a new suite):
```js
const LOCALSTORAGE_PATH = path.resolve(
__dirname,
'../../../../test-data/display_layout_with_child_layouts.json'
);
test.use({
storageState: path.resolve(__dirname, LOCALSTORAGE_PATH)
});
```
### How to write a great test ### How to write a great test
- Avoid using css locators to find elements to the page. Use modern web accessible locators like `getByRole` - Avoid using css locators to find elements to the page. Use modern web accessible locators like `getByRole`

File diff suppressed because one or more lines are too long

View File

@ -55,6 +55,42 @@ test.describe('Generate Visual Test Data @localStorage @generatedata', () => {
await page.goto('./', { waitUntil: 'domcontentloaded' }); await page.goto('./', { waitUntil: 'domcontentloaded' });
}); });
test('Generate display layout with 2 child display layouts', async ({ page, context }) => {
// Create Display Layout
const parent = await createDomainObjectWithDefaults(page, {
type: 'Display Layout',
name: 'Parent Display Layout'
});
const child1 = await createDomainObjectWithDefaults(page, {
type: 'Display Layout',
name: 'Child Layout 1',
parent: parent.uuid
});
const child2 = await createDomainObjectWithDefaults(page, {
type: 'Display Layout',
name: 'Child Layout 2',
parent: parent.uuid
});
await page.goto(parent.url);
await page.getByLabel('Edit').click();
await page.getByLabel(`${child2.name} Layout Grid`).hover();
await page.getByLabel('Move Sub-object Frame').nth(1).click();
await page.getByLabel('X:').fill('30');
await page.getByLabel(`${child1.name} Layout Grid`).hover();
await page.getByLabel('Move Sub-object Frame').first().click();
await page.getByLabel('Y:').fill('30');
await page.getByLabel('Save').click();
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
//Save localStorage for future test execution
await context.storageState({
path: path.join(__dirname, '../../../e2e/test-data/display_layout_with_child_layouts.json')
});
});
// TODO: Visual test for the generated object here // TODO: Visual test for the generated object here
// - Move to using appActions to create the overlay plot // - Move to using appActions to create the overlay plot
// and embedded standard telemetry object // and embedded standard telemetry object

View File

@ -19,7 +19,7 @@
* this source code distribution or the Licensing information page available * this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information. * at runtime from the About dialog for additional information.
*****************************************************************************/ *****************************************************************************/
/* global __dirname */
/* /*
This test suite is dedicated to tests which verify the basic operations surrounding conditionSets. Note: this This test suite is dedicated to tests which verify the basic operations surrounding conditionSets. Note: this
suite is sharing state between tests which is considered an anti-pattern. Implementing in this way to suite is sharing state between tests which is considered an anti-pattern. Implementing in this way to
@ -31,6 +31,7 @@ const {
createDomainObjectWithDefaults, createDomainObjectWithDefaults,
createExampleTelemetryObject createExampleTelemetryObject
} = require('../../../../appActions'); } = require('../../../../appActions');
const path = require('path');
let conditionSetUrl; let conditionSetUrl;
let getConditionSetIdentifierFromUrl; let getConditionSetIdentifierFromUrl;
@ -48,7 +49,9 @@ test.describe.serial('Condition Set CRUD Operations on @localStorage', () => {
await Promise.all([page.waitForNavigation(), page.click('button:has-text("OK")')]); await Promise.all([page.waitForNavigation(), page.click('button:has-text("OK")')]);
//Save localStorage for future test execution //Save localStorage for future test execution
await context.storageState({ path: './e2e/test-data/recycled_local_storage.json' }); await context.storageState({
path: path.resolve(__dirname, '../../../../test-data/recycled_local_storage.json')
});
//Set object identifier from url //Set object identifier from url
conditionSetUrl = page.url(); conditionSetUrl = page.url();
@ -59,7 +62,9 @@ test.describe.serial('Condition Set CRUD Operations on @localStorage', () => {
}); });
//Load localStorage for subsequent tests //Load localStorage for subsequent tests
test.use({ storageState: './e2e/test-data/recycled_local_storage.json' }); test.use({
storageState: path.resolve(__dirname, '../../../../test-data/recycled_local_storage.json')
});
//Begin suite of tests again localStorage //Begin suite of tests again localStorage
test('Condition set object properties persist in main view and inspector @localStorage', async ({ test('Condition set object properties persist in main view and inspector @localStorage', async ({

View File

@ -19,8 +19,9 @@
* this source code distribution or the Licensing information page available * this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information. * at runtime from the About dialog for additional information.
*****************************************************************************/ *****************************************************************************/
/* global __dirname */
const { test, expect } = require('../../../../pluginFixtures'); const { test, expect } = require('../../../../pluginFixtures');
const path = require('path');
const { const {
createDomainObjectWithDefaults, createDomainObjectWithDefaults,
setStartOffset, setStartOffset,
@ -29,6 +30,88 @@ const {
setIndependentTimeConductorBounds setIndependentTimeConductorBounds
} = require('../../../../appActions'); } = require('../../../../appActions');
const LOCALSTORAGE_PATH = path.resolve(
__dirname,
'../../../../test-data/display_layout_with_child_layouts.json'
);
const TINY_IMAGE_BASE64 =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII';
test.describe('Display Layout Toolbar Actions', () => {
const PARENT_DISPLAY_LAYOUT_NAME = 'Parent Display Layout';
const CHILD_DISPLAY_LAYOUT_NAME1 = 'Child Layout 1';
test.beforeEach(async ({ page }) => {
await page.goto('./', { waitUntil: 'domcontentloaded' });
await setRealTimeMode(page);
await page
.locator('a')
.filter({ hasText: 'Parent Display Layout Display Layout' })
.first()
.click();
await page.getByLabel('Edit').click();
});
test.use({
storageState: path.resolve(__dirname, LOCALSTORAGE_PATH)
});
test('can add/remove Text element to a single layout', async ({ page }) => {
const layoutObject = 'Text';
await test.step(`Add and remove ${layoutObject} from the parent's layout`, async () => {
await addAndRemoveDrawingObjectAndAssert(page, layoutObject, PARENT_DISPLAY_LAYOUT_NAME);
});
await test.step(`Add and remove ${layoutObject} from the child's layout`, async () => {
await addAndRemoveDrawingObjectAndAssert(page, layoutObject, CHILD_DISPLAY_LAYOUT_NAME1);
});
});
test('can add/remove Image to a single layout', async ({ page }) => {
const layoutObject = 'Image';
await test.step("Add and remove image element from the parent's layout", async () => {
expect(await page.getByLabel(`Move ${layoutObject} Frame`).count()).toBe(0);
await addLayoutObject(page, PARENT_DISPLAY_LAYOUT_NAME, layoutObject);
expect(await page.getByLabel(`Move ${layoutObject} Frame`).count()).toBe(1);
await removeLayoutObject(page, layoutObject);
expect(await page.getByLabel(`Move ${layoutObject} Frame`).count()).toBe(0);
});
await test.step("Add and remove image from the child's layout", async () => {
await addLayoutObject(page, CHILD_DISPLAY_LAYOUT_NAME1, layoutObject);
expect(await page.getByLabel(`Move ${layoutObject} Frame`).count()).toBe(1);
await removeLayoutObject(page, layoutObject);
expect(await page.getByLabel(`Move ${layoutObject} Frame`).count()).toBe(0);
});
});
test(`can add/remove Box to a single layout`, async ({ page }) => {
const layoutObject = 'Box';
await test.step(`Add and remove ${layoutObject} from the parent's layout`, async () => {
await addAndRemoveDrawingObjectAndAssert(page, layoutObject, PARENT_DISPLAY_LAYOUT_NAME);
});
await test.step(`Add and remove ${layoutObject} from the child's layout`, async () => {
await addAndRemoveDrawingObjectAndAssert(page, layoutObject, CHILD_DISPLAY_LAYOUT_NAME1);
});
});
test(`can add/remove Line to a single layout`, async ({ page }) => {
const layoutObject = 'Line';
await test.step(`Add and remove ${layoutObject} from the parent's layout`, async () => {
await addAndRemoveDrawingObjectAndAssert(page, layoutObject, PARENT_DISPLAY_LAYOUT_NAME);
});
await test.step(`Add and remove ${layoutObject} from the child's layout`, async () => {
await addAndRemoveDrawingObjectAndAssert(page, layoutObject, CHILD_DISPLAY_LAYOUT_NAME1);
});
});
test(`can add/remove Ellipse to a single layout`, async ({ page }) => {
const layoutObject = 'Ellipse';
await test.step(`Add and remove ${layoutObject} from the parent's layout`, async () => {
await addAndRemoveDrawingObjectAndAssert(page, layoutObject, PARENT_DISPLAY_LAYOUT_NAME);
});
await test.step(`Add and remove ${layoutObject} from the child's layout`, async () => {
await addAndRemoveDrawingObjectAndAssert(page, layoutObject, CHILD_DISPLAY_LAYOUT_NAME1);
});
});
test.fixme('Can switch view types of a single SWG in a layout', async ({ page }) => {});
test.fixme('Can merge multiple plots in a layout', async ({ page }) => {});
test.fixme('Can adjust stack order of a single object in a layout', async ({ page }) => {});
test.fixme('Can duplicate a single object in a layout', async ({ page }) => {});
});
test.describe('Display Layout', () => { test.describe('Display Layout', () => {
/** @type {import('../../../../appActions').CreatedObjectInfo} */ /** @type {import('../../../../appActions').CreatedObjectInfo} */
let sineWaveObject; let sineWaveObject;
@ -41,6 +124,7 @@ test.describe('Display Layout', () => {
type: 'Sine Wave Generator' type: 'Sine Wave Generator'
}); });
}); });
test('alpha-numeric widget telemetry value exactly matches latest telemetry value received in real time', async ({ test('alpha-numeric widget telemetry value exactly matches latest telemetry value received in real time', async ({
page page
}) => { }) => {
@ -339,6 +423,59 @@ test.describe('Display Layout', () => {
}); });
}); });
async function addAndRemoveDrawingObjectAndAssert(page, layoutObject, DISPLAY_LAYOUT_NAME) {
expect(await page.getByLabel(layoutObject, { exact: true }).count()).toBe(0);
await addLayoutObject(page, DISPLAY_LAYOUT_NAME, layoutObject);
expect(
await page
.getByLabel(layoutObject, {
exact: true
})
.count()
).toBe(1);
await removeLayoutObject(page, layoutObject);
expect(await page.getByLabel(layoutObject, { exact: true }).count()).toBe(0);
}
/**
* Remove the first matching layout object from the layout
* @param {import('@playwright/test').Page} page
* @param {'Box' | 'Ellipse' | 'Line' | 'Text' | 'Image'} layoutObject
*/
async function removeLayoutObject(page, layoutObject) {
await page
.getByLabel(`Move ${layoutObject} Frame`, { exact: true })
.or(page.getByLabel(layoutObject, { exact: true }))
.first()
// eslint-disable-next-line playwright/no-force-option
.click({ force: true });
await page.getByTitle('Delete the selected object').click();
await page.getByRole('button', { name: 'OK' }).click();
}
/**
* Add a layout object to the specified layout
* @param {import('@playwright/test').Page} page
* @param {string} layoutName
* @param {'Box' | 'Ellipse' | 'Line' | 'Text' | 'Image'} layoutObject
*/
async function addLayoutObject(page, layoutName, layoutObject) {
await page.getByLabel(`${layoutName} Layout Grid`).click();
await page.getByText('Add Drawing Object').click();
await page
.getByRole('menuitem', {
name: layoutObject
})
.click();
if (layoutObject === 'Text') {
await page.getByRole('textbox', { name: 'Text' }).fill('Hello, Universe!');
await page.getByText('OK').click();
} else if (layoutObject === 'Image') {
await page.getByLabel('Image URL').fill(TINY_IMAGE_BASE64);
await page.getByText('OK').click();
}
}
/** /**
* Util for subscribing to a telemetry object by object identifier * Util for subscribing to a telemetry object by object identifier
* Limitations: Currently only works to return telemetry once to the node scope * Limitations: Currently only works to return telemetry once to the node scope

File diff suppressed because it is too large Load Diff

View File

@ -33,6 +33,9 @@
class="c-box-view u-style-receiver js-style-receiver" class="c-box-view u-style-receiver js-style-receiver"
:class="[styleClass]" :class="[styleClass]"
:style="style" :style="style"
role="application"
aria-roledescription="draggable box"
aria-label="Box"
></div> ></div>
</template> </template>
</layout-frame> </layout-frame>

View File

@ -36,6 +36,8 @@
:grid-size="gridSize" :grid-size="gridSize"
:show-grid="showGrid" :show-grid="showGrid"
:grid-dimensions="gridDimensions" :grid-dimensions="gridDimensions"
:aria-label="`${domainObject.name} Layout Grid`"
:aria-hidden="showGrid ? 'false' : 'true'"
/> />
<div <div
v-if="shouldDisplayLayoutDimensions" v-if="shouldDisplayLayoutDimensions"

View File

@ -20,7 +20,14 @@
at runtime from the About dialog for additional information. at runtime from the About dialog for additional information.
--> -->
<template> <template>
<div class="l-layout__grid-holder" :class="{ 'c-grid': showGrid }"> <div
class="l-layout__grid-holder"
:class="{ 'c-grid': showGrid }"
role="grid"
:aria-label="'Layout Grid'"
:aria-hidden="showGrid ? 'false' : 'true'"
:aria-live="showGrid ? 'polite' : 'off'"
>
<div <div
v-if="gridSize[0] >= 3" v-if="gridSize[0] >= 3"
class="c-grid__x l-grid l-grid-x" class="c-grid__x l-grid l-grid-x"

View File

@ -33,6 +33,9 @@
class="c-ellipse-view u-style-receiver js-style-receiver" class="c-ellipse-view u-style-receiver js-style-receiver"
:class="[styleClass]" :class="[styleClass]"
:style="style" :style="style"
role="application"
aria-roledescription="draggable ellipse"
aria-label="Ellipse"
></div> ></div>
</template> </template>
</layout-frame> </layout-frame>

View File

@ -30,13 +30,18 @@
:style="style" :style="style"
> >
<slot name="content"></slot> <slot name="content"></slot>
<div class="c-frame__move-bar" @mousedown.left="startMove($event)"></div> <div
class="c-frame__move-bar"
:aria-label="`Move ${typeName} Frame`"
@mousedown.left="startMove($event)"
></div>
</div> </div>
</template> </template>
<script> <script>
import _ from 'lodash'; import _ from 'lodash';
import DRAWING_OBJECT_TYPES from '../DrawingObjectTypes';
import LayoutDrag from './../LayoutDrag'; import LayoutDrag from './../LayoutDrag';
export default { export default {
@ -58,6 +63,13 @@ export default {
}, },
emits: ['move', 'end-move'], emits: ['move', 'end-move'],
computed: { computed: {
typeName() {
const { type } = this.item;
if (DRAWING_OBJECT_TYPES[type]) {
return DRAWING_OBJECT_TYPES[type].name;
}
return 'Sub-object';
},
size() { size() {
let { width, height } = this.item; let { width, height } = this.item;

View File

@ -21,7 +21,14 @@
--> -->
<template> <template>
<div class="l-layout__frame c-frame no-frame c-line-view" :class="[styleClass]" :style="style"> <div
class="l-layout__frame c-frame no-frame c-line-view"
:class="[styleClass]"
:style="style"
aria-role="application"
aria-roledescription="draggable line"
aria-label="Line"
>
<svg width="100%" height="100%"> <svg width="100%" height="100%">
<line <line
v-bind="linePosition" v-bind="linePosition"

View File

@ -35,6 +35,9 @@
:data-font="item.font" :data-font="item.font"
:class="[styleClass]" :class="[styleClass]"
:style="style" :style="style"
role="application"
aria-roledescription="draggable text"
aria-label="Text"
> >
<div class="c-text-view__text">{{ item.text }}</div> <div class="c-text-view__text">{{ item.text }}</div>
</div> </div>

View File

@ -85,10 +85,9 @@ class DisplayLayoutView {
}; };
} }
contextAction() { contextAction(action, ...rest) {
const action = arguments[0]; if (this?.component.$refs.displayLayout[action]) {
if (this.component && this.component.$refs.displayLayout[action]) { this.component.$refs.displayLayout[action](...rest);
this.component.$refs.displayLayout[action](...Array.from(arguments).splice(1));
} }
} }

View File

@ -81,10 +81,9 @@ export default class FlexibleLayoutViewProvider {
type: 'flexible-layout' type: 'flexible-layout'
}; };
}, },
contextAction() { contextAction(action, ...args) {
const action = arguments[0]; if (component?.$refs?.flexibleLayout?.[action]) {
if (component && component.$refs.flexibleLayout[action]) { component.$refs.flexibleLayout[action](...args);
component.$refs.flexibleLayout[action](...Array.from(arguments).splice(1));
} }
}, },
onEditModeChange(isEditing) { onEditModeChange(isEditing) {

View File

@ -84,7 +84,8 @@ function ToolbarProvider(openmct) {
let containerIndex = containers.indexOf(container); let containerIndex = containers.indexOf(container);
let frame = container && container.frames.filter((f) => f.id === frameId)[0]; let frame = container && container.frames.filter((f) => f.id === frameId)[0];
let frameIndex = container && container.frames.indexOf(frame); let frameIndex = container && container.frames.indexOf(frame);
const primaryKeyString = openmct.objects.makeKeyString(primary.context.item.identifier);
const tertiaryKeyString = openmct.objects.makeKeyString(tertiary.context.item.identifier);
deleteFrame = { deleteFrame = {
control: 'button', control: 'button',
domainObject: primary.context.item, domainObject: primary.context.item,
@ -98,7 +99,7 @@ function ToolbarProvider(openmct) {
emphasis: 'true', emphasis: 'true',
callback: function () { callback: function () {
openmct.objectViews.emit( openmct.objectViews.emit(
'contextAction', `contextAction:${primaryKeyString}`,
'deleteFrame', 'deleteFrame',
primary.context.frameId primary.context.frameId
); );
@ -135,11 +136,12 @@ function ToolbarProvider(openmct) {
} }
] ]
}; };
addContainer = { addContainer = {
control: 'button', control: 'button',
domainObject: tertiary.context.item, domainObject: tertiary.context.item,
method: function () { method: function (...args) {
openmct.objectViews.emit('contextAction', 'addContainer', ...arguments); openmct.objectViews.emit(`contextAction:${tertiaryKeyString}`, 'addContainer', ...args);
}, },
key: 'add', key: 'add',
icon: 'icon-plus-in-rect', icon: 'icon-plus-in-rect',
@ -185,11 +187,14 @@ function ToolbarProvider(openmct) {
title: 'Remove Container' title: 'Remove Container'
}; };
const domainObject = secondary.context.item;
const keyString = openmct.objects.makeKeyString(domainObject.identifier);
addContainer = { addContainer = {
control: 'button', control: 'button',
domainObject: secondary.context.item, domainObject,
method: function () { method: function (...args) {
openmct.objectViews.emit('contextAction', 'addContainer', ...arguments); openmct.objectViews.emit(`contextAction:${keyString}`, 'addContainer', ...args);
}, },
key: 'add', key: 'add',
icon: 'icon-plus-in-rect', icon: 'icon-plus-in-rect',
@ -200,11 +205,14 @@ function ToolbarProvider(openmct) {
return []; return [];
} }
const domainObject = primary.context.item;
const keyString = openmct.objects.makeKeyString(domainObject.identifier);
addContainer = { addContainer = {
control: 'button', control: 'button',
domainObject: primary.context.item, domainObject,
method: function () { method: function (...args) {
openmct.objectViews.emit('contextAction', 'addContainer', ...arguments); openmct.objectViews.emit(`contextAction:${keyString}`, 'addContainer', ...args);
}, },
key: 'add', key: 'add',
icon: 'icon-plus-in-rect', icon: 'icon-plus-in-rect',

View File

@ -34,9 +34,6 @@ import { STYLE_CONSTANTS } from '@/plugins/condition/utils/constants';
import stalenessMixin from '@/ui/mixins/staleness-mixin'; import stalenessMixin from '@/ui/mixins/staleness-mixin';
export default { export default {
components: {
// IndependentTimeConductor
},
mixins: [stalenessMixin], mixins: [stalenessMixin],
inject: ['openmct'], inject: ['openmct'],
props: { props: {
@ -181,7 +178,9 @@ export default {
this.triggerUnsubscribeFromStaleness(this.domainObject); this.triggerUnsubscribeFromStaleness(this.domainObject);
this.openmct.objectViews.off('clearData', this.clearData); this.openmct.objectViews.off('clearData', this.clearData);
this.openmct.objectViews.off('contextAction', this.performContextAction); if (this.contextActionEvent) {
this.openmct.objectViews.off(this.contextActionEvent, this.performContextAction);
}
}, },
getStyleReceiver() { getStyleReceiver() {
let styleReceiver; let styleReceiver;
@ -301,8 +300,11 @@ export default {
); );
} }
this.contextActionEvent = `contextAction:${this.openmct.objects.makeKeyString(
this.domainObject.identifier
)}`;
this.openmct.objectViews.on('clearData', this.clearData); this.openmct.objectViews.on('clearData', this.clearData);
this.openmct.objectViews.on('contextAction', this.performContextAction); this.openmct.objectViews.on(this.contextActionEvent, this.performContextAction);
this.$nextTick(() => { this.$nextTick(() => {
this.updateStyle(this.styleRuleManager?.currentStyle); this.updateStyle(this.styleRuleManager?.currentStyle);
@ -473,9 +475,9 @@ export default {
} }
} }
}, },
performContextAction() { performContextAction(...args) {
if (this.currentView.contextAction) { if (this?.currentView?.contextAction) {
this.currentView.contextAction(...arguments); this.currentView.contextAction(...args);
} }
}, },
isEditingAllowed() { isEditingAllowed() {

View File

@ -31,15 +31,19 @@
{{ options.label }} {{ options.label }}
</div> </div>
</div> </div>
<div v-if="open" class="c-menu"> <div v-if="open" class="c-menu" role="menu">
<ul> <ul>
<li <li
v-for="(option, index) in options.options" v-for="(option, index) in options.options"
:key="index" :key="index"
:class="option.class" :class="option.class"
role="menuitem"
:aria-labelledby="`${option.name}-menuitem-label`"
@click="onClick(option)" @click="onClick(option)"
> >
{{ option.name }} <span :id="`${option.name}-menuitem-label`">
{{ option.name }}
</span>
</li> </li>
</ul> </ul>
</div> </div>