Compare commits
21 Commits
composable
...
subscripti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cc0a9e402 | ||
|
|
12b921a006 | ||
|
|
34b36d544a | ||
|
|
e135498401 | ||
|
|
114864429a | ||
|
|
4cf63062c0 | ||
|
|
c4a3ace027 | ||
|
|
ad02ba7ffe | ||
|
|
0bdd5efcba | ||
|
|
2e3dc4da9a | ||
|
|
61edd0f810 | ||
|
|
4c86b66624 | ||
|
|
f079c3a3b9 | ||
|
|
947810b5d7 | ||
|
|
c28ced5c29 | ||
|
|
e530fd8e8b | ||
|
|
74c1cdf468 | ||
|
|
0061d162e1 | ||
|
|
2f2af0bac5 | ||
|
|
a87ffee264 | ||
|
|
69b2f05de2 |
@@ -492,9 +492,7 @@
|
||||
"gcov",
|
||||
"WCAG",
|
||||
"stackedplot",
|
||||
"Andale",
|
||||
"composables",
|
||||
"composable"
|
||||
"Andale"
|
||||
],
|
||||
"dictionaries": ["npm", "softwareTerms", "node", "html", "css", "bash", "en_US"],
|
||||
"ignorePaths": [
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}')`);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
194
src/api/telemetry/BatchingWebSocket.js
Normal file
194
src/api/telemetry/BatchingWebSocket.js
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
383
src/api/telemetry/WebSocketWorker.js
Normal file
383
src/api/telemetry/WebSocketWorker.js
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 = '';
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
24
src/utils/vueWrapHtmlElement.js
Normal file
24
src/utils/vueWrapHtmlElement.js
Normal 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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user