Compare commits

..

21 Commits

Author SHA1 Message Date
Andrew Henry
4cc0a9e402 Support binary websockets 2024-01-26 10:35:34 -08:00
Andrew Henry
12b921a006 Clean up and reconnect on websocket close 2024-01-24 08:24:26 -08:00
Andrew Henry
34b36d544a Merge branch 'master' into subscriptions-support-array-callbacks 2024-01-24 07:58:14 -08:00
Andrew Henry
e135498401 Addressed review comments 2024-01-24 07:54:10 -08:00
Jesse Mazzella
114864429a feat(#7394): Incorporate Status Indicators into the main Vue app (#7395)
* feat(IndicatorAPI): accept Vue components

- Adds a new property to Indicators, `component`, which is a synchronous or asynchronous Vue component.
- Adds `wrapHtmlElement` utility function to create anonymous Vue components out of `HTMLElement`s (for backwards compatibility)
- Refactors StatusIndicators.vue to use dynamic components, allowing us to dynamically render indicators (and keep it all within Vue's ecosystem).

* refactor(indicators): use dynamic Vue components instead of `mount()`

- Refactors some indicators to use Vue components directly as async components

* refactor: use Vue reactivity for timestamps in clock indicator

* fix(test): fix unit tests and remove some console logs

* test(e2e): stabilize ladSet e2e test

* test: mix in some Vue indicators in indicatorSpec

* refactor: cleanup variable names

* docs: update IndicatorAPI docs

* fix(e2e): wait for async status bar components to load before snapshot

* a11y(e2e): add aria-labels and wait for status bar to load

* test(e2e): add exact: true

* fix: initializing indicators

* fix(typo): uhhh.. how did that get there? O_o

* fix: use synchronous components for default indicators

* test: clean up, remove unnecessary `nextTick()`s

* test: remove more `nextTick()`s

* refactor: lint:fix

* fix: `on` -> `off`

* test(e2e): stabilize tabs test

* test(e2e): attempt to stabilize limit lines tests with `toHaveCount()` assertion
2024-01-23 23:15:22 +00:00
John Hill
4cf63062c0 Mct7367-tests (#7387)
* refactor(ExportAsJSONAction): use private methods

* refactor: remove unnecessary webpack alias

* refactor: lint

* fix: tests for `ExportAsJSONAction`

* test: stabilize `InspectorStylesSpec` tests

* docs: fix jsdocs

* chore: remove dead / redundant code

* refactor(LocalStorageObjectProvider): use `getItem()` and `setItem()`

* refactor(ExportAsJSONAction): use `Promise.all` where applicable

* refactor(MenuAPI): one-liner

* feat: add percentage ProgressBar to ExportAsJSONAction

* fix(ProgressBar.vue): v-if conditionals

* test(fix): update mockLocalStorage

* test: fix locators

* test: remove unneeded awaits

* fix: example imagery urls (moved after NASA wordpress migration)

* Revert "refactor(LocalStorageObjectProvider): use `getItem()` and `setItem()`"

This reverts commit 4f8403adab.

* test(e2e): fix logPlot test

* Revert "Revert "refactor(LocalStorageObjectProvider): use `getItem()` and `setItem()`""

This reverts commit 0de66401cd.

* test(e2e): remove waitForNavigations

* driveby and fixes

* aria improvement

* getting tests back oline

* more tests

* add last test

* Add a11y

* lint

* lint

* driveby

* review comments

* driveby rename

* fix selectors and break up test suites

* add test for snapshot in header

* last lint fixes

* stable

---------

Co-authored-by: Jesse Mazzella <jesse.d.mazzella@nasa.gov>
2024-01-22 21:41:56 -08:00
Andrew Henry
c4a3ace027 Fixed broken tests 2024-01-22 18:35:15 -08:00
Andrew Henry
ad02ba7ffe Added more documentation 2024-01-22 07:20:13 -08:00
Andrew Henry
0bdd5efcba Renamed classes 2024-01-20 19:18:15 -08:00
Andrew Henry
2e3dc4da9a renamed class. changed throttling strategy to be driven by the main thread 2024-01-20 17:54:33 -08:00
Andrew Henry
61edd0f810 Adding docs 2024-01-19 15:44:27 -08:00
Andrew Henry
4c86b66624 Merge branch 'master' into subscriptions-support-array-callbacks 2024-01-19 13:17:21 -08:00
Andrew Henry
f079c3a3b9 Renamed BatchingWebSocketProvider to BatchingWebSocket 2024-01-19 13:15:30 -08:00
Andrew Henry
947810b5d7 Added copyright statement 2024-01-18 17:43:10 -08:00
Andrew Henry
c28ced5c29 Don't hide original error 2024-01-18 08:53:07 -08:00
Andrew Henry
e530fd8e8b Merge branch 'master' into subscriptions-support-array-callbacks 2024-01-17 12:41:39 -08:00
Andrew Henry
74c1cdf468 Default to latest strategy 2024-01-16 15:41:03 -08:00
Andrew Henry
0061d162e1 Support batch size based throttling 2024-01-16 14:59:23 -08:00
Andrew Henry
2f2af0bac5 Added configurable batch size and throttling rate 2024-01-12 17:00:03 -08:00
Andrew Henry
a87ffee264 Added batching worker 2024-01-11 09:52:26 -08:00
Andrew Henry
69b2f05de2 Support subscription batching from API, Tables, and Plots 2024-01-09 15:26:25 -08:00
53 changed files with 1366 additions and 567 deletions

View File

@@ -492,9 +492,7 @@
"gcov",
"WCAG",
"stackedplot",
"Andale",
"composables",
"composable"
"Andale"
],
"dictionaries": ["npm", "softwareTerms", "node", "html", "css", "bash", "en_US"],
"ignorePaths": [

View File

@@ -284,7 +284,7 @@ async function navigateToObjectWithFixedTimeBounds(page, url, start, end) {
*/
async function openObjectTreeContextMenu(page, url) {
await page.goto(url);
await page.click('button[title="Show selected item in tree"]');
await page.getByLabel('Show selected item in tree').click();
await page.locator('.is-navigated-object').click({
button: 'right'
});

View File

@@ -49,7 +49,7 @@ async function dragAndDropEmbed(page, notebookObject) {
// Navigate to notebook
await page.goto(notebookObject.url);
// Expand the tree to reveal the notebook
await page.click('button[title="Show selected item in tree"]');
await page.getByLabel('Show selected item in tree').click();
// Drag and drop the SWG into the notebook
await page.dragAndDrop(`text=${swg.name}`, NOTEBOOK_DROP_AREA);
await commitEntry(page);

View File

@@ -23,7 +23,8 @@
import {
createDomainObjectWithDefaults,
createNotification,
expandEntireTree
expandEntireTree,
openObjectTreeContextMenu
} from '../../appActions.js';
import { expect, test } from '../../pluginFixtures.js';
@@ -166,4 +167,13 @@ test.describe('AppActions', () => {
const locatorTreeCollapsedItems = locatorTree.locator('role=treeitem[expanded=false]');
expect(await locatorTreeCollapsedItems.count()).toBe(0);
});
test('openObjectTreeContextMenu', async ({ page }) => {
await page.goto('./', { waitUntil: 'domcontentloaded' });
const folder = await createDomainObjectWithDefaults(page, {
type: 'Folder'
});
await openObjectTreeContextMenu(page, folder.url);
await expect(page.getByLabel('Menu')).toBeVisible();
});
});

View File

@@ -24,36 +24,182 @@
This test suite is dedicated to tests which verify the basic operations surrounding exportAsJSON.
*/
// FIXME: Remove this eslint exception once tests are implemented
// eslint-disable-next-line no-unused-vars
import fs from 'fs/promises';
import {
createDomainObjectWithDefaults,
openObjectTreeContextMenu
} from '../../../../appActions.js';
import { expect, test } from '../../../../baseFixtures.js';
import { navigateToFaultManagementWithExample } from '../../../../helper/faultUtils.js';
test.describe('ExportAsJSON', () => {
test.fixme(
'Create a basic object and verify that it can be exported as JSON from Tree',
async ({ page }) => {
//Create domain object
//Save Domain Object
//Verify that the newly created domain object can be exported as JSON from the Tree
}
);
test.fixme(
'Create a basic object and verify that it can be exported as JSON from 3 dot menu',
async ({ page }) => {
//Create domain object
//Save Domain Object
//Verify that the newly created domain object can be exported as JSON from the 3 dot menu
}
);
test.fixme('Verify that a nested Object can be exported as JSON', async ({ page }) => {
// Create 2 objects with hierarchy
// Export as JSON
// Verify Hierarchy
let folder;
test.beforeEach(async ({ page }) => {
// Go to baseURL
await page.goto('./');
// Perform actions to create the domain object
folder = await createDomainObjectWithDefaults(page, {
type: 'Folder',
name: 'e2e folder'
});
});
test('Create a basic object and verify that it can be exported as JSON from Tree', async ({
page
}) => {
// Navigate to the page
await page.goto(folder.url);
// Open context menu and initiate download
await openObjectTreeContextMenu(page, folder.url);
const [download] = await Promise.all([
page.waitForEvent('download'), // Waits for the download event
page.getByLabel('Export as JSON').click() // Triggers the download
]);
// Wait for the download process to complete
const path = await download.path();
// Read the contents of the downloaded file using readFile from fs/promises
const fileContents = await fs.readFile(path, 'utf8');
const jsonData = JSON.parse(fileContents);
// Use the function to retrieve the key
const key = getFirstKeyFromOpenMctJson(jsonData);
// Verify the contents of the JSON file
expect(jsonData.openmct[key]).toHaveProperty('name', 'e2e folder');
expect(jsonData.openmct[key]).toHaveProperty('type', 'folder');
});
test('Create a basic object and verify that it can be exported as JSON from 3 dot menu', async ({
page
}) => {
// Navigate to the page
await page.goto(folder.url);
//3 dot menu
await page.getByLabel('More actions').click();
// Open context menu and initiate download
const [download] = await Promise.all([
page.waitForEvent('download'), // Waits for the download event
page.getByLabel('Export as JSON').click() // Triggers the download
]);
// Read the contents of the downloaded file using readFile from fs/promises
const fileContents = await fs.readFile(await download.path(), 'utf8');
const jsonData = JSON.parse(fileContents);
// Use the function to retrieve the key
const key = getFirstKeyFromOpenMctJson(jsonData);
// Verify the contents of the JSON file
expect(jsonData.openmct[key]).toHaveProperty('name', 'e2e folder');
expect(jsonData.openmct[key]).toHaveProperty('type', 'folder');
});
test('Verify that a nested Object can be exported as JSON', async ({ page }) => {
const timer = await createDomainObjectWithDefaults(page, {
type: 'Timer',
name: 'timer',
parent: folder.uuid
});
// Navigate to the page
await page.goto(timer.url);
//do this against parent folder.url, NOT timer.url child
await openObjectTreeContextMenu(page, folder.url);
// Open context menu and initiate download
const [download] = await Promise.all([
page.waitForEvent('download'), // Waits for the download event
page.getByLabel('Export as JSON').click() // Triggers the download
]);
// Read the contents of the downloaded file
const fileContents = await fs.readFile(await download.path(), 'utf8');
const jsonData = JSON.parse(fileContents);
// Retrieve the keys for folder and timer
const folderKey = getFirstKeyFromOpenMctJson(jsonData);
const timerKey = jsonData.openmct[folderKey].composition[0].key;
// Verify the folder properties
expect(jsonData.openmct[folderKey]).toHaveProperty('name', 'e2e folder');
expect(jsonData.openmct[folderKey]).toHaveProperty('type', 'folder');
// Verify the timer properties
expect(jsonData.openmct[timerKey]).toHaveProperty('name', 'timer');
expect(jsonData.openmct[timerKey]).toHaveProperty('type', 'timer');
// Verify the composition of the folder includes the timer
expect(jsonData.openmct[folderKey].composition).toEqual(
expect.arrayContaining([expect.objectContaining({ key: timerKey })])
);
});
test.fixme(
'Verify that the ExportAsJSON dropdown does not appear for the item X',
async ({ page }) => {
// Other than non-persistable objects
}
);
});
test.describe('ExportAsJSON Disabled Actions', () => {
test.beforeEach(async ({ page }) => {
//Use a Fault Management Object which is not composable
await navigateToFaultManagementWithExample(page);
});
test('Verify that the ExportAsJSON dropdown does not appear for the item X', async ({ page }) => {
await page.getByLabel('More actions').click();
await expect(await page.getByLabel('Export as JSON')).toHaveCount(0);
await page.getByRole('treeitem', { name: 'Fault Management' }).click({
button: 'right'
});
await expect(await page.getByLabel('Export as JSON')).toHaveCount(0);
});
});
test.describe('ExportAsJSON ProgressBar @couchdb', () => {
let folder;
test.beforeEach(async ({ page }) => {
await page.goto('./', { waitUntil: 'networkidle' });
// Perform actions to create the domain object
folder = await createDomainObjectWithDefaults(page, {
type: 'Folder'
});
await createDomainObjectWithDefaults(page, {
type: 'Timer',
parent: folder.uuid
});
await createDomainObjectWithDefaults(page, {
type: 'Timer',
parent: folder.uuid
});
});
test('Verify that the ExportAsJSON action creates a progressbar', async ({ page }) => {
// Navigate to the page
await page.goto(folder.url);
//Export My Items to create a large export
await page.getByRole('treeitem', { name: 'My Items' }).click({ button: 'right' });
// Open context menu and initiate download
await Promise.all([
page.getByRole('progressbar'), // This is just a check for the progress bar
page.getByText(
'Do not navigate away from this page or close this browser tab while this message'
), // This is the text associated with the download
page.waitForEvent('download'), // Waits for the download event
page.getByLabel('Export as JSON').click() // Triggers the download
]);
});
});
/**
* Retrieves the first key from the 'openmct' property of the provided JSON object.
*
* @param {Object} jsonData - The JSON object containing the 'openmct' property.
* @returns {string} The first key found in the 'openmct' object.
* @throws {Error} If no keys are found in the 'openmct' object.
*/
function getFirstKeyFromOpenMctJson(jsonData) {
if (!jsonData.openmct) {
throw new Error("The provided JSON object does not have an 'openmct' property.");
}
const keys = Object.keys(jsonData.openmct);
if (keys.length === 0) {
throw new Error('No keys found in the openmct object');
}
return keys[0];
}

View File

@@ -36,7 +36,7 @@ test.describe('Testing numeric data with inspector data visualization (i.e., dat
await page.goto('./', { waitUntil: 'domcontentloaded' });
});
test('Can click on telemetry and see data in inspector', async ({ page, context }) => {
test('Can click on telemetry and see data in inspector @2p', async ({ page, context }) => {
const exampleDataVisualizationSource = await createDomainObjectWithDefaults(page, {
type: 'Example Data Visualization Source'
});

View File

@@ -53,6 +53,9 @@ test.describe('LAD Table Sets', () => {
await page.goto(ladTableSet.url);
// Wait for the initial value to show after mount
await expect(page.getByLabel('lad value').first()).not.toContainText('---');
const valueFromFirstSineWave = await page.getByLabel('lad value').first().innerText();
const firstSineWaveNumber = parseFloat(valueFromFirstSineWave);
// ensure we have a float value in the cell and it's finite

View File

@@ -0,0 +1,147 @@
/*****************************************************************************
* 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.
*****************************************************************************/
/*
This test suite is dedicated to tests which verify the basic operations surrounding Notebooks.
*/
import fs from 'fs/promises';
import { fileURLToPath } from 'url';
import { createDomainObjectWithDefaults } from '../../../../appActions.js';
import { expect, test } from '../../../../pluginFixtures.js';
const NOTEBOOK_NAME = 'Notebook';
test.describe('Snapshot image tests', () => {
test.beforeEach(async ({ page }) => {
//Navigate to baseURL
await page.goto('./', { waitUntil: 'domcontentloaded' });
// Create Notebook
await createDomainObjectWithDefaults(page, {
type: NOTEBOOK_NAME
});
});
test('Can drop an image onto a notebook and create a new entry', async ({ page }) => {
const imageData = await fs.readFile(
fileURLToPath(
new URL('../../../../../src/images/favicons/favicon-96x96.png', import.meta.url)
)
);
const imageArray = new Uint8Array(imageData);
const fileData = Array.from(imageArray);
const dropTransfer = await page.evaluateHandle((data) => {
const dataTransfer = new DataTransfer();
const file = new File([new Uint8Array(data)], 'favicon-96x96.png', { type: 'image/png' });
dataTransfer.items.add(file);
return dataTransfer;
}, fileData);
await page.dispatchEvent('.c-notebook__drag-area', 'drop', { dataTransfer: dropTransfer });
await page.locator('.c-ne__save-button > button').click();
// be sure that entry was created
await expect(page.getByText('favicon-96x96.png')).toBeVisible();
await page.getByRole('img', { name: 'favicon-96x96.png thumbnail' }).click();
// expect large image to be displayed
await expect(page.getByRole('dialog').getByText('favicon-96x96.png')).toBeVisible();
await page.getByLabel('Close').click();
// drop another image onto the entry
await page.dispatchEvent('.c-snapshots', 'drop', { dataTransfer: dropTransfer });
const secondThumbnail = page.getByRole('img', { name: 'favicon-96x96.png thumbnail' }).nth(1);
await secondThumbnail.waitFor({ state: 'attached' });
// expect two embedded images now
expect(await page.getByRole('img', { name: 'favicon-96x96.png thumbnail' }).count()).toBe(2);
await page.locator('.c-snapshot.c-ne__embed').first().getByTitle('More actions').click();
await page.getByRole('menuitem', { name: /Remove This Embed/ }).click();
await page.getByRole('button', { name: 'Ok', exact: true }).click();
// Ensure that the thumbnail is removed before we assert
await secondThumbnail.waitFor({ state: 'detached' });
// expect one embedded image now as we deleted the other
expect(await page.getByRole('img', { name: 'favicon-96x96.png thumbnail' }).count()).toBe(1);
});
});
test.describe('Snapshot image failure tests', () => {
test.use({ failOnConsoleError: false });
test.beforeEach(async ({ page }) => {
//Navigate to baseURL
await page.goto('./', { waitUntil: 'domcontentloaded' });
// Create Notebook
await createDomainObjectWithDefaults(page, {
type: NOTEBOOK_NAME
});
});
test('Get an error notification when dropping unknown file onto notebook entry', async ({
page
}) => {
// fill Uint8Array array with some garbage data
const garbageData = new Uint8Array(100);
const fileData = Array.from(garbageData);
const dropTransfer = await page.evaluateHandle((data) => {
const dataTransfer = new DataTransfer();
const file = new File([new Uint8Array(data)], 'someGarbage.foo', { type: 'unknown/garbage' });
dataTransfer.items.add(file);
return dataTransfer;
}, fileData);
await page.dispatchEvent('.c-notebook__drag-area', 'drop', { dataTransfer: dropTransfer });
// should have gotten a notification from OpenMCT that we couldn't add it
await expect(page.getByText('Unknown object(s) dropped and cannot embed')).toBeVisible();
});
test('Get an error notification when dropping big files onto notebook entry', async ({
page
}) => {
const garbageSize = 15 * 1024 * 1024; // 15 megabytes
await page.addScriptTag({
// make the garbage client side
content: `window.bigGarbageData = new Uint8Array(${garbageSize})`
});
const bigDropTransfer = await page.evaluateHandle(() => {
const dataTransfer = new DataTransfer();
const file = new File([window.bigGarbageData], 'bigBoy.png', { type: 'image/png' });
dataTransfer.items.add(file);
return dataTransfer;
});
await page.dispatchEvent('.c-notebook__drag-area', 'drop', { dataTransfer: bigDropTransfer });
// should have gotten a notification from OpenMCT that we couldn't add it as it's too big
await expect(page.getByText('unable to embed')).toBeVisible();
});
});

View File

@@ -24,14 +24,8 @@
This test suite is dedicated to tests which verify the basic operations surrounding Notebooks.
*/
import fs from 'fs/promises';
import { fileURLToPath } from 'url';
import { createDomainObjectWithDefaults } from '../../../../appActions.js';
import { expect, test } from '../../../../pluginFixtures.js';
const NOTEBOOK_NAME = 'Notebook';
test.describe('Snapshot Menu tests', () => {
test.fixme(
'When no default notebook is selected, Snapshot Menu dropdown should only have a single option',
@@ -91,22 +85,13 @@ test.describe('Snapshot Container tests', () => {
await page.getByLabel('Take a Notebook Snapshot').click();
await page.getByRole('menuitem', { name: 'Save to Notebook Snapshots' }).click();
await page.getByRole('button', { name: 'Show' }).click();
await page.getByLabel('Show Snapshots').click();
});
test('A snapshot can be Quick Viewed from Container with 3 dot action menu', async ({ page }) => {
await page.locator('.c-snapshot.c-ne__embed').first().getByTitle('More actions').click();
await page.getByRole('menuitem', { name: 'Quick View' }).click();
await expect(page.locator('.c-overlay__outer')).toBeVisible();
});
test.fixme('5 Snapshots can be added to a container', async ({ page }) => {});
test.fixme(
'5 Snapshots can be added to a container and Deleted with Delete All action',
async ({ page }) => {}
);
test.fixme(
'A snapshot can be Deleted from Container with 3 dot action menu',
async ({ page }) => {}
);
test.fixme(
'A snapshot can be Viewed, Annotated, display deleted, and saved from Container with 3 dot action menu',
async ({ page }) => {
@@ -122,7 +107,15 @@ test.describe('Snapshot Container tests', () => {
//await expect(await page.locator)
}
);
test.fixme('5 Snapshots can be added to a container', async ({ page }) => {});
test.fixme(
'5 Snapshots can be added to a container and Deleted with Delete All action',
async ({ page }) => {}
);
test.fixme(
'A snapshot can be Deleted from Container with 3 dot action menu',
async ({ page }) => {}
);
test.fixme(
'A snapshot can be Navigated To from Container with 3 dot action menu',
async ({ page }) => {}
@@ -166,117 +159,3 @@ test.describe('Snapshot Container tests', () => {
}
);
});
test.describe('Snapshot image tests', () => {
test.beforeEach(async ({ page }) => {
//Navigate to baseURL
await page.goto('./', { waitUntil: 'domcontentloaded' });
// Create Notebook
await createDomainObjectWithDefaults(page, {
type: NOTEBOOK_NAME
});
});
test('Can drop an image onto a notebook and create a new entry', async ({ page }) => {
const imageData = await fs.readFile(
fileURLToPath(
new URL('../../../../../src/images/favicons/favicon-96x96.png', import.meta.url)
)
);
const imageArray = new Uint8Array(imageData);
const fileData = Array.from(imageArray);
const dropTransfer = await page.evaluateHandle((data) => {
const dataTransfer = new DataTransfer();
const file = new File([new Uint8Array(data)], 'favicon-96x96.png', { type: 'image/png' });
dataTransfer.items.add(file);
return dataTransfer;
}, fileData);
await page.dispatchEvent('.c-notebook__drag-area', 'drop', { dataTransfer: dropTransfer });
await page.locator('.c-ne__save-button > button').click();
// be sure that entry was created
await expect(page.getByText('favicon-96x96.png')).toBeVisible();
await page.getByRole('img', { name: 'favicon-96x96.png thumbnail' }).click();
// expect large image to be displayed
await expect(page.getByRole('dialog').getByText('favicon-96x96.png')).toBeVisible();
await page.getByLabel('Close').click();
// drop another image onto the entry
await page.dispatchEvent('.c-snapshots', 'drop', { dataTransfer: dropTransfer });
const secondThumbnail = page.getByRole('img', { name: 'favicon-96x96.png thumbnail' }).nth(1);
await secondThumbnail.waitFor({ state: 'attached' });
// expect two embedded images now
expect(await page.getByRole('img', { name: 'favicon-96x96.png thumbnail' }).count()).toBe(2);
await page.locator('.c-snapshot.c-ne__embed').first().getByTitle('More actions').click();
await page.getByRole('menuitem', { name: /Remove This Embed/ }).click();
await page.getByRole('button', { name: 'Ok', exact: true }).click();
// Ensure that the thumbnail is removed before we assert
await secondThumbnail.waitFor({ state: 'detached' });
// expect one embedded image now as we deleted the other
expect(await page.getByRole('img', { name: 'favicon-96x96.png thumbnail' }).count()).toBe(1);
});
});
test.describe('Snapshot image failure tests', () => {
test.use({ failOnConsoleError: false });
test.beforeEach(async ({ page }) => {
//Navigate to baseURL
await page.goto('./', { waitUntil: 'domcontentloaded' });
// Create Notebook
await createDomainObjectWithDefaults(page, {
type: NOTEBOOK_NAME
});
});
test('Get an error notification when dropping unknown file onto notebook entry', async ({
page
}) => {
// fill Uint8Array array with some garbage data
const garbageData = new Uint8Array(100);
const fileData = Array.from(garbageData);
const dropTransfer = await page.evaluateHandle((data) => {
const dataTransfer = new DataTransfer();
const file = new File([new Uint8Array(data)], 'someGarbage.foo', { type: 'unknown/garbage' });
dataTransfer.items.add(file);
return dataTransfer;
}, fileData);
await page.dispatchEvent('.c-notebook__drag-area', 'drop', { dataTransfer: dropTransfer });
// should have gotten a notification from OpenMCT that we couldn't add it
await expect(page.getByText('Unknown object(s) dropped and cannot embed')).toBeVisible();
});
test('Get an error notification when dropping big files onto notebook entry', async ({
page
}) => {
const garbageSize = 15 * 1024 * 1024; // 15 megabytes
await page.addScriptTag({
// make the garbage client side
content: `window.bigGarbageData = new Uint8Array(${garbageSize})`
});
const bigDropTransfer = await page.evaluateHandle(() => {
const dataTransfer = new DataTransfer();
const file = new File([window.bigGarbageData], 'bigBoy.png', { type: 'image/png' });
dataTransfer.items.add(file);
return dataTransfer;
});
await page.dispatchEvent('.c-notebook__drag-area', 'drop', { dataTransfer: bigDropTransfer });
// should have gotten a notification from OpenMCT that we couldn't add it as it's too big
await expect(page.getByText('unable to embed')).toBeVisible();
});
});

View File

@@ -51,7 +51,7 @@ test.describe('Operator Status', () => {
// Description should be empty https://github.com/nasa/openmct/issues/6978
await expect(page.locator('.c-message__action-text')).toBeHidden();
// set role
await page.getByRole('button', { name: 'Select' }).click();
await page.getByRole('button', { name: 'Select', exact: true }).click();
// dismiss role confirmation popup
await page.getByRole('button', { name: 'Dismiss' }).click();
});

View File

@@ -260,9 +260,9 @@ async function assertLimitLinesExistAndAreVisible(page) {
await waitForPlotsToRender(page);
// Wait for limit lines to be created
await page.waitForSelector('.js-limit-area', { state: 'attached' });
const limitLineCount = await page.locator('.c-plot-limit-line').count();
// There should be 10 limit lines created by default
expect(await page.locator('.c-plot-limit-line').count()).toBe(10);
await expect(page.locator('.c-plot-limit-line')).toHaveCount(10);
const limitLineCount = await page.locator('.c-plot-limit-line').count();
for (let i = 0; i < limitLineCount; i++) {
await expect(page.locator('.c-plot-limit-line').nth(i)).toBeVisible();
}

View File

@@ -55,7 +55,7 @@ test.describe('Tabs View', () => {
await expect(page.getByRole('searchbox', { name: 'message filter input' })).toBeVisible();
// no canvas (i.e., sine wave generator) in the document should be visible
await expect(page.locator('canvas')).toBeHidden();
await expect(page.locator('canvas[id=webglContext]')).toBeHidden();
// select second tab
await page.getByLabel(`${notebook.name} tab`).click();
@@ -64,7 +64,7 @@ test.describe('Tabs View', () => {
await expect(page.locator('.c-notebook__drag-area')).toBeVisible();
// no canvas (i.e., sine wave generator) in the document should be visible
await expect(page.locator('canvas')).toBeHidden();
await expect(page.locator('canvas[id=webglContext]')).toBeHidden();
// select third tab
await page.getByLabel(`${sineWaveGenerator.name} tab`).click();
@@ -83,6 +83,6 @@ test.describe('Tabs View', () => {
await expect(page.getByRole('searchbox', { name: 'message filter input' })).toBeVisible();
// no canvas (i.e., sine wave generator) in the document should be visible
await expect(page.locator('canvas')).toBeHidden();
await expect(page.locator('canvas[id=webglContext]')).toBeHidden();
});
});

View File

@@ -99,7 +99,7 @@ test.describe('Grand Search', () => {
page.waitForNavigation(),
page.getByLabel('OpenMCT Search').getByText('Clock A').click()
]);
await expect(page.getByRole('status', { name: 'Clock' })).toBeVisible();
await expect(page.getByRole('status', { name: 'Clock', exact: true })).toBeVisible();
await grandSearchInput.fill('Disp');
await expect(page.getByLabel('Object Search Result').first()).toContainText(

View File

@@ -34,13 +34,13 @@ test.describe('User Roles', () => {
// we have multiple available roles, so it should prompt the user
await expect(page.getByText('Select Role')).toBeVisible();
await page.getByRole('combobox').selectOption('driver');
await page.getByRole('button', { name: 'Select' }).click();
await page.getByRole('button', { name: 'Select', exact: true }).click();
await expect(page.getByLabel('User Role')).toContainText('driver');
// attempt changing the role to another valid available role
await page.getByRole('button', { name: 'Change Role' }).click();
await page.getByRole('combobox').selectOption('flight');
await page.getByRole('button', { name: 'Select' }).click();
await page.getByRole('button', { name: 'Select', exact: true }).click();
await expect(page.getByLabel('User Role')).toContainText('flight');
// reload page
@@ -63,7 +63,7 @@ test.describe('User Roles', () => {
// select real role of "driver"
await page.getByRole('combobox').selectOption('driver');
await page.getByRole('button', { name: 'Select' }).click();
await page.getByRole('button', { name: 'Select', exact: true }).click();
await expect(page.getByLabel('User Role')).toContainText('driver');
});
});

View File

@@ -26,7 +26,7 @@ Tests the branding associated with the default deployment. At least the about mo
import percySnapshot from '@percy/playwright';
import { scanForA11yViolations, test } from '../../../avpFixtures.js';
import { expect, scanForA11yViolations, test } from '../../../avpFixtures.js';
import { VISUAL_URL } from '../../../constants.js';
//Declare the scope of the visual test
@@ -36,6 +36,22 @@ test.describe('Visual - Header @a11y', () => {
test.beforeEach(async ({ page }) => {
//Go to baseURL and Hide Tree
await page.goto(VISUAL_URL, { waitUntil: 'domcontentloaded' });
// Wait for status bar to load
await expect(
page.getByRole('status', {
name: 'Clock Indicator'
})
).toBeInViewport();
await expect(
page.getByRole('status', {
name: 'Global Clear Indicator'
})
).toBeInViewport();
await expect(
page.getByRole('status', {
name: 'Snapshot Indicator'
})
).toBeInViewport();
});
test('header sizing', async ({ page, theme }) => {
@@ -50,6 +66,17 @@ test.describe('Visual - Header @a11y', () => {
scope: header
});
});
test('show snapshot button', async ({ page, theme }) => {
await page.getByLabel('Take a Notebook Snapshot').click();
await page.getByRole('menuitem', { name: 'Save to Notebook Snapshots' }).click();
await percySnapshot(page, `Notebook Snapshot Show button (theme: '${theme}')`, {
scope: header
});
await expect(await page.getByLabel('Show Snapshots')).toBeVisible();
});
});
test.afterEach(async ({ page }, testInfo) => {
await scanForA11yViolations(page, testInfo.title);

View File

@@ -23,7 +23,7 @@ import percySnapshot from '@percy/playwright';
import { fileURLToPath } from 'url';
import * as utils from '../../helper/faultUtils.js';
import { test } from '../../pluginFixtures.js';
import { expect, test } from '../../pluginFixtures.js';
test.describe('Fault Management Visual Tests', () => {
test('icon test', async ({ page, theme }) => {
@@ -32,6 +32,23 @@ test.describe('Fault Management Visual Tests', () => {
});
await page.goto('./', { waitUntil: 'domcontentloaded' });
// Wait for status bar to load
await expect(
page.getByRole('status', {
name: 'Clock Indicator'
})
).toBeInViewport();
await expect(
page.getByRole('status', {
name: 'Global Clear Indicator'
})
).toBeInViewport();
await expect(
page.getByRole('status', {
name: 'Snapshot Indicator'
})
).toBeInViewport();
await percySnapshot(page, `Fault Management icon appears in tree (theme: '${theme}')`);
});

View File

@@ -22,9 +22,12 @@
import EventEmitter from 'EventEmitter';
import vueWrapHtmlElement from '../../utils/vueWrapHtmlElement.js';
import SimpleIndicator from './SimpleIndicator.js';
class IndicatorAPI extends EventEmitter {
/** @type {import('../../../openmct.js').OpenMCT} */
openmct;
constructor(openmct) {
super();
@@ -42,6 +45,18 @@ class IndicatorAPI extends EventEmitter {
return new SimpleIndicator(this.openmct);
}
/**
* @typedef {import('vue').Component} VueComponent
*/
/**
* @typedef {Object} Indicator
* @property {HTMLElement} [element]
* @property {VueComponent|Promise<VueComponent>} [vueComponent]
* @property {string} key
* @property {number} priority
*/
/**
* Accepts an indicator object, which is a simple object
* with a two attributes: 'element' which has an HTMLElement
@@ -62,11 +77,20 @@ class IndicatorAPI extends EventEmitter {
* myIndicator.text("Hello World!");
* myIndicator.iconClass("icon-info");
*
* If you would like to use a Vue component, you can pass it in
* directly as the 'vueComponent' attribute of the indicator object.
* This accepts a Vue component or a promise that resolves to a Vue component (for asynchronous
* rendering).
*
* @param {Indicator} indicator
*/
add(indicator) {
if (!indicator.priority) {
indicator.priority = this.openmct.priority.DEFAULT;
}
if (!indicator.vueComponent) {
indicator.vueComponent = vueWrapHtmlElement(indicator.element);
}
this.indicatorObjects.push(indicator);

View File

@@ -19,6 +19,8 @@
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
import { defineComponent } from 'vue';
import { createOpenMct, resetApplicationState } from '../../utils/testing.js';
import SimpleIndicator from './SimpleIndicator.js';
@@ -33,7 +35,7 @@ describe('The Indicator API', () => {
return resetApplicationState(openmct);
});
function generateIndicator(className, label, priority) {
function generateHTMLIndicator(className, label, priority) {
const element = document.createElement('div');
element.classList.add(className);
const textNode = document.createTextNode(label);
@@ -46,8 +48,25 @@ describe('The Indicator API', () => {
return testIndicator;
}
it('can register an indicator', () => {
const testIndicator = generateIndicator('test-indicator', 'This is a test indicator', 2);
function generateVueIndicator(priority) {
return {
vueComponent: defineComponent({
template: '<div class="test-indicator">This is a test indicator</div>'
}),
priority
};
}
it('can register an HTML indicator', () => {
const testIndicator = generateHTMLIndicator('test-indicator', 'This is a test indicator', 2);
openmct.indicators.add(testIndicator);
expect(openmct.indicators.indicatorObjects).toBeDefined();
// notifier indicator is installed by default
expect(openmct.indicators.indicatorObjects.length).toBe(2);
});
it('can register a Vue indicator', () => {
const testIndicator = generateVueIndicator(2);
openmct.indicators.add(testIndicator);
expect(openmct.indicators.indicatorObjects).toBeDefined();
// notifier indicator is installed by default
@@ -55,37 +74,40 @@ describe('The Indicator API', () => {
});
it('can order indicators based on priority', () => {
const testIndicator1 = generateIndicator(
const testIndicator1 = generateHTMLIndicator(
'test-indicator-1',
'This is a test indicator',
openmct.priority.LOW
);
openmct.indicators.add(testIndicator1);
const testIndicator2 = generateIndicator(
const testIndicator2 = generateHTMLIndicator(
'test-indicator-2',
'This is another test indicator',
openmct.priority.DEFAULT
);
openmct.indicators.add(testIndicator2);
const testIndicator3 = generateIndicator(
const testIndicator3 = generateHTMLIndicator(
'test-indicator-3',
'This is yet another test indicator',
openmct.priority.LOW
);
openmct.indicators.add(testIndicator3);
const testIndicator4 = generateIndicator(
const testIndicator4 = generateHTMLIndicator(
'test-indicator-4',
'This is yet another test indicator',
openmct.priority.HIGH
);
openmct.indicators.add(testIndicator4);
expect(openmct.indicators.indicatorObjects.length).toBe(5);
const testIndicator5 = generateVueIndicator(openmct.priority.DEFAULT);
openmct.indicators.add(testIndicator5);
expect(openmct.indicators.indicatorObjects.length).toBe(6);
const indicatorObjectsByPriority = openmct.indicators.getIndicatorObjectsByPriority();
expect(indicatorObjectsByPriority.length).toBe(5);
expect(indicatorObjectsByPriority.length).toBe(6);
expect(indicatorObjectsByPriority[2].priority).toBe(openmct.priority.DEFAULT);
});

View File

@@ -0,0 +1,194 @@
/*****************************************************************************
* Open MCT Web, Copyright (c) 2014-2024, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT Web 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 Web 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.
*****************************************************************************/
import installWorker from './WebSocketWorker.js';
const DEFAULT_RATE_MS = 1000;
/**
* Describes the strategy to be used when batching WebSocket messages
*
* @typedef BatchingStrategy
* @property {Function} shouldBatchMessage a function that accepts a single
* argument - the raw message received from the websocket. Every message
* received will be evaluated against this function so it should be performant.
* Note also that this function is executed in a worker, so it must be
* completely self-contained with no external dependencies. The function
* should return `true` if the message should be batched, and `false` if not.
* @property {Function} getBatchIdFromMessage a function that accepts a
* single argument - the raw message received from the websocket. Only messages
* where `shouldBatchMessage` has evaluated to true will be passed into this
* function. The function should return a unique value on which to batch the
* messages. For example a telemetry, channel, or parameter identifier.
*/
/**
* Provides a reliable and convenient WebSocket abstraction layer that handles
* a lot of boilerplate common to managing WebSocket connections such as:
* - Establishing a WebSocket connection to a server
* - Reconnecting on error, with a fallback strategy
* - Queuing messages so that clients can send messages without concern for the current
* connection state of the WebSocket.
*
* The WebSocket that it manages is based in a dedicated worker so that network
* concerns are not handled on the main event loop. This allows for performant receipt
* and batching of messages without blocking either the UI or server.
*
* @memberof module:openmct.telemetry
*/
class BatchingWebSocket extends EventTarget {
#worker;
#openmct;
#showingRateLimitNotification;
#rate;
constructor(openmct) {
super();
// Install worker, register listeners etc.
const workerFunction = `(${installWorker.toString()})()`;
const workerBlob = new Blob([workerFunction]);
const workerUrl = URL.createObjectURL(workerBlob, { type: 'application/javascript' });
this.#worker = new Worker(workerUrl);
this.#openmct = openmct;
this.#showingRateLimitNotification = false;
this.#rate = DEFAULT_RATE_MS;
const routeMessageToHandler = this.#routeMessageToHandler.bind(this);
this.#worker.addEventListener('message', routeMessageToHandler);
openmct.on(
'destroy',
() => {
this.disconnect();
URL.revokeObjectURL(workerUrl);
},
{ once: true }
);
}
/**
* Will establish a WebSocket connection to the provided url
* @param {string} url The URL to connect to
*/
connect(url) {
this.#worker.postMessage({
type: 'connect',
url
});
this.#readyForNextBatch();
}
#readyForNextBatch() {
this.#worker.postMessage({
type: 'readyForNextBatch'
});
}
/**
* Send a message to the WebSocket.
* @param {any} message The message to send. Can be any type supported by WebSockets.
* See https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/send#data
*/
sendMessage(message) {
this.#worker.postMessage({
type: 'message',
message
});
}
/**
* Set the strategy used to both decide which raw messages to batch, and how to group
* them.
* @param {BatchingStrategy} strategy The batching strategy to use when evaluating
* raw messages from the WebSocket.
*/
setBatchingStrategy(strategy) {
const serializedStrategy = {
shouldBatchMessage: strategy.shouldBatchMessage.toString(),
getBatchIdFromMessage: strategy.getBatchIdFromMessage.toString()
};
this.#worker.postMessage({
type: 'setBatchingStrategy',
serializedStrategy
});
}
/**
* When using batching, sets the rate at which batches of messages are released.
* @param {Number} rate the amount of time to wait, in ms, between batches.
*/
setRate(rate) {
this.#rate = rate;
}
/**
* @param {Number} maxBatchSize the maximum length of a batch of messages. For example,
* the maximum number of telemetry values to batch before dropping them
* Note that this is a fail-safe that is only invoked if performance drops to the
* point where Open MCT cannot keep up with the amount of telemetry it is receiving.
* In this event it will sacrifice the oldest telemetry in the batch in favor of the
* most recent telemetry. The user will be informed that telemetry has been dropped.
*
* This should be specced appropriately for the expected data rate. eg. If telemetry
* is received at 10Hz for each telemetry point, then a minimal combination of batch
* size and rate is 10 and 1000 respectively. Ideally you would add some margin, so
* 15 would probably be a better batch size.
*/
setMaxBatchSize(maxBatchSize) {
this.#worker.postMessage({
type: 'setMaxBatchSize',
maxBatchSize
});
}
/**
* Disconnect the associated WebSocket. Generally speaking there is no need to call
* this manually.
*/
disconnect() {
this.#worker.postMessage({
type: 'disconnect'
});
}
#routeMessageToHandler(message) {
if (message.data.type === 'batch') {
if (message.data.batch.dropped === true && !this.#showingRateLimitNotification) {
const notification = this.#openmct.notifications.alert(
'Telemetry dropped due to client rate limiting.',
{ hint: 'Refresh individual telemetry views to retrieve dropped telemetry if needed.' }
);
this.#showingRateLimitNotification = true;
notification.once('minimized', () => {
this.#showingRateLimitNotification = false;
});
}
this.dispatchEvent(new CustomEvent('batch', { detail: message.data.batch }));
setTimeout(() => {
this.#readyForNextBatch();
}, this.#rate);
} else if (message.data.type === 'message') {
this.dispatchEvent(new CustomEvent('message', { detail: message.data.message }));
} else {
throw new Error(`Unknown message type: ${message.data.type}`);
}
}
}
export default BatchingWebSocket;

View File

@@ -23,6 +23,7 @@
import objectUtils from 'objectUtils';
import CustomStringFormatter from '../../plugins/displayLayout/CustomStringFormatter.js';
import BatchingWebSocket from './BatchingWebSocket.js';
import DefaultMetadataProvider from './DefaultMetadataProvider.js';
import TelemetryCollection from './TelemetryCollection.js';
import TelemetryMetadataManager from './TelemetryMetadataManager.js';
@@ -54,6 +55,28 @@ import TelemetryValueFormatter from './TelemetryValueFormatter.js';
* @memberof module:openmct.TelemetryAPI~
*/
/**
* Describes and bounds requests for telemetry data.
*
* @typedef TelemetrySubscriptionOptions
* @property {String} [strategy] symbolic identifier directing providers on how
* to handle telemetry subscriptions. The default behavior is 'latest' which will
* always return a single telemetry value with each callback, and in the event
* of throttling will always prioritize the latest data, meaning intermediate
* data will be skipped. Alternatively, the `batch` strategy can be used, which
* will return all telemetry values since the last callback. This strategy is
* useful for cases where intermediate data is important, such as when
* rendering a telemetry plot or table. If `batch` is specified, the subscription
* callback will be invoked with an Array.
*
* @memberof module:openmct.TelemetryAPI~
*/
const SUBSCRIBE_STRATEGY = {
LATEST: 'latest',
BATCH: 'batch'
};
/**
* Utilities for telemetry
* @interface TelemetryAPI
@@ -62,6 +85,10 @@ import TelemetryValueFormatter from './TelemetryValueFormatter.js';
export default class TelemetryAPI {
#isGreedyLAD;
get SUBSCRIBE_STRATEGY() {
return SUBSCRIBE_STRATEGY;
}
constructor(openmct) {
this.openmct = openmct;
@@ -78,6 +105,7 @@ export default class TelemetryAPI {
this.valueFormatterCache = new WeakMap();
this.requestInterceptorRegistry = new TelemetryRequestInterceptorRegistry();
this.#isGreedyLAD = true;
this.BatchingWebSocket = BatchingWebSocket;
}
abortAllRequests() {
@@ -378,18 +406,23 @@ export default class TelemetryAPI {
* @memberof module:openmct.TelemetryAPI~TelemetryProvider#
* @param {module:openmct.DomainObject} domainObject the object
* which has associated telemetry
* @param {TelemetryRequestOptions} options configuration items for subscription
* @param {TelemetrySubscriptionOptions} options configuration items for subscription
* @param {Function} callback the callback to invoke with new data, as
* it becomes available
* @returns {Function} a function which may be called to terminate
* the subscription
*/
subscribe(domainObject, callback, options) {
subscribe(domainObject, callback, options = { strategy: SUBSCRIBE_STRATEGY.LATEST }) {
if (domainObject.type === 'unknown') {
return () => {};
}
const provider = this.findSubscriptionProvider(domainObject);
// Default behavior is to use the latest strategy, as opposed to the new "batch" strategy
if (options.strategy === undefined || options.strategy === null) {
options.strategy = SUBSCRIBE_STRATEGY.LATEST;
}
const provider = this.findSubscriptionProvider(domainObject, options);
if (!this.subscribeCache) {
this.subscribeCache = {};
@@ -405,11 +438,9 @@ export default class TelemetryAPI {
if (provider) {
subscriber.unsubscribe = provider.subscribe(
domainObject,
function (value) {
subscriber.callbacks.forEach(function (cb) {
cb(value);
});
},
options.strategy === SUBSCRIBE_STRATEGY.BATCH
? subscriptionCallbackForArray
: subscriptionCallbackForSingleValue,
options
);
} else {
@@ -419,6 +450,38 @@ export default class TelemetryAPI {
subscriber.callbacks.push(callback);
}
function subscriptionCallbackForArray(value) {
if (value === undefined || value === null || value.length === 0) {
throw new Error(
'Attempt to invoke telemetry subscription callback with no telemetry datum'
);
}
if (!Array.isArray(value)) {
value = [value];
}
subscriber.callbacks.forEach(function (cb) {
cb(value);
});
}
function subscriptionCallbackForSingleValue(value) {
if (Array.isArray(value)) {
value = value[value.length - 1];
}
if (value === undefined || value === null) {
throw new Error(
'Attempt to invoke telemetry subscription callback with no telemetry datum'
);
}
subscriber.callbacks.forEach(function (cb) {
cb(value);
});
}
return function unsubscribe() {
subscriber.callbacks = subscriber.callbacks.filter(function (cb) {
return cb !== callback;

View File

@@ -90,7 +90,9 @@ describe('Telemetry API', () => {
const callback = jasmine.createSpy('callback');
const unsubscribe = telemetryAPI.subscribe(domainObject, callback);
expect(telemetryProvider.supportsSubscribe).toHaveBeenCalledWith(domainObject);
expect(telemetryProvider.supportsSubscribe).toHaveBeenCalledWith(domainObject, {
strategy: 'latest'
});
expect(telemetryProvider.subscribe).not.toHaveBeenCalled();
expect(unsubscribe).toEqual(jasmine.any(Function));
@@ -111,12 +113,16 @@ describe('Telemetry API', () => {
const callback = jasmine.createSpy('callback');
const unsubscribe = telemetryAPI.subscribe(domainObject, callback);
expect(telemetryProvider.supportsSubscribe.calls.count()).toBe(1);
expect(telemetryProvider.supportsSubscribe).toHaveBeenCalledWith(domainObject);
expect(telemetryProvider.supportsSubscribe).toHaveBeenCalledWith(domainObject, {
strategy: 'latest'
});
expect(telemetryProvider.subscribe.calls.count()).toBe(1);
expect(telemetryProvider.subscribe).toHaveBeenCalledWith(
domainObject,
jasmine.any(Function),
undefined
{
strategy: 'latest'
}
);
const notify = telemetryProvider.subscribe.calls.mostRecent().args[1];

View File

@@ -180,11 +180,14 @@ export default class TelemetryCollection extends EventEmitter {
if (this.unsubscribe) {
this.unsubscribe();
}
const options = { ...this.options };
//We always want to receive all available values in telemetry tables.
options.strategy = this.openmct.telemetry.SUBSCRIBE_STRATEGY.BATCH;
this.unsubscribe = this.openmct.telemetry.subscribe(
this.domainObject,
(datum) => this._processNewTelemetry(datum),
this.options
options
);
}

View File

@@ -0,0 +1,383 @@
/*****************************************************************************
* Open MCT Web, Copyright (c) 2014-2024, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT Web 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 Web 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 max-classes-per-file */
export default function installWorker() {
const FALLBACK_AND_WAIT_MS = [1000, 5000, 5000, 10000, 10000, 30000];
/**
* @typedef {import('./BatchingWebSocket').BatchingStrategy} BatchingStrategy
*/
/**
* Provides a WebSocket connection that is resilient to errors and dropouts.
* On an error or dropout, will automatically reconnect.
*
* Additionally, messages will be queued and sent only when WebSocket is
* connected meaning that client code does not need to check the state of
* the socket before sending.
*/
class ResilientWebSocket extends EventTarget {
#webSocket;
#isConnected = false;
#isConnecting = false;
#messageQueue = [];
#reconnectTimeoutHandle;
#currentWaitIndex = 0;
#messageCallbacks = [];
#wsUrl;
/**
* Establish a new WebSocket connection to the given URL
* @param {String} url
*/
connect(url) {
this.#wsUrl = url;
if (this.#isConnected) {
throw new Error('WebSocket already connected');
}
if (this.#isConnecting) {
throw new Error('WebSocket connection in progress');
}
this.#isConnecting = true;
//TODO: Make this configurable
this.#webSocket = new WebSocket(url, 'protobuf');
//TODO: Make this configurable
this.#webSocket.binaryType = 'arraybuffer';
const boundConnected = this.#connected.bind(this);
this.#webSocket.addEventListener('open', boundConnected);
const boundCleanUpAndReconnect = this.#cleanUpAndReconnect.bind(this);
this.#webSocket.addEventListener('error', boundCleanUpAndReconnect);
this.#webSocket.addEventListener('close', boundCleanUpAndReconnect);
const boundMessage = this.#message.bind(this);
this.#webSocket.addEventListener('message', boundMessage);
this.addEventListener(
'disconnected',
() => {
this.#webSocket.removeEventListener('open', boundConnected);
this.#webSocket.removeEventListener('error', boundCleanUpAndReconnect);
this.#webSocket.removeEventListener('close', boundCleanUpAndReconnect);
},
{ once: true }
);
}
/**
* Register a callback to be invoked when a message is received on the WebSocket.
* This paradigm is used instead of the standard EventTarget or EventEmitter approach
* for performance reasons.
* @param {Function} callback The function to be invoked when a message is received
* @returns an unregister function
*/
registerMessageCallback(callback) {
this.#messageCallbacks.push(callback);
return () => {
this.#messageCallbacks = this.#messageCallbacks.filter((cb) => cb !== callback);
};
}
#connected() {
console.debug('Websocket connected.');
this.#isConnected = true;
this.#isConnecting = false;
this.#currentWaitIndex = 0;
this.dispatchEvent(new Event('connected'));
this.#flushQueue();
}
#cleanUpAndReconnect() {
console.warn('Websocket closed. Attempting to reconnect...');
this.disconnect();
this.#reconnect();
}
#message(event) {
this.#messageCallbacks.forEach((callback) => callback(event.data));
}
disconnect() {
this.#isConnected = false;
this.#isConnecting = false;
// On WebSocket error, both error callback and close callback are invoked, resulting in
// this function being called twice, and websocket being destroyed and deallocated.
if (this.#webSocket !== undefined && this.#webSocket !== null) {
this.#webSocket.close();
}
this.dispatchEvent(new Event('disconnected'));
this.#webSocket = undefined;
}
#reconnect() {
if (this.#reconnectTimeoutHandle) {
return;
}
this.#reconnectTimeoutHandle = setTimeout(() => {
this.connect(this.#wsUrl);
this.#reconnectTimeoutHandle = undefined;
}, FALLBACK_AND_WAIT_MS[this.#currentWaitIndex]);
if (this.#currentWaitIndex < FALLBACK_AND_WAIT_MS.length - 1) {
this.#currentWaitIndex++;
}
}
enqueueMessage(message) {
this.#messageQueue.push(message);
this.#flushQueueIfReady();
}
#flushQueueIfReady() {
if (this.#isConnected) {
this.#flushQueue();
}
}
#flushQueue() {
while (this.#messageQueue.length > 0) {
if (!this.#isConnected) {
break;
}
const message = this.#messageQueue.shift();
this.#webSocket.send(message);
}
}
}
/**
* Handles messages over the worker interface, and
* sends corresponding WebSocket messages.
*/
class WorkerToWebSocketMessageBroker {
#websocket;
#messageBatcher;
constructor(websocket, messageBatcher) {
this.#websocket = websocket;
this.#messageBatcher = messageBatcher;
}
routeMessageToHandler(message) {
const { type } = message.data;
switch (type) {
case 'connect':
this.connect(message);
break;
case 'disconnect':
this.disconnect(message);
break;
case 'message':
this.#websocket.enqueueMessage(message.data.message);
break;
case 'setBatchingStrategy':
this.setBatchingStrategy(message);
break;
case 'readyForNextBatch':
this.#messageBatcher.readyForNextBatch();
break;
case 'setMaxBatchSize':
this.#messageBatcher.setMaxBatchSize(message.data.maxBatchSize);
break;
default:
throw new Error(`Unknown message type: ${type}`);
}
}
connect(message) {
const { url } = message.data;
this.#websocket.connect(url);
}
disconnect() {
this.#websocket.disconnect();
}
setBatchingStrategy(message) {
const { serializedStrategy } = message.data;
const batchingStrategy = {
// eslint-disable-next-line no-new-func
shouldBatchMessage: new Function(`return ${serializedStrategy.shouldBatchMessage}`)(),
// eslint-disable-next-line no-new-func
getBatchIdFromMessage: new Function(`return ${serializedStrategy.getBatchIdFromMessage}`)()
// Will also include maximum batch length here
};
this.#messageBatcher.setBatchingStrategy(batchingStrategy);
}
}
/**
* Received messages from the WebSocket, and passes them along to the
* Worker interface and back to the main thread.
*/
class WebSocketToWorkerMessageBroker {
#worker;
#messageBatcher;
constructor(messageBatcher, worker) {
this.#messageBatcher = messageBatcher;
this.#worker = worker;
}
routeMessageToHandler(data) {
//Implement batching here
if (this.#messageBatcher.shouldBatchMessage(data)) {
this.#messageBatcher.addMessageToBatch(data);
} else {
this.#worker.postMessage(
{
type: 'message',
message: data
},
{
transfer: data
}
);
}
}
}
/**
* Responsible for batching messages according to the defined batching strategy.
*/
class MessageBatcher {
#batch;
#batchingStrategy;
#hasBatch = false;
#maxBatchSize;
#readyForNextBatch;
#worker;
#transferables;
constructor(worker) {
this.#maxBatchSize = 10;
this.#readyForNextBatch = false;
this.#worker = worker;
this.#transferables = [];
this.#resetBatch();
}
#resetBatch() {
this.#batch = {};
this.#hasBatch = false;
}
/**
* @param {BatchingStrategy} strategy
*/
setBatchingStrategy(strategy) {
this.#batchingStrategy = strategy;
}
/**
* Applies the `shouldBatchMessage` function from the supplied batching strategy
* to each message to determine if it should be added to a batch. If not batched,
* the message is immediately sent over the worker to the main thread.
* @param {any} message the message received from the WebSocket. See the WebSocket
* documentation for more details -
* https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent/data
* @returns
*/
shouldBatchMessage(message) {
return (
this.#batchingStrategy.shouldBatchMessage &&
this.#batchingStrategy.shouldBatchMessage(message)
);
}
/**
* Adds the given message to a batch. The batch group that the message is added
* to will be determined by the value returned by `getBatchIdFromMessage`.
* @param {any} message the message received from the WebSocket. See the WebSocket
* documentation for more details -
* https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent/data
*/
addMessageToBatch(message) {
const batchId = this.#batchingStrategy.getBatchIdFromMessage(message);
let batch = this.#batch[batchId];
if (batch === undefined) {
batch = this.#batch[batchId] = [message];
} else {
batch.push(message);
}
if (batch.length > this.#maxBatchSize) {
batch.shift();
this.#batch.dropped = this.#batch.dropped || true;
}
if (this.#readyForNextBatch) {
this.#sendNextBatch();
} else {
this.#hasBatch = true;
}
this.#transferables.push(message);
}
setMaxBatchSize(maxBatchSize) {
this.#maxBatchSize = maxBatchSize;
}
/**
* Indicates that client code is ready to receive the next batch of
* messages. If a batch is available, it will be immediately sent.
* Otherwise a flag will be set to send the next batch as soon as
* any new data is available.
*/
readyForNextBatch() {
if (this.#hasBatch) {
this.#sendNextBatch();
} else {
this.#readyForNextBatch = true;
}
}
#sendNextBatch() {
const batch = this.#batch;
this.#resetBatch();
this.#worker.postMessage(
{
type: 'batch',
batch
},
{
transfer: this.#transferables
}
);
this.#readyForNextBatch = false;
this.#hasBatch = false;
this.#transferables = [];
}
}
const websocket = new ResilientWebSocket();
const messageBatcher = new MessageBatcher(self);
const workerBroker = new WorkerToWebSocketMessageBroker(websocket, messageBatcher);
const websocketBroker = new WebSocketToWorkerMessageBroker(messageBatcher, self);
self.addEventListener('message', (message) => {
workerBroker.routeMessageToHandler(message);
});
websocket.registerMessageCallback((data) => {
websocketBroker.routeMessageToHandler(data);
});
}

View File

@@ -130,11 +130,8 @@
</template>
<script>
import { inject } from 'vue';
import ColorPalette from '@/ui/color/ColorPalette';
import { useIsEditing } from '../../../../ui/composables/editing';
import SeriesOptions from './SeriesOptions.vue';
export default {
@@ -142,14 +139,6 @@ export default {
SeriesOptions
},
inject: ['openmct', 'domainObject'],
setup() {
const openmct = inject('openmct');
const { isEditing } = useIsEditing(openmct);
return {
isEditing
};
},
data() {
return {
xKey: this.domainObject.configuration.axes.xKey,
@@ -159,23 +148,34 @@ export default {
plotSeries: [],
yKeyOptions: [],
xKeyOptions: [],
isEditing: this.openmct.editor.isEditing(),
colorPalette: this.colorPalette,
useInterpolation: this.domainObject.configuration.useInterpolation,
useBar: this.domainObject.configuration.useBar
};
},
computed: {
canEdit() {
return this.isEditing && !this.domainObject.locked;
}
},
beforeMount() {
this.colorPalette = new ColorPalette();
},
mounted() {
this.openmct.editor.on('isEditing', this.setEditState);
this.composition = this.openmct.composition.get(this.domainObject);
this.registerListeners();
this.composition.load();
},
beforeUnmount() {
this.openmct.editor.off('isEditing', this.setEditState);
this.stopListening();
},
methods: {
setEditState(isEditing) {
this.isEditing = isEditing;
},
registerListeners() {
this.composition.on('add', this.addSeries);
this.composition.on('remove', this.removeSeries);

View File

@@ -31,9 +31,6 @@
</template>
<script>
import { computed, inject } from 'vue';
import { useIsEditing } from '../../../../ui/composables/editing';
import PlotOptionsBrowse from './PlotOptionsBrowse.vue';
import PlotOptionsEdit from './PlotOptionsEdit.vue';
export default {
@@ -41,17 +38,27 @@ export default {
PlotOptionsBrowse,
PlotOptionsEdit
},
setup() {
const openmct = inject('openmct');
const domainObject = inject('domainObject');
const { isEditing } = useIsEditing(openmct);
const canEdit = computed(() => {
return isEditing.value && !domainObject.locked;
});
inject: ['openmct', 'domainObject'],
data() {
return {
isEditing,
canEdit
isEditing: this.openmct.editor.isEditing()
};
},
computed: {
canEdit() {
return this.isEditing && !this.domainObject.locked;
}
},
mounted() {
this.openmct.editor.on('isEditing', this.setEditState);
},
beforeUnmount() {
this.openmct.editor.off('isEditing', this.setEditState);
},
methods: {
setEditState(isEditing) {
this.isEditing = isEditing;
}
}
};
</script>

View File

@@ -20,7 +20,10 @@
at runtime from the About dialog for additional information.
-->
<template>
<div class="c-indicator c-indicator--clickable icon-clear-data s-status-caution">
<div
aria-label="Global Clear Indicator"
class="c-indicator c-indicator--clickable icon-clear-data s-status-caution"
>
<span class="label c-indicator__label">
<button @click="globalClearEmit">Clear Data</button>
</span>

View File

@@ -19,8 +19,6 @@
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
import mount from 'utils/mount';
import ClearDataAction from './ClearDataAction.js';
import GlobalClearIndicator from './components/GlobalClearIndicator.vue';
@@ -31,27 +29,10 @@ export default function plugin(appliesToObjects, options = { indicator: true })
return function install(openmct) {
if (installIndicator) {
const { vNode, destroy } = mount(
{
components: {
GlobalClearIndicator
},
provide: {
openmct
},
template: '<GlobalClearIndicator></GlobalClearIndicator>'
},
{
app: openmct.app,
element: document.createElement('div')
}
);
let indicator = {
element: vNode.el,
vueComponent: GlobalClearIndicator,
key: 'global-clear-indicator',
priority: openmct.priority.DEFAULT,
destroy: destroy
priority: openmct.priority.DEFAULT
};
openmct.indicators.add(indicator);

View File

@@ -21,7 +21,6 @@
*****************************************************************************/
import { createMouseEvent, createOpenMct, resetApplicationState } from 'utils/testing';
import { nextTick } from 'vue';
import ClearDataPlugin from './plugin.js';
@@ -208,12 +207,11 @@ describe('The Clear Data Plugin:', () => {
it('installs', () => {
const globalClearIndicator = openmct.indicators.indicatorObjects.find(
(indicator) => indicator.key === 'global-clear-indicator'
).element;
).vueComponent;
expect(globalClearIndicator).toBeDefined();
});
it('renders its major elements', async () => {
await nextTick();
it('renders its major elements', () => {
const indicatorClass = appHolder.querySelector('.c-indicator');
const iconClass = appHolder.querySelector('.icon-clear-data');
const indicatorLabel = appHolder.querySelector('.c-indicator__label');
@@ -228,10 +226,7 @@ describe('The Clear Data Plugin:', () => {
const indicatorLabel = appHolder.querySelector('.c-indicator__label');
const buttonElement = indicatorLabel.querySelector('button');
const clickEvent = createMouseEvent('click');
openmct.objectViews.on('clearData', () => {
// when we click the button, this event should fire
done();
});
openmct.objectViews.on('clearData', done);
buttonElement.dispatchEvent(clickEvent);
});
});

View File

@@ -22,6 +22,7 @@
<template>
<div
aria-label="Clock Indicator"
class="c-indicator t-indicator-clock icon-clock no-minify c-indicator--not-clickable"
role="complementary"
>
@@ -40,27 +41,32 @@ export default {
props: {
indicatorFormat: {
type: String,
required: true
default: 'YYYY/MM/DD HH:mm:ss'
}
},
data() {
return {
timeTextValue: this.openmct.time.getClock() ? this.openmct.time.now() : undefined
timestamp: this.openmct.time.getClock() ? this.openmct.time.now() : undefined
};
},
computed: {
timeTextValue() {
return `${moment.utc(this.timestamp).format(this.indicatorFormat)} ${
this.openmct.time.getTimeSystem().name
}`;
}
},
mounted() {
this.tick = raf(this.tick);
this.openmct.time.on('tick', this.tick);
this.tick(this.timeTextValue);
this.tick(this.timestamp);
},
beforeUnmount() {
this.openmct.time.off('tick', this.tick);
},
methods: {
tick(timestamp) {
this.timeTextValue = `${moment.utc(timestamp).format(this.indicatorFormat)} ${
this.openmct.time.getTimeSystem().name
}`;
this.timestamp = timestamp;
}
}
};

View File

@@ -21,14 +21,12 @@
*****************************************************************************/
import momentTimezone from 'moment-timezone';
import mount from 'utils/mount';
import ClockViewProvider from './ClockViewProvider.js';
import ClockIndicator from './components/ClockIndicator.vue';
export default function ClockPlugin(options) {
return function install(openmct) {
const CLOCK_INDICATOR_FORMAT = 'YYYY/MM/DD HH:mm:ss';
openmct.types.addType('clock', {
name: 'Clock',
description:
@@ -92,31 +90,9 @@ export default function ClockPlugin(options) {
});
openmct.objectViews.addProvider(new ClockViewProvider(openmct));
if (options && options.enableClockIndicator === true) {
const element = document.createElement('div');
const { vNode } = mount(
{
components: {
ClockIndicator
},
provide: {
openmct
},
data() {
return {
indicatorFormat: CLOCK_INDICATOR_FORMAT
};
},
template: '<ClockIndicator :indicator-format="indicatorFormat" />'
},
{
app: openmct.app,
element
}
);
if (options?.enableClockIndicator === true) {
const indicator = {
element: vNode.el,
vueComponent: ClockIndicator,
key: 'clock-indicator',
priority: openmct.priority.LOW
};

View File

@@ -195,10 +195,6 @@ describe('Clock plugin:', () => {
let clockIndicator;
afterEach(() => {
if (clockIndicator) {
clockIndicator.remove();
}
clockIndicator = undefined;
if (appHolder) {
appHolder.remove();
@@ -223,7 +219,7 @@ describe('Clock plugin:', () => {
clockIndicator = openmct.indicators.indicatorObjects.find(
(indicator) => indicator.key === 'clock-indicator'
).element;
).vueComponent;
const hasClockIndicator = clockIndicator !== null && clockIndicator !== undefined;
expect(hasClockIndicator).toBe(true);
@@ -231,14 +227,16 @@ describe('Clock plugin:', () => {
it('contains text', async () => {
await setupClock(true);
await nextTick();
await new Promise((resolve) => requestAnimationFrame(resolve));
clockIndicator = openmct.indicators.indicatorObjects.find(
(indicator) => indicator.key === 'clock-indicator'
).element;
const clockIndicatorText = clockIndicator.textContent.trim();
).vueComponent;
const hasClockIndicator = clockIndicator !== null && clockIndicator !== undefined;
expect(hasClockIndicator).toBe(true);
const clockIndicatorText = appHolder
.querySelector('.t-indicator-clock .c-indicator__label')
.textContent.trim();
const textIncludesUTC = clockIndicatorText.includes('UTC');
expect(textIncludesUTC).toBe(true);

View File

@@ -135,8 +135,6 @@
</template>
<script>
import { inject } from 'vue';
import ConditionDescription from '@/plugins/condition/components/ConditionDescription.vue';
import ConditionError from '@/plugins/condition/components/ConditionError.vue';
import {
@@ -146,7 +144,6 @@ import {
} from '@/plugins/condition/utils/styleUtils';
import PreviewAction from '@/ui/preview/PreviewAction.js';
import { useIsEditing } from '../../../../ui/composables/editing';
import FontStyleEditor from '../../../inspectorViews/styles/FontStyleEditor.vue';
import StyleEditor from './StyleEditor.vue';
@@ -163,16 +160,10 @@ export default {
ConditionDescription
},
inject: ['openmct', 'selection', 'stylesManager'],
setup() {
const openmct = inject('openmct');
const { isEditing } = useIsEditing(openmct);
return {
isEditing
};
},
data() {
return {
staticStyle: undefined,
isEditing: this.openmct.editor.isEditing(),
mixedStyles: [],
isStaticAndConditionalStyles: false,
conditionalStyles: [],
@@ -240,20 +231,6 @@ export default {
return this.styleableFontItems.length && this.allowEditing;
}
},
watch: {
isEditing(isEditing) {
if (isEditing) {
if (this.stopProvidingTelemetry) {
this.stopProvidingTelemetry();
delete this.stopProvidingTelemetry;
}
} else {
//reset the selectedConditionID so that the condition set computation can drive it.
this.applySelectedConditionStyle('');
this.subscribeToConditionSet();
}
}
},
unmounted() {
this.removeListeners();
this.openmct.editor.off('isEditing', this.setEditState);
@@ -278,6 +255,7 @@ export default {
this.setConsolidatedFontStyle();
this.openmct.editor.on('isEditing', this.setEditState);
this.stylesManager.on('styleSelected', this.applyStyleToSelection);
},
methods: {
@@ -319,6 +297,19 @@ export default {
return objectStyles;
},
setEditState(isEditing) {
this.isEditing = isEditing;
if (this.isEditing) {
if (this.stopProvidingTelemetry) {
this.stopProvidingTelemetry();
delete this.stopProvidingTelemetry;
}
} else {
//reset the selectedConditionID so that the condition set computation can drive it.
this.applySelectedConditionStyle('');
this.subscribeToConditionSet();
}
},
enableConditionSetNav() {
this.openmct.objects
.getOriginalPath(this.conditionSetDomainObject.identifier)
@@ -329,7 +320,7 @@ export default {
},
navigateOrPreview(event) {
// If editing, display condition set in Preview overlay; otherwise nav to it while browsing
if (this.isEditing) {
if (this.openmct.editor.isEditing()) {
event.preventDefault();
this.previewAction.invoke(this.objectPath);
} else {

View File

@@ -257,6 +257,7 @@ describe('the plugin', function () {
return nextTick().then(() => {
styleViewComponentObject = component.$refs.root;
styleViewComponentObject.setEditState(true);
});
});
@@ -395,7 +396,7 @@ describe('the plugin', function () {
}
};
beforeEach(async () => {
beforeEach(() => {
displayLayoutItem = {
composition: [],
configuration: {
@@ -563,7 +564,6 @@ describe('the plugin', function () {
]
];
let viewContainer = document.createElement('div');
openmct.editor.isEditing = jasmine.createSpy().and.returnValue(true);
child.append(viewContainer);
const { vNode, destroy } = mount({
components: {
@@ -580,8 +580,10 @@ describe('the plugin', function () {
component = vNode.componentInstance;
_destroy = destroy;
await nextTick();
styleViewComponentObject = component.$refs.root;
return nextTick().then(() => {
styleViewComponentObject = component.$refs.root;
styleViewComponentObject.setEditState(true);
});
});
afterEach(() => {

View File

@@ -106,7 +106,6 @@ describe('the plugin', function () {
flexibleView.show(child, false);
await nextTick();
console.log(child);
const flexTitle = child.querySelector('.c-fl');
expect(flexTitle).not.toBeNull();

View File

@@ -56,8 +56,8 @@
{{ formatImageAltText }}
</div>
<div
role="button"
ref="focusedImageWrapper"
role="button"
class="image-wrapper"
aria-label="Image Wrapper"
:style="{
@@ -74,8 +74,8 @@
:style="getVisibleLayerStyles(layer)"
></div>
<img
aria-label="Focused Image"
ref="focusedImage"
aria-label="Focused Image"
class="c-imagery__main-image__image js-imageryView-image"
:src="imageUrl"
:draggable="!isSelectable"

View File

@@ -48,11 +48,12 @@
import mount from 'utils/mount';
import { EVENT_SNAPSHOTS_UPDATED } from '../notebook-constants.js';
import { getSnapshotContainer } from '../plugin.js';
import { NOTEBOOK_SNAPSHOT_MAX_COUNT } from '../snapshot-container.js';
import SnapshotContainerComponent from './NotebookSnapshotContainer.vue';
export default {
inject: ['openmct', 'snapshotContainer'],
inject: ['openmct'],
data() {
return {
expanded: false,
@@ -62,6 +63,9 @@ export default {
flashIndicator: false
};
},
created() {
this.snapshotContainer = getSnapshotContainer(this.openmct);
},
mounted() {
this.snapshotContainer.on(EVENT_SNAPSHOTS_UPDATED, this.snapshotsUpdated);
this.updateSnapshotIndicatorTitle();

View File

@@ -20,8 +20,6 @@
* at runtime from the About dialog for additional information.
*****************************************************************************/
import mount from 'utils/mount';
import { notebookImageMigration } from '../notebook/utils/notebook-migration.js';
import CopyToNotebookAction from './actions/CopyToNotebookAction.js';
import ExportNotebookAsTextAction from './actions/ExportNotebookAsTextAction.js';
@@ -39,7 +37,7 @@ import NotebookViewProvider from './NotebookViewProvider.js';
import SnapshotContainer from './snapshot-container.js';
let notebookSnapshotContainer;
function getSnapshotContainer(openmct) {
export function getSnapshotContainer(openmct) {
if (!notebookSnapshotContainer) {
notebookSnapshotContainer = new SnapshotContainer(openmct);
}
@@ -66,7 +64,6 @@ function installBaseNotebookFunctionality(openmct) {
return;
}
const snapshotContainer = getSnapshotContainer(openmct);
const notebookSnapshotImageType = {
name: 'Notebook Snapshot Image Storage',
description: 'Notebook Snapshot Image Storage object',
@@ -82,27 +79,10 @@ function installBaseNotebookFunctionality(openmct) {
openmct.actions.register(new CopyToNotebookAction(openmct));
openmct.actions.register(new ExportNotebookAsTextAction(openmct));
const { vNode, destroy } = mount(
{
components: {
NotebookSnapshotIndicator
},
provide: {
openmct,
snapshotContainer
},
template: '<NotebookSnapshotIndicator></NotebookSnapshotIndicator>'
},
{
app: openmct.app
}
);
const indicator = {
element: vNode.el,
vueComponent: NotebookSnapshotIndicator,
key: 'notebook-snapshot-indicator',
priority: openmct.priority.DEFAULT,
destroy: destroy
priority: openmct.priority.DEFAULT
};
openmct.indicators.add(indicator);

View File

@@ -336,24 +336,23 @@ describe('Notebook plugin:', () => {
let snapshotIndicator;
let drawerElement;
function clickSnapshotIndicator() {
const indicator = element.querySelector('.icon-camera');
const button = indicator.querySelector('button');
async function clickSnapshotIndicator() {
const button =
appHolder.querySelector('[aria-label="Show Snapshots"]') ??
appHolder.querySelector('[aria-label="Hide Snapshots"]');
const clickEvent = createMouseEvent('click');
button.dispatchEvent(clickEvent);
await nextTick();
}
beforeEach(() => {
beforeEach(async () => {
snapshotIndicator = openmct.indicators.indicatorObjects.find(
(indicator) => indicator.key === 'notebook-snapshot-indicator'
).element;
).vueComponent;
element.append(snapshotIndicator);
return nextTick().then(() => {
drawerElement = document.querySelector('.l-shell__drawer');
});
await nextTick();
drawerElement = document.querySelector('.l-shell__drawer');
});
afterEach(() => {
@@ -361,7 +360,6 @@ describe('Notebook plugin:', () => {
drawerElement.classList.remove('is-expanded');
}
snapshotIndicator.remove();
snapshotIndicator = undefined;
if (drawerElement) {
@@ -375,11 +373,11 @@ describe('Notebook plugin:', () => {
expect(hasSnapshotIndicator).toBe(true);
});
it('snapshots container has class isExpanded', () => {
it('snapshots container has class isExpanded', async () => {
let classes = drawerElement.classList;
const isExpandedBefore = classes.contains('is-expanded');
clickSnapshotIndicator();
await clickSnapshotIndicator();
classes = drawerElement.classList;
const isExpandedAfterFirstClick = classes.contains('is-expanded');
@@ -387,15 +385,15 @@ describe('Notebook plugin:', () => {
expect(isExpandedAfterFirstClick).toBeTrue();
});
it('snapshots container does not have class isExpanded', () => {
it('snapshots container does not have class isExpanded', async () => {
let classes = drawerElement.classList;
const isExpandedBefore = classes.contains('is-expanded');
clickSnapshotIndicator();
await clickSnapshotIndicator();
classes = drawerElement.classList;
const isExpandedAfterFirstClick = classes.contains('is-expanded');
clickSnapshotIndicator();
await clickSnapshotIndicator();
classes = drawerElement.classList;
const isExpandedAfterSecondClick = classes.contains('is-expanded');
@@ -404,8 +402,8 @@ describe('Notebook plugin:', () => {
expect(isExpandedAfterSecondClick).toBeFalse();
});
it('show notebook snapshots container text', () => {
clickSnapshotIndicator();
it('show notebook snapshots container text', async () => {
await clickSnapshotIndicator();
const notebookSnapshots = drawerElement.querySelector('.l-browse-bar__object-name');
const snapshotsText = notebookSnapshots.textContent.trim();

View File

@@ -72,8 +72,8 @@ export default {
this.openmct.notifications.on('dismiss-all', this.updateNotifications);
},
unmounted() {
this.openmct.notifications.of('notification', this.updateNotifications);
this.openmct.notifications.of('dismiss-all', this.updateNotifications);
this.openmct.notifications.off('notification', this.updateNotifications);
this.openmct.notifications.off('dismiss-all', this.updateNotifications);
},
methods: {
dismissAllNotifications() {

View File

@@ -19,32 +19,14 @@
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
import mount from 'utils/mount';
import NotificationIndicator from './components/NotificationIndicator.vue';
export default function plugin() {
return function install(openmct) {
const { vNode, destroy } = mount(
{
components: {
NotificationIndicator
},
provide: {
openmct
},
template: '<NotificationIndicator></NotificationIndicator>'
},
{
app: openmct.app
}
);
let indicator = {
key: 'notifications-indicator',
element: vNode.el,
priority: openmct.priority.DEFAULT,
destroy: destroy
vueComponent: NotificationIndicator,
priority: openmct.priority.DEFAULT
};
openmct.indicators.add(indicator);
};

View File

@@ -139,7 +139,9 @@ export default class Model extends EventEmitter {
/** @typedef {any} TODO */
/** @typedef {TODO} OpenMCT */
/**
* @typedef {import('../../../../openmct.js').OpenMCT} OpenMCT
*/
/**
@template {object} T

View File

@@ -211,9 +211,16 @@ export default class PlotSeries extends Model {
);
if (!this.unsubscribe) {
this.unsubscribe = this.openmct.telemetry.subscribe(this.domainObject, this.add.bind(this), {
filters: this.filters
});
this.unsubscribe = this.openmct.telemetry.subscribe(
this.domainObject,
(data) => {
this.addAll(data, true);
},
{
filters: this.filters,
strategy: this.openmct.telemetry.SUBSCRIBE_STRATEGY.BATCH
}
);
}
try {
@@ -302,9 +309,7 @@ export default class PlotSeries extends Model {
this.resetStats();
this.emit('reset');
if (newData) {
newData.forEach(function (point) {
this.add(point, true);
}, this);
this.addAll(newData, true);
}
}
/**
@@ -416,14 +421,14 @@ export default class PlotSeries extends Model {
* when adding an array of points that are already properly sorted.
*
* @private
* @param {Object} point a telemetry datum.
* @param {Boolean} [appendOnly] default false, if true will append
* @param {Object} newData a telemetry datum.
* @param {Boolean} [sorted] default false, if true will append
* a point to the end without dupe checking.
*/
add(point, appendOnly) {
add(newData, sorted = false) {
let data = this.getSeriesData();
let insertIndex = data.length;
const currentYVal = this.getYVal(point);
const currentYVal = this.getYVal(newData);
const lastYVal = this.getYVal(data[insertIndex - 1]);
if (this.isValueInvalid(currentYVal) && this.isValueInvalid(lastYVal)) {
@@ -432,22 +437,28 @@ export default class PlotSeries extends Model {
return;
}
if (!appendOnly) {
insertIndex = this.sortedIndex(point);
if (this.getXVal(data[insertIndex]) === this.getXVal(point)) {
if (!sorted) {
insertIndex = this.sortedIndex(newData);
if (this.getXVal(data[insertIndex]) === this.getXVal(newData)) {
return;
}
if (this.getXVal(data[insertIndex - 1]) === this.getXVal(point)) {
if (this.getXVal(data[insertIndex - 1]) === this.getXVal(newData)) {
return;
}
}
this.updateStats(point);
point.mctLimitState = this.evaluate(point);
data.splice(insertIndex, 0, point);
this.updateStats(newData);
newData.mctLimitState = this.evaluate(newData);
data.splice(insertIndex, 0, newData);
this.updateSeriesData(data);
this.emit('add', point, insertIndex, this);
this.emit('add', newData, insertIndex, this);
}
addAll(points, sorted = false) {
for (let i = 0; i < points.length; i++) {
this.add(points[i], sorted);
}
}
/**

View File

@@ -59,26 +59,21 @@ export default class RemoteClock extends DefaultClock {
}
start() {
this.openmct.objects
.get(this.identifier)
.then((domainObject) => {
// The start method is called when at least one listener registers with the clock.
// When the clock is changed, listeners are unregistered from the clock and the stop method is called.
// Sometimes, the objects.get call above does not resolve before the stop method is called.
// So when we proceed with the clock subscription below, we first need to ensure that there is at least one listener for our clock.
if (this.eventNames().length === 0) {
return;
}
this.openmct.time.on('timeSystem', this._timeSystemChange);
this.timeTelemetryObject = domainObject;
this.metadata = this.openmct.telemetry.getMetadata(domainObject);
this._timeSystemChange();
this._requestLatest();
this._subscribe();
})
.catch((error) => {
throw new Error(error);
});
this.openmct.objects.get(this.identifier).then((domainObject) => {
// The start method is called when at least one listener registers with the clock.
// When the clock is changed, listeners are unregistered from the clock and the stop method is called.
// Sometimes, the objects.get call above does not resolve before the stop method is called.
// So when we proceed with the clock subscription below, we first need to ensure that there is at least one listener for our clock.
if (this.eventNames().length === 0) {
return;
}
this.openmct.time.on('timeSystem', this._timeSystemChange);
this.timeTelemetryObject = domainObject;
this.metadata = this.openmct.telemetry.getMetadata(domainObject);
this._timeSystemChange();
this._requestLatest();
this._subscribe();
});
}
stop() {

View File

@@ -59,13 +59,10 @@
</template>
<script>
import { computed, inject } from 'vue';
import { useIsEditing } from '../../ui/composables/editing.js';
import ColorPalette from './ColorPalette.js';
export default {
inject: ['domainObject'],
inject: ['openmct', 'domainObject'],
props: {
currentColor: {
type: String,
@@ -93,28 +90,26 @@ export default {
}
},
emits: ['color-set'],
setup() {
const openmct = inject('openmct');
const domainObject = inject('domainObject');
const isEditing = useIsEditing(openmct);
const canEdit = computed(() => {
return isEditing.value && !domainObject.locked;
});
return {
isEditing,
canEdit
};
},
data() {
return {
swatchActive: false,
colorPaletteGroups: []
colorPaletteGroups: [],
isEditing: this.openmct.editor.isEditing()
};
},
computed: {
canEdit() {
return this.isEditing && !this.domainObject.locked;
}
},
mounted() {
this.colorPalette = new ColorPalette();
this.openmct.editor.on('isEditing', this.setEditState);
this.initialize();
},
beforeUnmount() {
this.openmct.editor.off('isEditing', this.setEditState);
},
methods: {
initialize() {
const colorPaletteGroups = this.colorPalette.groups();
@@ -129,6 +124,9 @@ export default {
});
this.colorPaletteGroups = colorPaletteGroups;
},
setEditState(isEditing) {
this.isEditing = isEditing;
},
setColor(chosenColor) {
this.$emit('color-set', chosenColor);
},

View File

@@ -25,6 +25,10 @@
class="c-progress-bar__bar"
:class="{ '--indeterminate': !progressPerc }"
:style="styleBarWidth"
role="progressbar"
:aria-valuenow="progressPerc"
aria-valuemin="0"
aria-valuemax="100"
></div>
<div v-if="progressText !== ''" class="c-progress-bar__text">
<span v-if="progressPerc > 0">{{ progressPerc }}% complete.</span>

View File

@@ -1,43 +0,0 @@
/*****************************************************************************
* 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.
*****************************************************************************/
import { ref } from 'vue';
import { useEventEmitter } from './event.js';
/**
* Provides a reactive `isEditing` property that reflects the current editing state of the
* application.
* @param {OpenMCT} openmct the open mct api
* @returns {{
* isEditing: import('vue').Ref<boolean>
* }}
*/
export function useIsEditing(openmct) {
const isEditing = ref(openmct.editor.isEditing());
useEventEmitter(openmct.editor, 'isEditing', (_isEditing) => {
isEditing.value = _isEditing;
});
return { isEditing };
}

View File

@@ -1,49 +0,0 @@
/*****************************************************************************
* 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.
*****************************************************************************/
import { onBeforeMount, onBeforeUnmount, onMounted } 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} callback - The callback function to execute when the event is triggered.
*/
export function useEventListener(target, event, callback) {
onMounted(() => target.addEventListener(event, callback));
onBeforeUnmount(() => target.removeEventListener(event, callback));
}
/**
* 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));
}

View File

@@ -37,7 +37,7 @@
>
<CreateButton class="l-shell__create-button" />
<GrandSearch ref="grand-search" />
<StatusIndicators class="l-shell__head-section l-shell__indicators" />
<StatusIndicators />
<button
class="l-shell__head__collapse-button c-icon-button"
:class="
@@ -85,11 +85,13 @@
<template #controls>
<button
class="c-icon-button l-shell__reset-tree-button icon-folders-collapse"
aria-label="Collapse all tree items"
title="Collapse all tree items"
@click="handleTreeReset"
></button>
<button
class="c-icon-button l-shell__sync-tree-button icon-target"
aria-label="Show selected item in tree"
title="Show selected item in tree"
@click="handleSyncTreeNavigation"
></button>
@@ -155,9 +157,6 @@
</template>
<script>
import { inject } from 'vue';
import { useIsEditing } from '../../ui/composables/editing.js';
import ObjectView from '../components/ObjectView.vue';
import Inspector from '../inspector/InspectorPanel.vue';
import Toolbar from '../toolbar/ToolbarContainer.vue';
@@ -189,14 +188,7 @@ export default {
RecentObjectsList
},
inject: ['openmct'],
setup() {
const openmct = inject('openmct');
const { isEditing } = useIsEditing(openmct);
return {
isEditing
};
},
data() {
data: function () {
let storedHeadProps = window.localStorage.getItem('openmct-shell-head');
let headExpanded = true;
if (storedHeadProps) {
@@ -206,6 +198,7 @@ export default {
return {
fullScreen: false,
conductorComponent: undefined,
isEditing: false,
hasToolbar: false,
actionCollection: undefined,
triggerSync: false,
@@ -224,6 +217,10 @@ export default {
}
},
mounted() {
this.openmct.editor.on('isEditing', (isEditing) => {
this.isEditing = isEditing;
});
this.openmct.selection.on('change', this.toggleHasToolbar);
},
methods: {

View File

@@ -141,14 +141,13 @@
</template>
<script>
import { inject, toRaw } from 'vue';
import { toRaw } from 'vue';
import NotebookMenuSwitcher from '@/plugins/notebook/components/NotebookMenuSwitcher.vue';
import IndependentTimeConductor from '@/plugins/timeConductor/independent/IndependentTimeConductor.vue';
import tooltipHelpers from '../../api/tooltips/tooltipMixins.js';
import { SupportedViewTypes } from '../../utils/constants.js';
import { useIsEditing } from '../composables/editing.js';
import ViewSwitcher from './ViewSwitcher.vue';
const PLACEHOLDER_OBJECT = {};
@@ -169,20 +168,14 @@ export default {
}
}
},
setup() {
const openmct = inject('openmct');
const { isEditing } = useIsEditing(openmct);
return {
isEditing
};
},
data() {
data: function () {
return {
notebookTypes: [],
showViewMenu: false,
showSaveMenu: false,
domainObject: PLACEHOLDER_OBJECT,
viewKey: undefined,
isEditing: this.openmct.editor.isEditing(),
notebookEnabled: this.openmct.types.get('notebook'),
statusBarItems: [],
status: ''
@@ -280,10 +273,14 @@ export default {
this.updateActionItems(this.actionCollection.getActionsObject());
}
},
mounted() {
mounted: function () {
document.addEventListener('click', this.closeViewAndSaveMenu);
this.promptUserbeforeNavigatingAway = this.promptUserbeforeNavigatingAway.bind(this);
window.addEventListener('beforeunload', this.promptUserbeforeNavigatingAway);
this.openmct.editor.on('isEditing', (isEditing) => {
this.isEditing = isEditing;
});
},
beforeUnmount: function () {
if (this.mutationObserver) {
@@ -355,7 +352,7 @@ export default {
});
},
promptUserbeforeNavigatingAway(event) {
if (this.isEditing) {
if (this.openmct.editor.isEditing()) {
event.preventDefault();
event.returnValue = '';
}

View File

@@ -17,24 +17,43 @@
at runtime from the About dialog for additional information.
-->
<template>
<div></div>
<div class="l-shell__head-section l-shell__indicators">
<component
:is="indicator.value.vueComponent"
v-for="indicator in sortedIndicators"
:key="indicator.value.key"
role="status"
/>
</div>
</template>
<script>
import { shallowRef } from 'vue';
export default {
inject: ['openmct'],
data() {
return {
indicators: this.openmct.indicators.getIndicatorObjectsByPriority().map(shallowRef)
};
},
computed: {
sortedIndicators() {
if (this.indicators.length === 0) {
return [];
}
return [...this.indicators].sort((a, b) => b.value.priority - a.value.priority);
}
},
beforeUnmount() {
this.openmct.indicators.off('addIndicator', this.addIndicator);
},
mounted() {
this.openmct.indicators.getIndicatorObjectsByPriority().forEach(this.addIndicator);
created() {
this.openmct.indicators.on('addIndicator', this.addIndicator);
},
methods: {
addIndicator(indicator) {
this.$el.appendChild(indicator.element);
this.indicators.push(shallowRef(indicator));
}
}
};

View File

@@ -32,7 +32,7 @@
class="c-swatch"
:style="{ background: options.value }"
role="img"
aria-label="None"
:aria-label="None"
></div>
</button>
<div v-if="open" class="c-menu c-palette c-palette--color">

View File

@@ -0,0 +1,24 @@
import { defineComponent, h, onMounted, ref } from 'vue';
/**
* Compatibility wrapper for wrapping an HTMLElement in a Vue component.
*
* @param {HTMLElement} element
* @returns {import('vue').Component}
*/
export default function vueWrapHtmlElement(element) {
return defineComponent({
setup() {
const wrapper = ref(null);
onMounted(() => {
if (wrapper.value) {
wrapper.value.appendChild(element);
}
});
// Render function returning the wrapper div
return () => h('div', { ref: wrapper });
}
});
}