Compare commits
51 Commits
test-form-
...
v2.1.6
Author | SHA1 | Date | |
---|---|---|---|
7c82aeb4eb | |||
78a3de78b2 | |||
cff2ef7992 | |||
34f25a3e16 | |||
0751d0fed4 | |||
d49cd2510a | |||
31ff6f228a | |||
242fa3cd25 | |||
02fc1690a9 | |||
25eccbed2c | |||
ce31893797 | |||
ccdaa7d2cc | |||
492289ad82 | |||
c2957acea5 | |||
2d502b7ac2 | |||
981b1afb71 | |||
f37d3aadb6 | |||
66cbc32dd8 | |||
4bec2c459c | |||
97781c216e | |||
9656783fbd | |||
4f37daafb5 | |||
80e16ae254 | |||
cbba210ee7 | |||
060ee35dbe | |||
dda6800858 | |||
7b2ad060ac | |||
7917f0977d | |||
2e5f8e7a47 | |||
1c79d2b5cf | |||
2b7129fe0b | |||
decec8deef | |||
50592fdc0e | |||
8bc698cfed | |||
430428f689 | |||
c6987cd866 | |||
8847c862fa | |||
1b71a3bf33 | |||
9980aab18f | |||
5e530aa625 | |||
986c596d90 | |||
4d84b16d8b | |||
20c7b23a4f | |||
d1c7d133fc | |||
edbbebe329 | |||
f98a2cdd6b | |||
22621aaaf8 | |||
e0ca6200bb | |||
70074c52c8 | |||
d5adaf6e8c | |||
8125632728 |
@ -80,6 +80,7 @@ const config = {
|
||||
projectRootDir,
|
||||
"src/api/objects/object-utils.js"
|
||||
),
|
||||
"kdbush": path.join(projectRootDir, "node_modules/kdbush/kdbush.min.js"),
|
||||
utils: path.join(projectRootDir, "src/utils")
|
||||
}
|
||||
},
|
||||
@ -167,8 +168,8 @@ const config = {
|
||||
performance: {
|
||||
// We should eventually consider chunking to decrease
|
||||
// these values
|
||||
maxEntrypointSize: 25000000,
|
||||
maxAssetSize: 25000000
|
||||
maxEntrypointSize: 27000000,
|
||||
maxAssetSize: 27000000
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -89,7 +89,7 @@ Read more about [Playwright Snapshots](https://playwright.dev/docs/test-snapshot
|
||||
#### Open MCT's implementation
|
||||
|
||||
- Our Snapshot tests receive a `@snapshot` tag.
|
||||
- Snapshots need to be executed within the official Playwright container to ensure we're using the exact rendering platform in CI and locally.
|
||||
- Snapshots need to be executed within the official Playwright container to ensure we're using the exact rendering platform in CI and locally. To do a valid comparison locally:
|
||||
|
||||
```sh
|
||||
docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:[GET THIS VERSION FROM OUR CIRCLECI CONFIG FILE]-focal /bin/bash
|
||||
@ -97,9 +97,24 @@ npm install
|
||||
npx playwright test --config=e2e/playwright-ci.config.js --project=chrome --grep @snapshot
|
||||
```
|
||||
|
||||
### (WIP) Updating Snapshots
|
||||
### Updating Snapshots
|
||||
|
||||
When the `@snapshot` tests fail, they will need to be evaluated to see if the failure is an acceptable change or
|
||||
When the `@snapshot` tests fail, they will need to be evaluated to determine if the failure is an acceptable and desireable or an unintended regression.
|
||||
|
||||
To compare a snapshot, run a test and open the html report with the 'Expected' vs 'Actual' screenshot. If the actual screenshot is preferred, then the source-controlled 'Expected' snapshots will need to be updated with the following scripts.
|
||||
|
||||
MacOS
|
||||
```
|
||||
npm run test:e2e:updatesnapshots
|
||||
```
|
||||
|
||||
Linux/CI
|
||||
```sh
|
||||
// Replace {X.X.X} with the current Playwright version
|
||||
// from our package.json or circleCI configuration file
|
||||
docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v{X.X.X}-focal /bin/bash
|
||||
npm install
|
||||
npm run test:e2e:updatesnapshots
|
||||
|
||||
## Performance Testing
|
||||
|
||||
|
@ -144,7 +144,9 @@ async function createNotification(page, createNotificationOptions) {
|
||||
* @param {string} name
|
||||
*/
|
||||
async function expandTreePaneItemByName(page, name) {
|
||||
const treePane = page.locator('#tree-pane');
|
||||
const treePane = page.getByRole('tree', {
|
||||
name: 'Main Tree'
|
||||
});
|
||||
const treeItem = treePane.locator(`role=treeitem[expanded=false][name=/${name}/]`);
|
||||
const expandTriangle = treeItem.locator('.c-disclosure-triangle');
|
||||
await expandTriangle.click();
|
||||
@ -218,6 +220,30 @@ async function openObjectTreeContextMenu(page, url) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands the entire object tree (every expandable tree item).
|
||||
* @param {import('@playwright/test').Page} page
|
||||
* @param {"Main Tree" | "Create Modal Tree"} [treeName="Main Tree"]
|
||||
*/
|
||||
async function expandEntireTree(page, treeName = "Main Tree") {
|
||||
const treeLocator = page.getByRole('tree', {
|
||||
name: treeName
|
||||
});
|
||||
const collapsedTreeItems = treeLocator.getByRole('treeitem', {
|
||||
expanded: false
|
||||
}).locator('span.c-disclosure-triangle.is-enabled');
|
||||
|
||||
while (await collapsedTreeItems.count() > 0) {
|
||||
await collapsedTreeItems.nth(0).click();
|
||||
|
||||
// FIXME: Replace hard wait with something event-driven.
|
||||
// Without the wait, this fails periodically due to a race condition
|
||||
// with Vue rendering (loop exits prematurely).
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the UUID of the currently focused object by parsing the current URL
|
||||
* and returning the last UUID in the path.
|
||||
@ -362,6 +388,7 @@ module.exports = {
|
||||
createDomainObjectWithDefaults,
|
||||
createNotification,
|
||||
expandTreePaneItemByName,
|
||||
expandEntireTree,
|
||||
createPlanFromJSON,
|
||||
openObjectTreeContextMenu,
|
||||
getHashUrlToDomainObject,
|
||||
|
27
e2e/helper/addInitExampleUser.js
Normal file
@ -0,0 +1,27 @@
|
||||
/*****************************************************************************
|
||||
* Open MCT, Copyright (c) 2014-2023, United States Government
|
||||
* as represented by the Administrator of the National Aeronautics and Space
|
||||
* Administration. All rights reserved.
|
||||
*
|
||||
* Open MCT is licensed under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0.
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*
|
||||
* Open MCT includes source code licensed under additional open source
|
||||
* licenses. See the Open Source Licenses file (LICENSES.md) included with
|
||||
* this source code distribution or the Licensing information page available
|
||||
* at runtime from the About dialog for additional information.
|
||||
*****************************************************************************/
|
||||
|
||||
// This should be used to install the Example User
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const openmct = window.openmct;
|
||||
openmct.install(openmct.plugins.example.ExampleUser());
|
||||
});
|
76
e2e/helper/addInitFileInputObject.js
Normal file
@ -0,0 +1,76 @@
|
||||
class DomainObjectViewProvider {
|
||||
constructor(openmct) {
|
||||
this.key = 'doViewProvider';
|
||||
this.name = 'Domain Object View Provider';
|
||||
this.openmct = openmct;
|
||||
}
|
||||
|
||||
canView(domainObject) {
|
||||
return domainObject.type === 'imageFileInput'
|
||||
|| domainObject.type === 'jsonFileInput';
|
||||
}
|
||||
|
||||
view(domainObject, objectPath) {
|
||||
let content;
|
||||
|
||||
return {
|
||||
show: function (element) {
|
||||
const body = domainObject.selectFile.body;
|
||||
const type = typeof body;
|
||||
|
||||
content = document.createElement('div');
|
||||
content.id = 'file-input-type';
|
||||
content.textContent = JSON.stringify(type);
|
||||
element.appendChild(content);
|
||||
},
|
||||
destroy: function (element) {
|
||||
element.removeChild(content);
|
||||
content = undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const openmct = window.openmct;
|
||||
|
||||
openmct.types.addType('jsonFileInput', {
|
||||
key: 'jsonFileInput',
|
||||
name: "JSON File Input Object",
|
||||
creatable: true,
|
||||
form: [
|
||||
{
|
||||
name: 'Upload File',
|
||||
key: 'selectFile',
|
||||
control: 'file-input',
|
||||
required: true,
|
||||
text: 'Select File...',
|
||||
type: 'application/json',
|
||||
property: [
|
||||
"selectFile"
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
openmct.types.addType('imageFileInput', {
|
||||
key: 'imageFileInput',
|
||||
name: "Image File Input Object",
|
||||
creatable: true,
|
||||
form: [
|
||||
{
|
||||
name: 'Upload File',
|
||||
key: 'selectFile',
|
||||
control: 'file-input',
|
||||
required: true,
|
||||
text: 'Select File...',
|
||||
type: 'image/*',
|
||||
property: [
|
||||
"selectFile"
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
openmct.objectViews.addProvider(new DomainObjectViewProvider(openmct));
|
||||
});
|
32
e2e/helper/addInitNotebookWithUrls.js
Normal file
@ -0,0 +1,32 @@
|
||||
/*****************************************************************************
|
||||
* Open MCT, Copyright (c) 2014-2022, United States Government
|
||||
* as represented by the Administrator of the National Aeronautics and Space
|
||||
* Administration. All rights reserved.
|
||||
*
|
||||
* Open MCT is licensed under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0.
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*
|
||||
* Open MCT includes source code licensed under additional open source
|
||||
* licenses. See the Open Source Licenses file (LICENSES.md) included with
|
||||
* this source code distribution or the Licensing information page available
|
||||
* at runtime from the About dialog for additional information.
|
||||
*****************************************************************************/
|
||||
|
||||
// This should be used to install the re-instal default Notebook plugin with a simple url whitelist.
|
||||
// e.g.
|
||||
// await page.addInitScript({ path: path.join(__dirname, 'addInitNotebookWithUrls.js') });
|
||||
const NOTEBOOK_NAME = 'Notebook';
|
||||
const URL_WHITELIST = ['google.com'];
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const openmct = window.openmct;
|
||||
openmct.install(openmct.plugins.Notebook(NOTEBOOK_NAME, URL_WHITELIST));
|
||||
});
|
27
e2e/helper/addInitOperatorStatus.js
Normal file
@ -0,0 +1,27 @@
|
||||
/*****************************************************************************
|
||||
* Open MCT, Copyright (c) 2014-2023, United States Government
|
||||
* as represented by the Administrator of the National Aeronautics and Space
|
||||
* Administration. All rights reserved.
|
||||
*
|
||||
* Open MCT is licensed under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0.
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*
|
||||
* Open MCT includes source code licensed under additional open source
|
||||
* licenses. See the Open Source Licenses file (LICENSES.md) included with
|
||||
* this source code distribution or the Licensing information page available
|
||||
* at runtime from the About dialog for additional information.
|
||||
*****************************************************************************/
|
||||
|
||||
// This should be used to install the Operator Status
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const openmct = window.openmct;
|
||||
openmct.install(openmct.plugins.OperatorStatus());
|
||||
});
|
BIN
e2e/test-data/rick.jpg
Normal file
After Width: | Height: | Size: 10 KiB |
@ -21,7 +21,7 @@
|
||||
*****************************************************************************/
|
||||
|
||||
const { test, expect } = require('../../pluginFixtures.js');
|
||||
const { createDomainObjectWithDefaults, createNotification } = require('../../appActions.js');
|
||||
const { createDomainObjectWithDefaults, createNotification, expandEntireTree } = require('../../appActions.js');
|
||||
|
||||
test.describe('AppActions', () => {
|
||||
test('createDomainObjectsWithDefaults', async ({ page }) => {
|
||||
@ -49,11 +49,11 @@ test.describe('AppActions', () => {
|
||||
parent: e2eFolder.uuid
|
||||
});
|
||||
|
||||
await page.goto(timer1.url, { waitUntil: 'networkidle' });
|
||||
await page.goto(timer1.url);
|
||||
await expect(page.locator('.l-browse-bar__object-name')).toHaveText(timer1.name);
|
||||
await page.goto(timer2.url, { waitUntil: 'networkidle' });
|
||||
await page.goto(timer2.url);
|
||||
await expect(page.locator('.l-browse-bar__object-name')).toHaveText(timer2.name);
|
||||
await page.goto(timer3.url, { waitUntil: 'networkidle' });
|
||||
await page.goto(timer3.url);
|
||||
await expect(page.locator('.l-browse-bar__object-name')).toHaveText(timer3.name);
|
||||
});
|
||||
|
||||
@ -73,11 +73,11 @@ test.describe('AppActions', () => {
|
||||
name: 'Folder Baz',
|
||||
parent: folder2.uuid
|
||||
});
|
||||
await page.goto(folder1.url, { waitUntil: 'networkidle' });
|
||||
await page.goto(folder1.url);
|
||||
await expect(page.locator('.l-browse-bar__object-name')).toHaveText(folder1.name);
|
||||
await page.goto(folder2.url, { waitUntil: 'networkidle' });
|
||||
await page.goto(folder2.url);
|
||||
await expect(page.locator('.l-browse-bar__object-name')).toHaveText(folder2.name);
|
||||
await page.goto(folder3.url, { waitUntil: 'networkidle' });
|
||||
await page.goto(folder3.url);
|
||||
await expect(page.locator('.l-browse-bar__object-name')).toHaveText(folder3.name);
|
||||
|
||||
expect(folder1.url).toBe(`${e2eFolder.url}/${folder1.uuid}`);
|
||||
@ -109,4 +109,57 @@ test.describe('AppActions', () => {
|
||||
await expect(page.locator('.c-message-banner')).toHaveClass(/error/);
|
||||
await page.locator('[aria-label="Dismiss"]').click();
|
||||
});
|
||||
test('expandEntireTree', async ({ page }) => {
|
||||
await page.goto('./', { waitUntil: 'networkidle' });
|
||||
|
||||
const rootFolder = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Folder'
|
||||
});
|
||||
const folder1 = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Folder',
|
||||
parent: rootFolder.uuid
|
||||
});
|
||||
|
||||
await createDomainObjectWithDefaults(page, {
|
||||
type: 'Clock',
|
||||
parent: folder1.uuid
|
||||
});
|
||||
const folder2 = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Folder',
|
||||
parent: folder1.uuid
|
||||
});
|
||||
await createDomainObjectWithDefaults(page, {
|
||||
type: 'Folder',
|
||||
parent: folder1.uuid
|
||||
});
|
||||
await createDomainObjectWithDefaults(page, {
|
||||
type: 'Display Layout',
|
||||
parent: folder2.uuid
|
||||
});
|
||||
await createDomainObjectWithDefaults(page, {
|
||||
type: 'Folder',
|
||||
parent: folder2.uuid
|
||||
});
|
||||
|
||||
await page.goto('./#/browse/mine');
|
||||
await expandEntireTree(page);
|
||||
const treePane = page.getByRole('tree', {
|
||||
name: "Main Tree"
|
||||
});
|
||||
const treePaneCollapsedItems = treePane.getByRole('treeitem', { expanded: false });
|
||||
expect(await treePaneCollapsedItems.count()).toBe(0);
|
||||
|
||||
await page.goto('./#/browse/mine');
|
||||
//Click the Create button
|
||||
await page.click('button:has-text("Create")');
|
||||
|
||||
// Click the object specified by 'type'
|
||||
await page.click(`li[role='menuitem']:text("Clock")`);
|
||||
await expandEntireTree(page, "Create Modal Tree");
|
||||
const locatorTree = page.getByRole("tree", {
|
||||
name: "Create Modal Tree"
|
||||
});
|
||||
const locatorTreeCollapsedItems = locatorTree.locator('role=treeitem[expanded=false]');
|
||||
expect(await locatorTreeCollapsedItems.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
@ -30,6 +30,8 @@ const genUuid = require('uuid').v4;
|
||||
const path = require('path');
|
||||
|
||||
const TEST_FOLDER = 'test folder';
|
||||
const jsonFilePath = 'e2e/test-data/ExampleLayouts.json';
|
||||
const imageFilePath = 'e2e/test-data/rick.jpg';
|
||||
|
||||
test.describe('Form Validation Behavior', () => {
|
||||
test('Required Field indicators appear if title is empty and can be corrected', async ({ page }) => {
|
||||
@ -68,6 +70,41 @@ test.describe('Form Validation Behavior', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Form File Input Behavior', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// eslint-disable-next-line no-undef
|
||||
await page.addInitScript({ path: path.join(__dirname, '../../helper', 'addInitFileInputObject.js') });
|
||||
});
|
||||
|
||||
test('Can select a JSON file type', async ({ page }) => {
|
||||
await page.goto('./', { waitUntil: 'networkidle' });
|
||||
|
||||
await page.getByRole('button', { name: ' Create ' }).click();
|
||||
await page.getByRole('menuitem', { name: 'JSON File Input Object' }).click();
|
||||
|
||||
await page.setInputFiles('#fileElem', jsonFilePath);
|
||||
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
const type = await page.locator('#file-input-type').textContent();
|
||||
await expect(type).toBe(`"string"`);
|
||||
});
|
||||
|
||||
test('Can select an image file type', async ({ page }) => {
|
||||
await page.goto('./', { waitUntil: 'networkidle' });
|
||||
|
||||
await page.getByRole('button', { name: ' Create ' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Image File Input Object' }).click();
|
||||
|
||||
await page.setInputFiles('#fileElem', imageFilePath);
|
||||
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
const type = await page.locator('#file-input-type').textContent();
|
||||
await expect(type).toBe(`"object"`);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Persistence operations @addInit', () => {
|
||||
// add non persistable root item
|
||||
test.beforeEach(async ({ page }) => {
|
||||
|
@ -43,48 +43,80 @@ test.describe('Move & link item tests', () => {
|
||||
name: 'Child Folder',
|
||||
parent: parentFolder.uuid
|
||||
});
|
||||
await createDomainObjectWithDefaults(page, {
|
||||
const grandchildFolder = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Folder',
|
||||
name: 'Grandchild Folder',
|
||||
parent: childFolder.uuid
|
||||
});
|
||||
|
||||
// Attempt to move parent to its own grandparent
|
||||
await page.locator(`text=Open MCT ${myItemsFolderName} >> span`).nth(3).click();
|
||||
await page.locator('.c-disclosure-triangle >> nth=0').click();
|
||||
await page.locator('button[title="Show selected item in tree"]').click();
|
||||
|
||||
await page.locator(`a:has-text("Parent Folder") >> nth=0`).click({
|
||||
const treePane = page.getByRole('tree', {
|
||||
name: 'Main Tree'
|
||||
});
|
||||
await treePane.getByRole('treeitem', {
|
||||
name: 'Parent Folder'
|
||||
}).click({
|
||||
button: 'right'
|
||||
});
|
||||
|
||||
await page.locator('li.icon-move').click();
|
||||
await page.locator('form[name="mctForm"] >> .c-disclosure-triangle >> nth=0').click();
|
||||
await page.locator('form[name="mctForm"] >> text=Parent Folder').click();
|
||||
await page.getByRole('menuitem', {
|
||||
name: /Move/
|
||||
}).click();
|
||||
|
||||
const createModalTree = page.getByRole('tree', {
|
||||
name: "Create Modal Tree"
|
||||
});
|
||||
const myItemsLocatorTreeItem = createModalTree.getByRole('treeitem', {
|
||||
name: myItemsFolderName
|
||||
});
|
||||
await myItemsLocatorTreeItem.locator('.c-disclosure-triangle').click();
|
||||
await myItemsLocatorTreeItem.click();
|
||||
|
||||
const parentFolderLocatorTreeItem = createModalTree.getByRole('treeitem', {
|
||||
name: parentFolder.name
|
||||
});
|
||||
await parentFolderLocatorTreeItem.locator('.c-disclosure-triangle').click();
|
||||
await parentFolderLocatorTreeItem.click();
|
||||
await expect(page.locator('[aria-label="Save"]')).toBeDisabled();
|
||||
await page.locator('form[name="mctForm"] >> .c-disclosure-triangle >> nth=1').click();
|
||||
await page.locator('form[name="mctForm"] >> text=Child Folder').click();
|
||||
|
||||
const childFolderLocatorTreeItem = createModalTree.getByRole('treeitem', {
|
||||
name: new RegExp(childFolder.name)
|
||||
});
|
||||
await childFolderLocatorTreeItem.locator('.c-disclosure-triangle').click();
|
||||
await childFolderLocatorTreeItem.click();
|
||||
await expect(page.locator('[aria-label="Save"]')).toBeDisabled();
|
||||
await page.locator('form[name="mctForm"] >> .c-disclosure-triangle >> nth=2').click();
|
||||
await page.locator('form[name="mctForm"] >> text=Grandchild Folder').click();
|
||||
|
||||
const grandchildFolderLocatorTreeItem = createModalTree.getByRole('treeitem', {
|
||||
name: grandchildFolder.name
|
||||
});
|
||||
await grandchildFolderLocatorTreeItem.locator('.c-disclosure-triangle').click();
|
||||
await grandchildFolderLocatorTreeItem.click();
|
||||
await expect(page.locator('[aria-label="Save"]')).toBeDisabled();
|
||||
await page.locator('form[name="mctForm"] >> text=Parent Folder').click();
|
||||
|
||||
await parentFolderLocatorTreeItem.click();
|
||||
await expect(page.locator('[aria-label="Save"]')).toBeDisabled();
|
||||
await page.locator('[aria-label="Cancel"]').click();
|
||||
|
||||
// Move Child Folder from Parent Folder to My Items
|
||||
await page.locator('.c-disclosure-triangle >> nth=0').click();
|
||||
await page.locator('.c-disclosure-triangle >> nth=1').click();
|
||||
|
||||
await page.locator(`a:has-text("Child Folder") >> nth=0`).click({
|
||||
await treePane.getByRole('treeitem', {
|
||||
name: new RegExp(childFolder.name)
|
||||
}).click({
|
||||
button: 'right'
|
||||
});
|
||||
await page.locator('li.icon-move').click();
|
||||
await page.locator(`form[name="mctForm"] >> text=${myItemsFolderName}`).click();
|
||||
await page.getByRole('menuitem', {
|
||||
name: /Move/
|
||||
}).click();
|
||||
await myItemsLocatorTreeItem.click();
|
||||
|
||||
await page.locator('button:has-text("OK")').click();
|
||||
await page.locator('[aria-label="Save"]').click();
|
||||
const myItemsPaneTreeItem = treePane.getByRole('treeitem', {
|
||||
name: myItemsFolderName
|
||||
});
|
||||
|
||||
// Expect that Child Folder is in My Items, the root folder
|
||||
expect(page.locator(`text=${myItemsFolderName} >> nth=0:has(text=Child Folder)`)).toBeTruthy();
|
||||
expect(myItemsPaneTreeItem.locator('nth=0:has(text=Child Folder)')).toBeTruthy();
|
||||
});
|
||||
test('Create a basic object and verify that it cannot be moved to telemetry object without Composition Provider', async ({ page, openmctConfig }) => {
|
||||
const { myItemsFolderName } = openmctConfig;
|
||||
@ -114,7 +146,7 @@ test.describe('Move & link item tests', () => {
|
||||
|
||||
// See if it's possible to put the folder in the Telemetry object during creation (Soft Assert)
|
||||
await page.locator(`form[name="mctForm"] >> text=${telemetryTable}`).click();
|
||||
let okButton = await page.locator('button.c-button.c-button--major:has-text("OK")');
|
||||
let okButton = page.locator('button.c-button.c-button--major:has-text("OK")');
|
||||
let okButtonStateDisabled = await okButton.isDisabled();
|
||||
expect.soft(okButtonStateDisabled).toBeTruthy();
|
||||
|
||||
@ -138,7 +170,7 @@ test.describe('Move & link item tests', () => {
|
||||
// See if it's possible to put the folder in the Telemetry object after creation
|
||||
await page.locator(`text=Location Open MCT ${myItemsFolderName} >> span`).nth(3).click();
|
||||
await page.locator(`form[name="mctForm"] >> text=${telemetryTable}`).click();
|
||||
let okButton2 = await page.locator('button.c-button.c-button--major:has-text("OK")');
|
||||
let okButton2 = page.locator('button.c-button.c-button--major:has-text("OK")');
|
||||
let okButtonStateDisabled2 = await okButton2.isDisabled();
|
||||
expect(okButtonStateDisabled2).toBeTruthy();
|
||||
});
|
||||
@ -158,48 +190,80 @@ test.describe('Move & link item tests', () => {
|
||||
name: 'Child Folder',
|
||||
parent: parentFolder.uuid
|
||||
});
|
||||
await createDomainObjectWithDefaults(page, {
|
||||
const grandchildFolder = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Folder',
|
||||
name: 'Grandchild Folder',
|
||||
parent: childFolder.uuid
|
||||
});
|
||||
|
||||
// Attempt to link parent to its own grandparent
|
||||
await page.locator(`text=Open MCT ${myItemsFolderName} >> span`).nth(3).click();
|
||||
await page.locator('.c-disclosure-triangle >> nth=0').click();
|
||||
// Attempt to move parent to its own grandparent
|
||||
await page.locator('button[title="Show selected item in tree"]').click();
|
||||
|
||||
await page.locator(`a:has-text("Parent Folder") >> nth=0`).click({
|
||||
const treePane = page.getByRole('tree', {
|
||||
name: 'Main Tree'
|
||||
});
|
||||
await treePane.getByRole('treeitem', {
|
||||
name: 'Parent Folder'
|
||||
}).click({
|
||||
button: 'right'
|
||||
});
|
||||
|
||||
await page.locator('li.icon-link').click();
|
||||
await page.locator('form[name="mctForm"] >> .c-disclosure-triangle >> nth=0').click();
|
||||
await page.locator('form[name="mctForm"] >> text=Parent Folder').click();
|
||||
await page.getByRole('menuitem', {
|
||||
name: /Move/
|
||||
}).click();
|
||||
|
||||
const createModalTree = page.getByRole('tree', {
|
||||
name: "Create Modal Tree"
|
||||
});
|
||||
const myItemsLocatorTreeItem = createModalTree.getByRole('treeitem', {
|
||||
name: myItemsFolderName
|
||||
});
|
||||
await myItemsLocatorTreeItem.locator('.c-disclosure-triangle').click();
|
||||
await myItemsLocatorTreeItem.click();
|
||||
|
||||
const parentFolderLocatorTreeItem = createModalTree.getByRole('treeitem', {
|
||||
name: parentFolder.name
|
||||
});
|
||||
await parentFolderLocatorTreeItem.locator('.c-disclosure-triangle').click();
|
||||
await parentFolderLocatorTreeItem.click();
|
||||
await expect(page.locator('[aria-label="Save"]')).toBeDisabled();
|
||||
await page.locator('form[name="mctForm"] >> .c-disclosure-triangle >> nth=1').click();
|
||||
await page.locator('form[name="mctForm"] >> text=Child Folder').click();
|
||||
|
||||
const childFolderLocatorTreeItem = createModalTree.getByRole('treeitem', {
|
||||
name: new RegExp(childFolder.name)
|
||||
});
|
||||
await childFolderLocatorTreeItem.locator('.c-disclosure-triangle').click();
|
||||
await childFolderLocatorTreeItem.click();
|
||||
await expect(page.locator('[aria-label="Save"]')).toBeDisabled();
|
||||
await page.locator('form[name="mctForm"] >> .c-disclosure-triangle >> nth=2').click();
|
||||
await page.locator('form[name="mctForm"] >> text=Grandchild Folder').click();
|
||||
|
||||
const grandchildFolderLocatorTreeItem = createModalTree.getByRole('treeitem', {
|
||||
name: grandchildFolder.name
|
||||
});
|
||||
await grandchildFolderLocatorTreeItem.locator('.c-disclosure-triangle').click();
|
||||
await grandchildFolderLocatorTreeItem.click();
|
||||
await expect(page.locator('[aria-label="Save"]')).toBeDisabled();
|
||||
await page.locator('form[name="mctForm"] >> text=Parent Folder').click();
|
||||
|
||||
await parentFolderLocatorTreeItem.click();
|
||||
await expect(page.locator('[aria-label="Save"]')).toBeDisabled();
|
||||
await page.locator('[aria-label="Cancel"]').click();
|
||||
|
||||
// Link Child Folder from Parent Folder to My Items
|
||||
await page.locator('.c-disclosure-triangle >> nth=0').click();
|
||||
await page.locator('.c-disclosure-triangle >> nth=1').click();
|
||||
|
||||
await page.locator(`a:has-text("Child Folder") >> nth=0`).click({
|
||||
// Move Child Folder from Parent Folder to My Items
|
||||
await treePane.getByRole('treeitem', {
|
||||
name: new RegExp(childFolder.name)
|
||||
}).click({
|
||||
button: 'right'
|
||||
});
|
||||
await page.locator('li.icon-link').click();
|
||||
await page.locator(`form[name="mctForm"] >> text=${myItemsFolderName}`).click();
|
||||
await page.getByRole('menuitem', {
|
||||
name: /Link/
|
||||
}).click();
|
||||
await myItemsLocatorTreeItem.click();
|
||||
|
||||
await page.locator('button:has-text("OK")').click();
|
||||
await page.locator('[aria-label="Save"]').click();
|
||||
const myItemsPaneTreeItem = treePane.getByRole('treeitem', {
|
||||
name: myItemsFolderName
|
||||
});
|
||||
|
||||
// Expect that Child Folder is in My Items, the root folder
|
||||
expect(page.locator(`text=${myItemsFolderName} >> nth=0:has(text=Child Folder)`)).toBeTruthy();
|
||||
expect(myItemsPaneTreeItem.locator('nth=0:has(text=Child Folder)')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -26,6 +26,7 @@ This test suite is dedicated to tests which verify Open MCT's Notification funct
|
||||
|
||||
// FIXME: Remove this eslint exception once tests are implemented
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { createDomainObjectWithDefaults } = require('../../appActions');
|
||||
const { test, expect } = require('../../pluginFixtures');
|
||||
|
||||
test.describe('Notifications List', () => {
|
||||
@ -37,3 +38,42 @@ test.describe('Notifications List', () => {
|
||||
// Verify that the other notifications are still present in the notifications list
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Notification Overlay', () => {
|
||||
test('Closing notification list after notification banner disappeared does not cause it to open automatically', async ({ page }) => {
|
||||
test.info().annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/nasa/openmct/issues/6130'
|
||||
});
|
||||
|
||||
// Go to baseURL
|
||||
await page.goto('./', { waitUntil: 'networkidle' });
|
||||
|
||||
// Create a new Display Layout object
|
||||
await createDomainObjectWithDefaults(page, { type: 'Display Layout' });
|
||||
|
||||
// Click on the button "Review 1 Notification"
|
||||
await page.click('button[aria-label="Review 1 Notification"]');
|
||||
|
||||
// Verify that Notification List is open
|
||||
expect(await page.locator('div[role="dialog"]').isVisible()).toBe(true);
|
||||
|
||||
// Wait until there is no Notification Banner
|
||||
await page.waitForSelector('div[role="alert"]', { state: 'detached'});
|
||||
|
||||
// Click on the "Close" button of the Notification List
|
||||
await page.click('button[aria-label="Close"]');
|
||||
|
||||
// On the Display Layout object, click on the "Edit" button
|
||||
await page.click('button[title="Edit"]');
|
||||
|
||||
// Click on the "Save" button
|
||||
await page.click('button[title="Save"]');
|
||||
|
||||
// Click on the "Save and Finish Editing" option
|
||||
await page.click('li[title="Save and Finish Editing"]');
|
||||
|
||||
// Verify that Notification List is NOT open
|
||||
expect(await page.locator('div[role="dialog"]').isVisible()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
@ -98,8 +98,8 @@ test.describe.serial('Condition Set CRUD Operations on @localStorage', () => {
|
||||
await page.locator('text=Conditions View Snapshot >> button').nth(3).click();
|
||||
|
||||
//Edit Condition Set Name from main view
|
||||
await page.locator('text=Unnamed Condition Set').first().fill('Renamed Condition Set');
|
||||
await page.locator('text=Renamed Condition Set').first().press('Enter');
|
||||
await page.locator('.l-browse-bar__object-name').filter({ hasText: 'Unnamed Condition Set' }).first().fill('Renamed Condition Set');
|
||||
await page.locator('.l-browse-bar__object-name').filter({ hasText: 'Renamed Condition Set' }).first().press('Enter');
|
||||
// Click Save Button
|
||||
await page.locator('text=Snapshot Save and Finish Editing Save and Continue Editing >> button').nth(1).click();
|
||||
// Click Save and Finish Editing Option
|
||||
|
@ -24,6 +24,7 @@ const { test, expect } = require('../../../../pluginFixtures');
|
||||
const { createDomainObjectWithDefaults, setStartOffset, setFixedTimeMode, setRealTimeMode } = require('../../../../appActions');
|
||||
|
||||
test.describe('Display Layout', () => {
|
||||
/** @type {import('../../../../appActions').CreatedObjectInfo} */
|
||||
let sineWaveObject;
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('./', { waitUntil: 'networkidle' });
|
||||
@ -31,8 +32,7 @@ test.describe('Display Layout', () => {
|
||||
|
||||
// Create Sine Wave Generator
|
||||
sineWaveObject = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Sine Wave Generator',
|
||||
name: "Test Sine Wave Generator"
|
||||
type: 'Sine Wave Generator'
|
||||
});
|
||||
});
|
||||
test('alpha-numeric widget telemetry value exactly matches latest telemetry value received in real time', async ({ page }) => {
|
||||
@ -47,7 +47,14 @@ test.describe('Display Layout', () => {
|
||||
// Expand the 'My Items' folder in the left tree
|
||||
await page.locator('.c-tree__item__view-control.c-disclosure-triangle').click();
|
||||
// Add the Sine Wave Generator to the Display Layout and save changes
|
||||
await page.dragAndDrop('text=Test Sine Wave Generator', '.l-layout__grid-holder');
|
||||
const treePane = page.getByRole('tree', {
|
||||
name: 'Main Tree'
|
||||
});
|
||||
const sineWaveGeneratorTreeItem = treePane.getByRole('treeitem', {
|
||||
name: new RegExp(sineWaveObject.name)
|
||||
});
|
||||
const layoutGridHolder = page.locator('.l-layout__grid-holder');
|
||||
await sineWaveGeneratorTreeItem.dragTo(layoutGridHolder);
|
||||
await page.locator('button[title="Save"]').click();
|
||||
await page.locator('text=Save and Finish Editing').click();
|
||||
|
||||
@ -74,7 +81,14 @@ test.describe('Display Layout', () => {
|
||||
// Expand the 'My Items' folder in the left tree
|
||||
await page.locator('.c-tree__item__view-control.c-disclosure-triangle').click();
|
||||
// Add the Sine Wave Generator to the Display Layout and save changes
|
||||
await page.dragAndDrop('text=Test Sine Wave Generator', '.l-layout__grid-holder');
|
||||
const treePane = page.getByRole('tree', {
|
||||
name: 'Main Tree'
|
||||
});
|
||||
const sineWaveGeneratorTreeItem = treePane.getByRole('treeitem', {
|
||||
name: new RegExp(sineWaveObject.name)
|
||||
});
|
||||
const layoutGridHolder = page.locator('.l-layout__grid-holder');
|
||||
await sineWaveGeneratorTreeItem.dragTo(layoutGridHolder);
|
||||
await page.locator('button[title="Save"]').click();
|
||||
await page.locator('text=Save and Finish Editing').click();
|
||||
|
||||
@ -105,7 +119,14 @@ test.describe('Display Layout', () => {
|
||||
// Expand the 'My Items' folder in the left tree
|
||||
await page.locator('.c-tree__item__view-control.c-disclosure-triangle').click();
|
||||
// Add the Sine Wave Generator to the Display Layout and save changes
|
||||
await page.dragAndDrop('text=Test Sine Wave Generator', '.l-layout__grid-holder');
|
||||
const treePane = page.getByRole('tree', {
|
||||
name: 'Main Tree'
|
||||
});
|
||||
const sineWaveGeneratorTreeItem = treePane.getByRole('treeitem', {
|
||||
name: new RegExp(sineWaveObject.name)
|
||||
});
|
||||
const layoutGridHolder = page.locator('.l-layout__grid-holder');
|
||||
await sineWaveGeneratorTreeItem.dragTo(layoutGridHolder);
|
||||
await page.locator('button[title="Save"]').click();
|
||||
await page.locator('text=Save and Finish Editing').click();
|
||||
|
||||
@ -115,7 +136,7 @@ test.describe('Display Layout', () => {
|
||||
await page.locator('.c-tree__item.is-navigated-object .c-disclosure-triangle').click();
|
||||
|
||||
// Bring up context menu and remove
|
||||
await page.locator('.c-tree__item.is-alias .c-tree__item__name:text("Test Sine Wave Generator")').first().click({ button: 'right' });
|
||||
await sineWaveGeneratorTreeItem.nth(1).click({ button: 'right' });
|
||||
await page.locator('li[role="menuitem"]:has-text("Remove")').click();
|
||||
await page.locator('button:has-text("OK")').click();
|
||||
|
||||
@ -130,8 +151,7 @@ test.describe('Display Layout', () => {
|
||||
});
|
||||
// Create a Display Layout
|
||||
const displayLayout = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Display Layout',
|
||||
name: "Test Display Layout"
|
||||
type: 'Display Layout'
|
||||
});
|
||||
// Edit Display Layout
|
||||
await page.locator('[title="Edit"]').click();
|
||||
@ -139,7 +159,14 @@ test.describe('Display Layout', () => {
|
||||
// Expand the 'My Items' folder in the left tree
|
||||
await page.locator('.c-tree__item__view-control.c-disclosure-triangle').click();
|
||||
// Add the Sine Wave Generator to the Display Layout and save changes
|
||||
await page.dragAndDrop('text=Test Sine Wave Generator', '.l-layout__grid-holder');
|
||||
const treePane = page.getByRole('tree', {
|
||||
name: 'Main Tree'
|
||||
});
|
||||
const sineWaveGeneratorTreeItem = treePane.getByRole('treeitem', {
|
||||
name: new RegExp(sineWaveObject.name)
|
||||
});
|
||||
const layoutGridHolder = page.locator('.l-layout__grid-holder');
|
||||
await sineWaveGeneratorTreeItem.dragTo(layoutGridHolder);
|
||||
await page.locator('button[title="Save"]').click();
|
||||
await page.locator('text=Save and Finish Editing').click();
|
||||
|
||||
@ -152,7 +179,7 @@ test.describe('Display Layout', () => {
|
||||
await page.goto(sineWaveObject.url);
|
||||
|
||||
// Bring up context menu and remove
|
||||
await page.locator('.c-tree__item.is-alias .c-tree__item__name:text("Test Sine Wave Generator")').click({ button: 'right' });
|
||||
await sineWaveGeneratorTreeItem.first().click({ button: 'right' });
|
||||
await page.locator('li[role="menuitem"]:has-text("Remove")').click();
|
||||
await page.locator('button:has-text("OK")').click();
|
||||
|
||||
|
@ -25,26 +25,33 @@ const { createDomainObjectWithDefaults } = require('../../../../appActions');
|
||||
|
||||
test.describe('Flexible Layout', () => {
|
||||
let sineWaveObject;
|
||||
let clockObject;
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('./', { waitUntil: 'networkidle' });
|
||||
|
||||
// Create Sine Wave Generator
|
||||
sineWaveObject = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Sine Wave Generator',
|
||||
name: "Test Sine Wave Generator"
|
||||
type: 'Sine Wave Generator'
|
||||
});
|
||||
|
||||
// Create Clock Object
|
||||
await createDomainObjectWithDefaults(page, {
|
||||
type: 'Clock',
|
||||
name: "Test Clock"
|
||||
clockObject = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Clock'
|
||||
});
|
||||
});
|
||||
test('panes have the appropriate draggable attribute while in Edit and Browse modes', async ({ page }) => {
|
||||
const treePane = page.getByRole('tree', {
|
||||
name: 'Main Tree'
|
||||
});
|
||||
const sineWaveGeneratorTreeItem = treePane.getByRole('treeitem', {
|
||||
name: new RegExp(sineWaveObject.name)
|
||||
});
|
||||
const clockTreeItem = treePane.getByRole('treeitem', {
|
||||
name: new RegExp(clockObject.name)
|
||||
});
|
||||
// Create a Flexible Layout
|
||||
await createDomainObjectWithDefaults(page, {
|
||||
type: 'Flexible Layout',
|
||||
name: "Test Flexible Layout"
|
||||
type: 'Flexible Layout'
|
||||
});
|
||||
// Edit Flexible Layout
|
||||
await page.locator('[title="Edit"]').click();
|
||||
@ -52,8 +59,8 @@ test.describe('Flexible Layout', () => {
|
||||
// Expand the 'My Items' folder in the left tree
|
||||
await page.locator('.c-tree__item__view-control.c-disclosure-triangle').first().click();
|
||||
// Add the Sine Wave Generator and Clock to the Flexible Layout
|
||||
await page.dragAndDrop('text=Test Sine Wave Generator', '.c-fl__container.is-empty');
|
||||
await page.dragAndDrop('text=Test Clock', '.c-fl__container.is-empty');
|
||||
await sineWaveGeneratorTreeItem.dragTo(page.locator('.c-fl__container.is-empty').first());
|
||||
await clockTreeItem.dragTo(page.locator('.c-fl__container.is-empty'));
|
||||
// Check that panes can be dragged while Flexible Layout is in Edit mode
|
||||
let dragWrapper = page.locator('.c-fl-container__frames-holder .c-fl-frame__drag-wrapper').first();
|
||||
await expect(dragWrapper).toHaveAttribute('draggable', 'true');
|
||||
@ -65,10 +72,15 @@ test.describe('Flexible Layout', () => {
|
||||
await expect(dragWrapper).toHaveAttribute('draggable', 'false');
|
||||
});
|
||||
test('items in a flexible layout can be removed with object tree context menu when viewing the flexible layout', async ({ page }) => {
|
||||
const treePane = page.getByRole('tree', {
|
||||
name: 'Main Tree'
|
||||
});
|
||||
const sineWaveGeneratorTreeItem = treePane.getByRole('treeitem', {
|
||||
name: new RegExp(sineWaveObject.name)
|
||||
});
|
||||
// Create a Display Layout
|
||||
await createDomainObjectWithDefaults(page, {
|
||||
type: 'Flexible Layout',
|
||||
name: "Test Flexible Layout"
|
||||
type: 'Flexible Layout'
|
||||
});
|
||||
// Edit Flexible Layout
|
||||
await page.locator('[title="Edit"]').click();
|
||||
@ -76,7 +88,7 @@ test.describe('Flexible Layout', () => {
|
||||
// Expand the 'My Items' folder in the left tree
|
||||
await page.locator('.c-tree__item__view-control.c-disclosure-triangle').first().click();
|
||||
// Add the Sine Wave Generator to the Flexible Layout and save changes
|
||||
await page.dragAndDrop('text=Test Sine Wave Generator', '.c-fl__container.is-empty');
|
||||
await sineWaveGeneratorTreeItem.dragTo(page.locator('.c-fl__container.is-empty').first());
|
||||
await page.locator('button[title="Save"]').click();
|
||||
await page.locator('text=Save and Finish Editing').click();
|
||||
|
||||
@ -86,7 +98,7 @@ test.describe('Flexible Layout', () => {
|
||||
await page.locator('.c-tree__item.is-navigated-object .c-disclosure-triangle').click();
|
||||
|
||||
// Bring up context menu and remove
|
||||
await page.locator('.c-tree__item.is-alias .c-tree__item__name:text("Test Sine Wave Generator")').first().click({ button: 'right' });
|
||||
await sineWaveGeneratorTreeItem.first().click({ button: 'right' });
|
||||
await page.locator('li[role="menuitem"]:has-text("Remove")').click();
|
||||
await page.locator('button:has-text("OK")').click();
|
||||
|
||||
@ -98,10 +110,16 @@ test.describe('Flexible Layout', () => {
|
||||
type: 'issue',
|
||||
description: 'https://github.com/nasa/openmct/issues/3117'
|
||||
});
|
||||
const treePane = page.getByRole('tree', {
|
||||
name: 'Main Tree'
|
||||
});
|
||||
const sineWaveGeneratorTreeItem = treePane.getByRole('treeitem', {
|
||||
name: new RegExp(sineWaveObject.name)
|
||||
});
|
||||
|
||||
// Create a Flexible Layout
|
||||
const flexibleLayout = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Flexible Layout',
|
||||
name: "Test Flexible Layout"
|
||||
type: 'Flexible Layout'
|
||||
});
|
||||
// Edit Flexible Layout
|
||||
await page.locator('[title="Edit"]').click();
|
||||
@ -109,7 +127,7 @@ test.describe('Flexible Layout', () => {
|
||||
// Expand the 'My Items' folder in the left tree
|
||||
await page.locator('.c-tree__item__view-control.c-disclosure-triangle').click();
|
||||
// Add the Sine Wave Generator to the Flexible Layout and save changes
|
||||
await page.dragAndDrop('text=Test Sine Wave Generator', '.c-fl__container.is-empty');
|
||||
await sineWaveGeneratorTreeItem.dragTo(page.locator('.c-fl__container.is-empty').first());
|
||||
await page.locator('button[title="Save"]').click();
|
||||
await page.locator('text=Save and Finish Editing').click();
|
||||
|
||||
@ -122,7 +140,7 @@ test.describe('Flexible Layout', () => {
|
||||
await page.goto(sineWaveObject.url);
|
||||
|
||||
// Bring up context menu and remove
|
||||
await page.locator('.c-tree__item.is-alias .c-tree__item__name:text("Test Sine Wave Generator")').click({ button: 'right' });
|
||||
await sineWaveGeneratorTreeItem.first().click({ button: 'right' });
|
||||
await page.locator('li[role="menuitem"]:has-text("Remove")').click();
|
||||
await page.locator('button:has-text("OK")').click();
|
||||
|
||||
|
@ -25,13 +25,13 @@ This test suite is dedicated to tests which verify the basic operations surround
|
||||
but only assume that example imagery is present.
|
||||
*/
|
||||
/* globals process */
|
||||
const { v4: uuid } = require('uuid');
|
||||
const { waitForAnimations } = require('../../../../baseFixtures');
|
||||
const { test, expect } = require('../../../../pluginFixtures');
|
||||
const { createDomainObjectWithDefaults } = require('../../../../appActions');
|
||||
const backgroundImageSelector = '.c-imagery__main-image__background-image';
|
||||
const panHotkey = process.platform === 'linux' ? ['Control', 'Alt'] : ['Alt'];
|
||||
const expectedAltText = process.platform === 'linux' ? 'Ctrl+Alt drag to pan' : 'Alt drag to pan';
|
||||
const thumbnailUrlParamsRegexp = /\?w=100&h=100/;
|
||||
|
||||
//The following block of tests verifies the basic functionality of example imagery and serves as a template for Imagery objects embedded in other objects.
|
||||
test.describe('Example Imagery Object', () => {
|
||||
@ -207,6 +207,58 @@ test.describe('Example Imagery in Display Layout', () => {
|
||||
await page.goto(displayLayout.url);
|
||||
});
|
||||
|
||||
test('View Large action pauses imagery when in realtime and returns to realtime', async ({ page }) => {
|
||||
test.info().annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/nasa/openmct/issues/3647'
|
||||
});
|
||||
|
||||
// Click time conductor mode button
|
||||
await page.locator('.c-mode-button').click();
|
||||
|
||||
// set realtime mode
|
||||
await page.locator('[data-testid="conductor-modeOption-realtime"]').click();
|
||||
|
||||
// pause/play button
|
||||
const pausePlayButton = await page.locator('.c-button.pause-play');
|
||||
|
||||
await expect.soft(pausePlayButton).not.toHaveClass(/is-paused/);
|
||||
|
||||
// Open context menu and click view large menu item
|
||||
await page.locator('button[title="View menu items"]').click();
|
||||
await page.locator('li[title="View Large"]').click();
|
||||
await expect(pausePlayButton).toHaveClass(/is-paused/);
|
||||
|
||||
await page.locator('[aria-label="Close"]').click();
|
||||
await expect.soft(pausePlayButton).not.toHaveClass(/is-paused/);
|
||||
});
|
||||
|
||||
test('View Large action leaves keeps realtime mode paused', async ({ page }) => {
|
||||
test.info().annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/nasa/openmct/issues/3647'
|
||||
});
|
||||
|
||||
// Click time conductor mode button
|
||||
await page.locator('.c-mode-button').click();
|
||||
|
||||
// set realtime mode
|
||||
await page.locator('[data-testid="conductor-modeOption-realtime"]').click();
|
||||
|
||||
// pause/play button
|
||||
const pausePlayButton = await page.locator('.c-button.pause-play');
|
||||
await pausePlayButton.click();
|
||||
await expect.soft(pausePlayButton).toHaveClass(/is-paused/);
|
||||
|
||||
// Open context menu and click view large menu item
|
||||
await page.locator('button[title="View menu items"]').click();
|
||||
await page.locator('li[title="View Large"]').click();
|
||||
await expect(pausePlayButton).toHaveClass(/is-paused/);
|
||||
|
||||
await page.locator('[aria-label="Close"]').click();
|
||||
await expect.soft(pausePlayButton).toHaveClass(/is-paused/);
|
||||
});
|
||||
|
||||
test('Imagery View operations @unstable', async ({ page }) => {
|
||||
test.info().annotations.push({
|
||||
type: 'issue',
|
||||
@ -345,13 +397,11 @@ test.describe('Example Imagery in Time Strip', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('./', { waitUntil: 'networkidle' });
|
||||
timeStripObject = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Time Strip',
|
||||
name: 'Time Strip'.concat(' ', uuid())
|
||||
type: 'Time Strip'
|
||||
});
|
||||
|
||||
await createDomainObjectWithDefaults(page, {
|
||||
type: 'Example Imagery',
|
||||
name: 'Example Imagery'.concat(' ', uuid()),
|
||||
parent: timeStripObject.uuid
|
||||
});
|
||||
// Navigate to timestrip
|
||||
@ -362,17 +412,28 @@ test.describe('Example Imagery in Time Strip', () => {
|
||||
type: 'issue',
|
||||
description: 'https://github.com/nasa/openmct/issues/5632'
|
||||
});
|
||||
|
||||
// Hover over the timestrip to reveal a thumbnail image
|
||||
await page.locator('.c-imagery-tsv-container').hover();
|
||||
// get url of the hovered image
|
||||
const hoveredImg = page.locator('.c-imagery-tsv div.c-imagery-tsv__image-wrapper:hover img');
|
||||
const hoveredImgSrc = await hoveredImg.getAttribute('src');
|
||||
expect(hoveredImgSrc).toBeTruthy();
|
||||
|
||||
// Get the img src of the hovered image thumbnail
|
||||
const hoveredThumbnailImg = page.locator('.c-imagery-tsv div.c-imagery-tsv__image-wrapper:hover img');
|
||||
const hoveredThumbnailImgSrc = await hoveredThumbnailImg.getAttribute('src');
|
||||
|
||||
// Verify that imagery timestrip view uses the thumbnailUrl as img src for thumbnails
|
||||
expect(hoveredThumbnailImgSrc).toBeTruthy();
|
||||
expect(hoveredThumbnailImgSrc).toMatch(thumbnailUrlParamsRegexp);
|
||||
|
||||
// Click on the hovered thumbnail to open "View Large" view
|
||||
await page.locator('.c-imagery-tsv-container').click();
|
||||
// get image of view large container
|
||||
|
||||
// Get the img src of the large view image
|
||||
const viewLargeImg = page.locator('img.c-imagery__main-image__image');
|
||||
const viewLargeImgSrc = await viewLargeImg.getAttribute('src');
|
||||
expect(viewLargeImgSrc).toBeTruthy();
|
||||
expect(viewLargeImgSrc).toEqual(hoveredImgSrc);
|
||||
|
||||
// Verify that the image in the large view is the same as the hovered thumbnail
|
||||
expect(viewLargeImgSrc).toEqual(hoveredThumbnailImgSrc.split('?')[0]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -389,6 +450,12 @@ test.describe('Example Imagery in Time Strip', () => {
|
||||
* @param {import('@playwright/test').Page} page
|
||||
*/
|
||||
async function performImageryViewOperationsAndAssert(page) {
|
||||
// Verify that imagery thumbnails use a thumbnail url
|
||||
const thumbnailImages = page.locator('.c-thumb__image');
|
||||
const mainImage = page.locator('.c-imagery__main-image__image');
|
||||
await expect(thumbnailImages.first()).toHaveAttribute('src', thumbnailUrlParamsRegexp);
|
||||
await expect(mainImage).not.toHaveAttribute('src', thumbnailUrlParamsRegexp);
|
||||
|
||||
// Click previous image button
|
||||
const previousImageButton = page.locator('.c-nav--prev');
|
||||
await previousImageButton.click();
|
||||
|
@ -24,11 +24,12 @@
|
||||
This test suite is dedicated to tests which verify the basic operations surrounding Notebooks.
|
||||
*/
|
||||
|
||||
// FIXME: Remove this eslint exception once tests are implemented
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { test, expect } = require('../../../../pluginFixtures');
|
||||
const { expandTreePaneItemByName, createDomainObjectWithDefaults } = require('../../../../appActions');
|
||||
const { createDomainObjectWithDefaults } = require('../../../../appActions');
|
||||
const nbUtils = require('../../../../helper/notebookUtils');
|
||||
const path = require('path');
|
||||
|
||||
const NOTEBOOK_NAME = 'Notebook';
|
||||
|
||||
test.describe('Notebook CRUD Operations', () => {
|
||||
test.fixme('Can create a Notebook Object', async ({ page }) => {
|
||||
@ -75,8 +76,7 @@ test.describe('Notebook section tests', () => {
|
||||
|
||||
// Create Notebook
|
||||
await createDomainObjectWithDefaults(page, {
|
||||
type: 'Notebook',
|
||||
name: "Test Notebook"
|
||||
type: NOTEBOOK_NAME
|
||||
});
|
||||
});
|
||||
test('Default and new sections are automatically named Unnamed Section with Unnamed Page', async ({ page }) => {
|
||||
@ -137,8 +137,7 @@ test.describe('Notebook page tests', () => {
|
||||
|
||||
// Create Notebook
|
||||
await createDomainObjectWithDefaults(page, {
|
||||
type: 'Notebook',
|
||||
name: "Test Notebook"
|
||||
type: NOTEBOOK_NAME
|
||||
});
|
||||
});
|
||||
//Test will need to be implemented after a refactor in #5713
|
||||
@ -209,24 +208,30 @@ test.describe('Notebook search tests', () => {
|
||||
});
|
||||
|
||||
test.describe('Notebook entry tests', () => {
|
||||
// Create Notebook with URL Whitelist
|
||||
let notebookObject;
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// eslint-disable-next-line no-undef
|
||||
await page.addInitScript({ path: path.join(__dirname, '../../../../helper/', 'addInitNotebookWithUrls.js') });
|
||||
await page.goto('./', { waitUntil: 'networkidle' });
|
||||
|
||||
notebookObject = await createDomainObjectWithDefaults(page, {
|
||||
type: NOTEBOOK_NAME
|
||||
});
|
||||
});
|
||||
test.fixme('When a new entry is created, it should be focused', async ({ page }) => {});
|
||||
test('When an object is dropped into a notebook, a new entry is created and it should be focused @unstable', async ({ page }) => {
|
||||
await page.goto('./#/browse/mine', { waitUntil: 'networkidle' });
|
||||
|
||||
// Create Notebook
|
||||
const notebook = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Notebook',
|
||||
name: "Embed Test Notebook"
|
||||
});
|
||||
// Create Overlay Plot
|
||||
await createDomainObjectWithDefaults(page, {
|
||||
type: 'Overlay Plot',
|
||||
name: "Dropped Overlay Plot"
|
||||
type: 'Overlay Plot'
|
||||
});
|
||||
|
||||
await expandTreePaneItemByName(page, 'My Items');
|
||||
// Navigate to the notebook object
|
||||
await page.goto(notebookObject.url);
|
||||
|
||||
// Reveal the notebook in the tree
|
||||
await page.getByTitle('Show selected item in tree').click();
|
||||
|
||||
await page.goto(notebook.url);
|
||||
await page.dragAndDrop('role=treeitem[name=/Dropped Overlay Plot/]', '.c-notebook__drag-area');
|
||||
|
||||
const embed = page.locator('.c-ne__embed__link');
|
||||
@ -236,22 +241,16 @@ test.describe('Notebook entry tests', () => {
|
||||
expect(embedName).toBe('Dropped Overlay Plot');
|
||||
});
|
||||
test('When an object is dropped into a notebooks existing entry, it should be focused @unstable', async ({ page }) => {
|
||||
await page.goto('./#/browse/mine', { waitUntil: 'networkidle' });
|
||||
|
||||
// Create Notebook
|
||||
const notebook = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Notebook',
|
||||
name: "Embed Test Notebook"
|
||||
});
|
||||
// Create Overlay Plot
|
||||
await createDomainObjectWithDefaults(page, {
|
||||
type: 'Overlay Plot',
|
||||
name: "Dropped Overlay Plot"
|
||||
type: 'Overlay Plot'
|
||||
});
|
||||
|
||||
await expandTreePaneItemByName(page, 'My Items');
|
||||
// Navigate to the notebook object
|
||||
await page.goto(notebookObject.url);
|
||||
|
||||
await page.goto(notebook.url);
|
||||
// Reveal the notebook in the tree
|
||||
await page.getByTitle('Show selected item in tree').click();
|
||||
|
||||
await nbUtils.enterTextEntry(page, 'Entry to drop into');
|
||||
await page.dragAndDrop('role=treeitem[name=/Dropped Overlay Plot/]', 'text=Entry to drop into');
|
||||
@ -265,71 +264,117 @@ test.describe('Notebook entry tests', () => {
|
||||
});
|
||||
test.fixme('new entries persist through navigation events without save', async ({ page }) => {});
|
||||
test.fixme('previous and new entries can be deleted', async ({ page }) => {});
|
||||
});
|
||||
test('when a valid link is entered into a notebook entry, it becomes clickable when viewing', async ({ page }) => {
|
||||
const TEST_LINK = 'http://www.google.com';
|
||||
|
||||
test.describe('Snapshot Menu tests', () => {
|
||||
test.fixme('When no default notebook is selected, Snapshot Menu dropdown should only have a single option', async ({ page }) => {
|
||||
// There should be no default notebook
|
||||
// Clear default notebook if exists using `localStorage.setItem('notebook-storage', null);`
|
||||
// refresh page
|
||||
// Click on 'Notebook Snaphot Menu'
|
||||
// 'save to Notebook Snapshots' should be only option there
|
||||
});
|
||||
test.fixme('When default notebook is updated selected, Snapshot Menu dropdown should list it as the newest option', async ({ page }) => {
|
||||
// Create 2a notebooks
|
||||
// Set Notebook A as Default
|
||||
// Open Snapshot Menu and note that Notebook A is listed
|
||||
// Close Snapshot Menu
|
||||
// Set Default Notebook to Notebook B
|
||||
// Open Snapshot Notebook and note that Notebook B is listed
|
||||
// Select Default Notebook Option and verify that Snapshot is added to Notebook B
|
||||
});
|
||||
test.fixme('Can add Snapshots via Snapshot Menu and details are correct', async ({ page }) => {
|
||||
//Note this should be a visual test, too
|
||||
// Create Telemetry object
|
||||
// Create A notebook with many pages and sections.
|
||||
// Set page and section defaults to be between first and last of many. i.e. 3 of 5
|
||||
// Navigate to Telemetry object
|
||||
// Select Default Notebook Option and verify that Snapshot is added to Notebook A
|
||||
// Verify Snapshot Details appear correctly
|
||||
});
|
||||
test.fixme('Snapshots adjust time conductor', async ({ page }) => {
|
||||
// Create Telemetry object
|
||||
// Set Telemetry object's timeconductor to Fixed time with Start and Endtimes are recorded
|
||||
// Embed Telemetry object into notebook
|
||||
// Set Time Conductor to Local clock
|
||||
// Click into embedded telemetry object and verify object appears with same fixed time from record
|
||||
});
|
||||
});
|
||||
// Navigate to the notebook object
|
||||
await page.goto(notebookObject.url);
|
||||
|
||||
test.describe('Snapshot Container tests', () => {
|
||||
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', async ({ page }) => {});
|
||||
test.fixme('A snapshot can be Previewed from Container', async ({ page }) => {});
|
||||
test.fixme('A snapshot Container can be open and closed', async ({ page }) => {});
|
||||
test.fixme('Can add object to Snapshot container and pull into notebook and create a new entry', async ({ page }) => {
|
||||
//Create Notebook
|
||||
//Create Telemetry Object
|
||||
//From Telemetry Object, use 'save to Notebook Snapshots'
|
||||
//Snapshots indicator should blink, click on it to view snapshots
|
||||
//Navigate to Notebook
|
||||
//Drag and Drop onto droppable area for new entry
|
||||
//New Entry created with given snapshot added
|
||||
//Snapshot removed from container?
|
||||
// Reveal the notebook in the tree
|
||||
await page.getByTitle('Show selected item in tree').click();
|
||||
|
||||
await nbUtils.enterTextEntry(page, `This should be a link: ${TEST_LINK} is it?`);
|
||||
|
||||
const validLink = page.locator(`a[href="${TEST_LINK}"]`);
|
||||
|
||||
// Start waiting for popup before clicking. Note no await.
|
||||
const popupPromise = page.waitForEvent('popup');
|
||||
|
||||
await validLink.click();
|
||||
const popup = await popupPromise;
|
||||
|
||||
// Wait for the popup to load.
|
||||
await popup.waitForLoadState();
|
||||
expect.soft(popup.url()).toContain('www.google.com');
|
||||
|
||||
expect(await validLink.count()).toBe(1);
|
||||
});
|
||||
test.fixme('Can add object to Snapshot container and pull into notebook and existing entry', async ({ page }) => {
|
||||
//Create Notebook
|
||||
//Create Telemetry Object
|
||||
//From Telemetry Object, use 'save to Notebook Snapshots'
|
||||
//Snapshots indicator should blink, click on it to view snapshots
|
||||
//Navigate to Notebook
|
||||
//Drag and Drop into exiting entry
|
||||
//Existing Entry updated with given snapshot
|
||||
//Snapshot removed from container?
|
||||
test('when an invalid link is entered into a notebook entry, it does not become clickable when viewing', async ({ page }) => {
|
||||
const TEST_LINK = 'www.google.com';
|
||||
|
||||
// Navigate to the notebook object
|
||||
await page.goto(notebookObject.url);
|
||||
|
||||
// Reveal the notebook in the tree
|
||||
await page.getByTitle('Show selected item in tree').click();
|
||||
|
||||
await nbUtils.enterTextEntry(page, `This should NOT be a link: ${TEST_LINK} is it?`);
|
||||
|
||||
const invalidLink = page.locator(`a[href="${TEST_LINK}"]`);
|
||||
|
||||
expect(await invalidLink.count()).toBe(0);
|
||||
});
|
||||
test.fixme('Verify Embedded options for PNG, JPG, and Annotate work correctly', async ({ page }) => {
|
||||
//Add snapshot to container
|
||||
//Verify PNG, JPG, and Annotate buttons work correctly
|
||||
test('when a link is entered, but it is not in the whitelisted urls, it does not become clickable when viewing', async ({ page }) => {
|
||||
const TEST_LINK = 'http://www.bing.com';
|
||||
|
||||
// Navigate to the notebook object
|
||||
await page.goto(notebookObject.url);
|
||||
|
||||
// Reveal the notebook in the tree
|
||||
await page.getByTitle('Show selected item in tree').click();
|
||||
|
||||
await nbUtils.enterTextEntry(page, `This should NOT be a link: ${TEST_LINK} is it?`);
|
||||
|
||||
const invalidLink = page.locator(`a[href="${TEST_LINK}"]`);
|
||||
|
||||
expect(await invalidLink.count()).toBe(0);
|
||||
});
|
||||
test('when a valid link with a subdomain and a valid domain in the whitelisted urls is entered into a notebook entry, it becomes clickable when viewing', async ({ page }) => {
|
||||
const INVALID_TEST_LINK = 'http://bing.google.com';
|
||||
|
||||
// Navigate to the notebook object
|
||||
await page.goto(notebookObject.url);
|
||||
|
||||
// Reveal the notebook in the tree
|
||||
await page.getByTitle('Show selected item in tree').click();
|
||||
|
||||
await nbUtils.enterTextEntry(page, `This should be a link: ${INVALID_TEST_LINK} is it?`);
|
||||
|
||||
const validLink = page.locator(`a[href="${INVALID_TEST_LINK}"]`);
|
||||
|
||||
expect(await validLink.count()).toBe(1);
|
||||
});
|
||||
test('when a valid secure link is entered into a notebook entry, it becomes clickable when viewing', async ({ page }) => {
|
||||
const TEST_LINK = 'https://www.google.com';
|
||||
|
||||
// Navigate to the notebook object
|
||||
await page.goto(notebookObject.url);
|
||||
|
||||
// Reveal the notebook in the tree
|
||||
await page.getByTitle('Show selected item in tree').click();
|
||||
|
||||
await nbUtils.enterTextEntry(page, `This should be a link: ${TEST_LINK} is it?`);
|
||||
|
||||
const validLink = page.locator(`a[href="${TEST_LINK}"]`);
|
||||
|
||||
// Start waiting for popup before clicking. Note no await.
|
||||
const popupPromise = page.waitForEvent('popup');
|
||||
|
||||
await validLink.click();
|
||||
const popup = await popupPromise;
|
||||
|
||||
// Wait for the popup to load.
|
||||
await popup.waitForLoadState();
|
||||
expect.soft(popup.url()).toContain('www.google.com');
|
||||
|
||||
expect(await validLink.count()).toBe(1);
|
||||
});
|
||||
test('when a nefarious link is entered into a notebook entry, it is sanitized when viewing', async ({ page }) => {
|
||||
const TEST_LINK = 'http://www.google.com?bad=';
|
||||
const TEST_LINK_BAD = `http://www.google.com?bad=<script>alert('gimme your cookies')</script>`;
|
||||
|
||||
// Navigate to the notebook object
|
||||
await page.goto(notebookObject.url);
|
||||
|
||||
// Reveal the notebook in the tree
|
||||
await page.getByTitle('Show selected item in tree').click();
|
||||
|
||||
await nbUtils.enterTextEntry(page, `This should be a link, BUT not a bad link: ${TEST_LINK_BAD} is it?`);
|
||||
|
||||
const sanitizedLink = page.locator(`a[href="${TEST_LINK}"]`);
|
||||
const unsanitizedLink = page.locator(`a[href="${TEST_LINK_BAD}"]`);
|
||||
|
||||
expect.soft(await sanitizedLink.count()).toBe(1);
|
||||
expect(await unsanitizedLink.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
@ -0,0 +1,134 @@
|
||||
/*****************************************************************************
|
||||
* Open MCT, Copyright (c) 2014-2022, 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.
|
||||
*/
|
||||
|
||||
const { test, expect } = require('../../../../pluginFixtures');
|
||||
// const { expandTreePaneItemByName, createDomainObjectWithDefaults } = require('../../../../appActions');
|
||||
// const nbUtils = require('../../../../helper/notebookUtils');
|
||||
|
||||
test.describe('Snapshot Menu tests', () => {
|
||||
test.fixme('When no default notebook is selected, Snapshot Menu dropdown should only have a single option', async ({ page }) => {
|
||||
// There should be no default notebook
|
||||
// Clear default notebook if exists using `localStorage.setItem('notebook-storage', null);`
|
||||
// refresh page
|
||||
// Click on 'Notebook Snaphot Menu'
|
||||
// 'save to Notebook Snapshots' should be only option there
|
||||
});
|
||||
test.fixme('When default notebook is updated selected, Snapshot Menu dropdown should list it as the newest option', async ({ page }) => {
|
||||
// Create 2a notebooks
|
||||
// Set Notebook A as Default
|
||||
// Open Snapshot Menu and note that Notebook A is listed
|
||||
// Close Snapshot Menu
|
||||
// Set Default Notebook to Notebook B
|
||||
// Open Snapshot Notebook and note that Notebook B is listed
|
||||
// Select Default Notebook Option and verify that Snapshot is added to Notebook B
|
||||
});
|
||||
test.fixme('Can add Snapshots via Snapshot Menu and details are correct', async ({ page }) => {
|
||||
//Note this should be a visual test, too
|
||||
// Create Telemetry object
|
||||
// Create A notebook with many pages and sections.
|
||||
// Set page and section defaults to be between first and last of many. i.e. 3 of 5
|
||||
// Navigate to Telemetry object
|
||||
// Select Default Notebook Option and verify that Snapshot is added to Notebook A
|
||||
// Verify Snapshot Details appear correctly
|
||||
});
|
||||
test.fixme('Snapshots adjust time conductor', async ({ page }) => {
|
||||
// Create Telemetry object
|
||||
// Set Telemetry object's timeconductor to Fixed time with Start and Endtimes are recorded
|
||||
// Embed Telemetry object into notebook
|
||||
// Set Time Conductor to Local clock
|
||||
// Click into embedded telemetry object and verify object appears with same fixed time from record
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Snapshot Container tests', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
//Navigate to baseURL
|
||||
await page.goto('./', { waitUntil: 'networkidle' });
|
||||
|
||||
// Create Notebook
|
||||
// const notebook = await createDomainObjectWithDefaults(page, {
|
||||
// type: 'Notebook',
|
||||
// name: "Test Notebook"
|
||||
// });
|
||||
// // Create Overlay Plot
|
||||
// const snapShotObject = await createDomainObjectWithDefaults(page, {
|
||||
// type: 'Overlay Plot',
|
||||
// name: "Dropped Overlay Plot"
|
||||
// });
|
||||
|
||||
await page.getByRole('button', { name: ' Snapshot ' }).click();
|
||||
await page.getByRole('menuitem', { name: ' Save to Notebook Snapshots' }).click();
|
||||
await page.getByRole('button', { name: 'Show' }).click();
|
||||
|
||||
});
|
||||
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 }) => {
|
||||
await page.locator('.c-snapshot.c-ne__embed').first().getByTitle('More options').click();
|
||||
await page.getByRole('menuitem', { name: ' View Snapshot' }).click();
|
||||
await expect(page.locator('.c-overlay__outer')).toBeVisible();
|
||||
await page.getByTitle('Annotate').click();
|
||||
await expect(page.locator('#snap-annotation-canvas')).toBeVisible();
|
||||
await page.getByRole('button', { name: '' }).click();
|
||||
// await expect(page.locator('#snap-annotation-canvas')).not.toBeVisible();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await page.getByRole('button', { name: 'Done' }).click();
|
||||
//await expect(await page.locator)
|
||||
});
|
||||
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 options').click();
|
||||
await page.getByRole('menuitem', { name: 'Quick View' }).click();
|
||||
await expect(page.locator('.c-overlay__outer')).toBeVisible();
|
||||
});
|
||||
test.fixme('A snapshot can be Navigated To from Container with 3 dot action menu', async ({ page }) => {});
|
||||
test.fixme('A snapshot can be Navigated To Item in Time from Container with 3 dot action menu', async ({ page }) => {});
|
||||
test.fixme('A snapshot Container can be open and closed', async ({ page }) => {});
|
||||
test.fixme('Can add object to Snapshot container and pull into notebook and create a new entry', async ({ page }) => {
|
||||
//Create Notebook
|
||||
//Create Telemetry Object
|
||||
//From Telemetry Object, use 'save to Notebook Snapshots'
|
||||
//Snapshots indicator should blink, click on it to view snapshots
|
||||
//Navigate to Notebook
|
||||
//Drag and Drop onto droppable area for new entry
|
||||
//New Entry created with given snapshot added
|
||||
//Snapshot removed from container?
|
||||
});
|
||||
test.fixme('Can add object to Snapshot container and pull into notebook and existing entry', async ({ page }) => {
|
||||
//Create Notebook
|
||||
//Create Telemetry Object
|
||||
//From Telemetry Object, use 'save to Notebook Snapshots'
|
||||
//Snapshots indicator should blink, click on it to view snapshots
|
||||
//Navigate to Notebook
|
||||
//Drag and Drop into exiting entry
|
||||
//Existing Entry updated with given snapshot
|
||||
//Snapshot removed from container?
|
||||
});
|
||||
test.fixme('Verify Embedded options for PNG, JPG, and Annotate work correctly', async ({ page }) => {
|
||||
//Add snapshot to container
|
||||
//Verify PNG, JPG, and Annotate buttons work correctly
|
||||
});
|
||||
});
|
@ -41,6 +41,7 @@ test.describe('Notebook Tests with CouchDB @couchdb', () => {
|
||||
});
|
||||
|
||||
test('Inspect Notebook Entry Network Requests', async ({ page }) => {
|
||||
await page.getByText('Annotations').click();
|
||||
// Expand sidebar
|
||||
await page.locator('.c-notebook__toggle-nav-button').click();
|
||||
|
||||
@ -76,6 +77,7 @@ test.describe('Notebook Tests with CouchDB @couchdb', () => {
|
||||
await page.locator('text=To start a new entry, click here or drag and drop any object').click();
|
||||
await page.locator('[aria-label="Notebook Entry Input"]').click();
|
||||
await page.locator('[aria-label="Notebook Entry Input"]').fill(`First Entry`);
|
||||
await page.locator('[aria-label="Notebook Entry Input"]').press('Enter');
|
||||
await page.waitForLoadState('networkidle');
|
||||
expect(addingNotebookElementsRequests.length).toBeLessThanOrEqual(2);
|
||||
|
||||
@ -148,30 +150,33 @@ test.describe('Notebook Tests with CouchDB @couchdb', () => {
|
||||
await page.locator('text=To start a new entry, click here or drag and drop any object').click();
|
||||
await page.locator('[aria-label="Notebook Entry Input"]').click();
|
||||
await page.locator('[aria-label="Notebook Entry Input"]').fill(`First Entry`);
|
||||
await page.locator('[aria-label="Notebook Entry Input"]').press('Enter');
|
||||
|
||||
await page.locator('text=To start a new entry, click here or drag and drop any object').click();
|
||||
await page.locator('[aria-label="Notebook Entry Input"] >> nth=1').click();
|
||||
await page.locator('[aria-label="Notebook Entry Input"] >> nth=1').fill(`Second Entry`);
|
||||
await page.locator('[aria-label="Notebook Entry Input"] >> nth=1').press('Enter');
|
||||
|
||||
await page.locator('text=To start a new entry, click here or drag and drop any object').click();
|
||||
await page.locator('[aria-label="Notebook Entry Input"] >> nth=2').click();
|
||||
await page.locator('[aria-label="Notebook Entry Input"] >> nth=2').fill(`Third Entry`);
|
||||
await page.locator('[aria-label="Notebook Entry Input"] >> nth=2').press('Enter');
|
||||
|
||||
// Add three tags
|
||||
await page.hover(`button:has-text("Add Tag") >> nth=2`);
|
||||
await page.locator(`button:has-text("Add Tag") >> nth=2`).click();
|
||||
await page.hover(`button:has-text("Add Tag")`);
|
||||
await page.locator(`button:has-text("Add Tag")`).click();
|
||||
await page.locator('[placeholder="Type to select tag"]').click();
|
||||
await page.locator('[aria-label="Autocomplete Options"] >> text=Science').click();
|
||||
await page.waitForSelector('[aria-label="Tag"]:has-text("Science")');
|
||||
|
||||
await page.hover(`button:has-text("Add Tag") >> nth=2`);
|
||||
await page.locator(`button:has-text("Add Tag") >> nth=2`).click();
|
||||
await page.hover(`button:has-text("Add Tag")`);
|
||||
await page.locator(`button:has-text("Add Tag")`).click();
|
||||
await page.locator('[placeholder="Type to select tag"]').click();
|
||||
await page.locator('[aria-label="Autocomplete Options"] >> text=Drilling').click();
|
||||
await page.waitForSelector('[aria-label="Tag"]:has-text("Drilling")');
|
||||
|
||||
await page.hover(`button:has-text("Add Tag") >> nth=2`);
|
||||
await page.locator(`button:has-text("Add Tag") >> nth=2`).click();
|
||||
await page.hover(`button:has-text("Add Tag")`);
|
||||
await page.locator(`button:has-text("Add Tag")`).click();
|
||||
await page.locator('[placeholder="Type to select tag"]').click();
|
||||
await page.locator('[aria-label="Autocomplete Options"] >> text=Driving').click();
|
||||
await page.waitForSelector('[aria-label="Tag"]:has-text("Driving")');
|
||||
@ -227,6 +232,7 @@ test.describe('Notebook Tests with CouchDB @couchdb', () => {
|
||||
type: 'issue',
|
||||
description: 'https://github.com/akhenry/openmct-yamcs/issues/69'
|
||||
});
|
||||
await page.getByText('Annotations').click();
|
||||
await page.locator('text=To start a new entry, click here or drag and drop any object').click();
|
||||
await page.locator('[aria-label="Notebook Entry Input"]').click();
|
||||
await page.locator('[aria-label="Notebook Entry Input"]').fill(`First Entry`);
|
||||
|
@ -152,7 +152,7 @@ test.describe('Restricted Notebook with a page locked and with an embed @addInit
|
||||
|
||||
test('Allows embeds to be deleted if page unlocked @addInit', async ({ page }) => {
|
||||
// Click .c-ne__embed__name .c-popup-menu-button
|
||||
await page.locator('.c-ne__embed__name .c-popup-menu-button').click(); // embed popup menu
|
||||
await page.locator('.c-ne__embed__name .c-icon-button').click(); // embed popup menu
|
||||
|
||||
const embedMenu = page.locator('body >> .c-menu');
|
||||
await expect(embedMenu).toContainText('Remove This Embed');
|
||||
@ -161,7 +161,7 @@ test.describe('Restricted Notebook with a page locked and with an embed @addInit
|
||||
test('Disallows embeds to be deleted if page locked @addInit', async ({ page }) => {
|
||||
await lockPage(page);
|
||||
// Click .c-ne__embed__name .c-popup-menu-button
|
||||
await page.locator('.c-ne__embed__name .c-popup-menu-button').click(); // embed popup menu
|
||||
await page.locator('.c-ne__embed__name .c-icon-button').click(); // embed popup menu
|
||||
|
||||
const embedMenu = page.locator('body >> .c-menu');
|
||||
await expect(embedMenu).not.toContainText('Remove This Embed');
|
||||
|
@ -57,12 +57,14 @@ async function createNotebookAndEntry(page, iterations = 1) {
|
||||
*/
|
||||
async function createNotebookEntryAndTags(page, iterations = 1) {
|
||||
const notebook = await createNotebookAndEntry(page, iterations);
|
||||
await page.locator('text=Annotations').click();
|
||||
|
||||
for (let iteration = 0; iteration < iterations; iteration++) {
|
||||
// Hover and click "Add Tag" button
|
||||
// Hover is needed here to "slow down" the actions while running in headless mode
|
||||
await page.hover(`button:has-text("Add Tag") >> nth = ${iteration}`);
|
||||
await page.locator(`button:has-text("Add Tag") >> nth = ${iteration}`).click();
|
||||
await page.locator(`[aria-label="Notebook Entry"] >> nth = ${iteration}`).click();
|
||||
await page.hover(`button:has-text("Add Tag")`);
|
||||
await page.locator(`button:has-text("Add Tag")`).click();
|
||||
|
||||
// Click inside the tag search input
|
||||
await page.locator('[placeholder="Type to select tag"]').click();
|
||||
@ -71,8 +73,8 @@ async function createNotebookEntryAndTags(page, iterations = 1) {
|
||||
|
||||
// Hover and click "Add Tag" button
|
||||
// Hover is needed here to "slow down" the actions while running in headless mode
|
||||
await page.hover(`button:has-text("Add Tag") >> nth = ${iteration}`);
|
||||
await page.locator(`button:has-text("Add Tag") >> nth = ${iteration}`).click();
|
||||
await page.hover(`button:has-text("Add Tag")`);
|
||||
await page.locator(`button:has-text("Add Tag")`).click();
|
||||
// Click inside the tag search input
|
||||
await page.locator('[placeholder="Type to select tag"]').click();
|
||||
// Select the "Science" tag
|
||||
@ -84,8 +86,10 @@ async function createNotebookEntryAndTags(page, iterations = 1) {
|
||||
|
||||
test.describe('Tagging in Notebooks @addInit', () => {
|
||||
test('Can load tags', async ({ page }) => {
|
||||
|
||||
await createNotebookAndEntry(page);
|
||||
|
||||
await page.locator('text=Annotations').click();
|
||||
|
||||
await page.locator('button:has-text("Add Tag")').click();
|
||||
|
||||
await page.locator('[placeholder="Type to select tag"]').click();
|
||||
@ -126,13 +130,12 @@ test.describe('Tagging in Notebooks @addInit', () => {
|
||||
|
||||
test('Can delete tags', async ({ page }) => {
|
||||
await createNotebookEntryAndTags(page);
|
||||
await page.locator('[aria-label="Notebook Entries"]').click();
|
||||
// Delete Driving
|
||||
await page.hover('[aria-label="Tag"]:has-text("Driving")');
|
||||
await page.locator('[aria-label="Tag"]:has-text("Driving") ~ .c-completed-tag-deletion').click();
|
||||
|
||||
await expect(page.locator('[aria-label="Notebook Entry"]')).toContainText("Science");
|
||||
await expect(page.locator('[aria-label="Notebook Entry"]')).not.toContainText("Driving");
|
||||
await expect(page.locator('[aria-label="Tags Inspector"]')).toContainText("Science");
|
||||
await expect(page.locator('[aria-label="Tags Inspector"]')).not.toContainText("Driving");
|
||||
|
||||
await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('sc');
|
||||
await expect(page.locator('[aria-label="Search Result"]')).not.toContainText("Driving");
|
||||
@ -194,11 +197,18 @@ test.describe('Tagging in Notebooks @addInit', () => {
|
||||
page.goto('./#/browse/mine?hideTree=false'),
|
||||
page.click('.c-disclosure-triangle')
|
||||
]);
|
||||
// Click Clock
|
||||
await page.click(`text=${clock.name}`);
|
||||
|
||||
const treePane = page.getByRole('tree', {
|
||||
name: 'Main Tree'
|
||||
});
|
||||
// Click Clock
|
||||
await treePane.getByRole('treeitem', {
|
||||
name: clock.name
|
||||
}).click();
|
||||
// Click Notebook
|
||||
await page.click(`text=${notebook.name}`);
|
||||
await page.getByRole('treeitem', {
|
||||
name: notebook.name
|
||||
}).click();
|
||||
|
||||
for (let iteration = 0; iteration < ITERATIONS; iteration++) {
|
||||
const entryLocator = `[aria-label="Notebook Entry"] >> nth = ${iteration}`;
|
||||
|
@ -0,0 +1,156 @@
|
||||
/*****************************************************************************
|
||||
* Open MCT, Copyright (c) 2014-2023, United States Government
|
||||
* as represented by the Administrator of the National Aeronautics and Space
|
||||
* Administration. All rights reserved.
|
||||
*
|
||||
* Open MCT is licensed under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0.
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*
|
||||
* Open MCT includes source code licensed under additional open source
|
||||
* licenses. See the Open Source Licenses file (LICENSES.md) included with
|
||||
* this source code distribution or the Licensing information page available
|
||||
* at runtime from the About dialog for additional information.
|
||||
*****************************************************************************/
|
||||
|
||||
/*
|
||||
* This test suite is dedicated to testing the operator status plugin.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const { test, expect } = require('../../../../pluginFixtures');
|
||||
|
||||
/*
|
||||
|
||||
Precondition: Inject Example User, Operator Status Plugins
|
||||
Verify that user 1 sees updates from user/role 2 (Not possible without openmct-yamcs implementation)
|
||||
|
||||
Clear Role Status of single user test
|
||||
STUB (test.fixme) Rolling through each
|
||||
|
||||
*/
|
||||
|
||||
test.describe('Operator Status', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// FIXME: determine if plugins will be added to index.html or need to be injected
|
||||
// eslint-disable-next-line no-undef
|
||||
await page.addInitScript({ path: path.join(__dirname, '../../../../helper/', 'addInitExampleUser.js')});
|
||||
// eslint-disable-next-line no-undef
|
||||
await page.addInitScript({ path: path.join(__dirname, '../../../../helper/', 'addInitOperatorStatus.js')});
|
||||
await page.goto('./', { waitUntil: 'networkidle' });
|
||||
});
|
||||
|
||||
// verify that operator status is visible
|
||||
test('operator status is visible and expands when clicked', async ({ page }) => {
|
||||
await expect(page.locator('div[title="Set my operator status"]')).toBeVisible();
|
||||
await page.locator('div[title="Set my operator status"]').click();
|
||||
|
||||
// expect default status to be 'GO'
|
||||
await expect(page.locator('.c-status-poll-panel')).toBeVisible();
|
||||
});
|
||||
|
||||
test('poll question indicator remains when blank poll set', async ({ page }) => {
|
||||
await expect(page.locator('div[title="Set the current poll question"]')).toBeVisible();
|
||||
await page.locator('div[title="Set the current poll question"]').click();
|
||||
// set to blank
|
||||
await page.getByRole('button', { name: 'Update' }).click();
|
||||
|
||||
// should still be visible
|
||||
await expect(page.locator('div[title="Set the current poll question"]')).toBeVisible();
|
||||
});
|
||||
|
||||
// Verify that user 1 sees updates from user/role 2 (Not possible without openmct-yamcs implementation)
|
||||
test('operator status table reflects answered values', async ({ page }) => {
|
||||
// user navigates to operator status poll
|
||||
const statusPollIndicator = page.locator('div[title="Set my operator status"]');
|
||||
await statusPollIndicator.click();
|
||||
|
||||
// get user role value
|
||||
const userRole = page.locator('.c-status-poll-panel__user-role');
|
||||
const userRoleText = await userRole.innerText();
|
||||
|
||||
// get selected status value
|
||||
const selectStatus = page.locator('select[name="setStatus"]');
|
||||
await selectStatus.selectOption({ index: 1});
|
||||
const initialStatusValue = await selectStatus.inputValue();
|
||||
|
||||
// open manage status poll
|
||||
const manageStatusPollIndicator = page.locator('div[title="Set the current poll question"]');
|
||||
await manageStatusPollIndicator.click();
|
||||
// parse the table row values
|
||||
const row = page.locator(`tr:has-text("${userRoleText}")`);
|
||||
const rowValues = await row.innerText();
|
||||
const rowValuesArr = rowValues.split('\t');
|
||||
const COLUMN_STATUS_INDEX = 1;
|
||||
// check initial set value matches status table
|
||||
expect(rowValuesArr[COLUMN_STATUS_INDEX].toLowerCase())
|
||||
.toEqual(initialStatusValue.toLowerCase());
|
||||
|
||||
// change user status
|
||||
await statusPollIndicator.click();
|
||||
// FIXME: might want to grab a dynamic option instead of arbitrary
|
||||
await page.locator('select[name="setStatus"]').selectOption({ index: 2});
|
||||
const updatedStatusValue = await selectStatus.inputValue();
|
||||
// verify user status is reflected in table
|
||||
await manageStatusPollIndicator.click();
|
||||
|
||||
const updatedRow = page.locator(`tr:has-text("${userRoleText}")`);
|
||||
const updatedRowValues = await updatedRow.innerText();
|
||||
const updatedRowValuesArr = updatedRowValues.split('\t');
|
||||
|
||||
expect(updatedRowValuesArr[COLUMN_STATUS_INDEX].toLowerCase())
|
||||
.toEqual(updatedStatusValue.toLowerCase());
|
||||
|
||||
});
|
||||
|
||||
test('clear poll button removes poll responses', async ({ page }) => {
|
||||
// user navigates to operator status poll
|
||||
const statusPollIndicator = page.locator('div[title="Set my operator status"]');
|
||||
await statusPollIndicator.click();
|
||||
|
||||
// get user role value
|
||||
const userRole = page.locator('.c-status-poll-panel__user-role');
|
||||
const userRoleText = await userRole.innerText();
|
||||
|
||||
// get selected status value
|
||||
const selectStatus = page.locator('select[name="setStatus"]');
|
||||
// FIXME: might want to grab a dynamic option instead of arbitrary
|
||||
await selectStatus.selectOption({ index: 1});
|
||||
const initialStatusValue = await selectStatus.inputValue();
|
||||
|
||||
// open manage status poll
|
||||
const manageStatusPollIndicator = page.locator('div[title="Set the current poll question"]');
|
||||
await manageStatusPollIndicator.click();
|
||||
// parse the table row values
|
||||
const row = page.locator(`tr:has-text("${userRoleText}")`);
|
||||
const rowValues = await row.innerText();
|
||||
const rowValuesArr = rowValues.split('\t');
|
||||
const COLUMN_STATUS_INDEX = 1;
|
||||
// check initial set value matches status table
|
||||
expect(rowValuesArr[COLUMN_STATUS_INDEX].toLowerCase())
|
||||
.toEqual(initialStatusValue.toLowerCase());
|
||||
|
||||
// clear the poll
|
||||
await page.locator('button[title="Clear the previous poll question"]').click();
|
||||
|
||||
const updatedRow = page.locator(`tr:has-text("${userRoleText}")`);
|
||||
const updatedRowValues = await updatedRow.innerText();
|
||||
const updatedRowValuesArr = updatedRowValues.split('\t');
|
||||
const UNSET_VALUE_LABEL = 'Not set';
|
||||
expect(updatedRowValuesArr[COLUMN_STATUS_INDEX])
|
||||
.toEqual(UNSET_VALUE_LABEL);
|
||||
|
||||
});
|
||||
|
||||
test.fixme('iterate through all possible response values', async ({ page }) => {
|
||||
// test all possible respone values for the poll
|
||||
});
|
||||
|
||||
});
|
@ -32,7 +32,7 @@ test.use({
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('ExportAsJSON', () => {
|
||||
test.describe('Autoscale', () => {
|
||||
test('User can set autoscale with a valid range @snapshot', async ({ page, openmctConfig }) => {
|
||||
const { myItemsFolderName } = openmctConfig;
|
||||
|
||||
@ -47,16 +47,32 @@ test.describe('ExportAsJSON', () => {
|
||||
|
||||
await testYTicks(page, ['-1.00', '-0.50', '0.00', '0.50', '1.00']);
|
||||
|
||||
// enter edit mode
|
||||
await page.click('button[title="Edit"]');
|
||||
|
||||
await turnOffAutoscale(page);
|
||||
|
||||
// Make sure that after turning off autoscale, the user selected range values start at the same values the plot had prior.
|
||||
await testYTicks(page, ['-1.00', '-0.50', '0.00', '0.50', '1.00']);
|
||||
await setUserDefinedMinAndMax(page, '-2', '2');
|
||||
|
||||
// save
|
||||
await page.click('button[title="Save"]');
|
||||
await Promise.all([
|
||||
page.locator('li[title = "Save and Finish Editing"]').click(),
|
||||
//Wait for Save Banner to appear
|
||||
page.waitForSelector('.c-message-banner__message')
|
||||
]);
|
||||
//Wait until Save Banner is gone
|
||||
await page.locator('.c-message-banner__close-button').click();
|
||||
await page.waitForSelector('.c-message-banner__message', { state: 'detached'});
|
||||
|
||||
// Make sure that after turning off autoscale, the user entered range values are reflexted in the ticks.
|
||||
await testYTicks(page, ['-2.00', '-1.50', '-1.00', '-0.50', '0.00', '0.50', '1.00', '1.50', '2.00']);
|
||||
|
||||
const canvas = page.locator('canvas').nth(1);
|
||||
|
||||
await canvas.hover({trial: true});
|
||||
|
||||
expect(await canvas.screenshot()).toMatchSnapshot('autoscale-canvas-prepan.png', { animations: 'disabled' });
|
||||
expect.soft(await canvas.screenshot()).toMatchSnapshot('autoscale-canvas-prepan.png', { animations: 'disabled' });
|
||||
|
||||
//Alt Drag Start
|
||||
await page.keyboard.down('Alt');
|
||||
@ -76,11 +92,12 @@ test.describe('ExportAsJSON', () => {
|
||||
await page.keyboard.up('Alt');
|
||||
|
||||
// Ensure the drag worked.
|
||||
await testYTicks(page, ['0.00', '0.50', '1.00', '1.50', '2.00']);
|
||||
await testYTicks(page, ['0.00', '0.50', '1.00', '1.50', '2.00', '2.50', '3.00', '3.50']);
|
||||
|
||||
//Wait for canvas to stablize.
|
||||
await canvas.hover({trial: true});
|
||||
|
||||
expect(await canvas.screenshot()).toMatchSnapshot('autoscale-canvas-panned.png', { animations: 'disabled' });
|
||||
expect.soft(await canvas.screenshot()).toMatchSnapshot('autoscale-canvas-panned.png', { animations: 'disabled' });
|
||||
});
|
||||
});
|
||||
|
||||
@ -152,22 +169,25 @@ async function createSinewaveOverlayPlot(page, myItemsFolderName) {
|
||||
* @param {import('@playwright/test').Page} page
|
||||
*/
|
||||
async function turnOffAutoscale(page) {
|
||||
// enter edit mode
|
||||
await page.locator('text=Unnamed Overlay Plot Snapshot >> button').nth(3).click();
|
||||
|
||||
// uncheck autoscale
|
||||
await page.locator('text=Y Axis Label Log mode Auto scale Padding >> input[type="checkbox"] >> nth=1').uncheck();
|
||||
await page.getByRole('listitem').filter({ hasText: 'Auto scale' }).getByRole('checkbox').uncheck();
|
||||
}
|
||||
|
||||
// save
|
||||
await page.locator('text=Snapshot Save and Finish Editing Save and Continue Editing >> button').nth(1).click();
|
||||
await Promise.all([
|
||||
page.locator('text=Save and Finish Editing').click(),
|
||||
//Wait for Save Banner to appear
|
||||
page.waitForSelector('.c-message-banner__message')
|
||||
]);
|
||||
//Wait until Save Banner is gone
|
||||
await page.locator('.c-message-banner__close-button').click();
|
||||
await page.waitForSelector('.c-message-banner__message', { state: 'detached'});
|
||||
/**
|
||||
* @param {import('@playwright/test').Page} page
|
||||
* @param {string} min
|
||||
* @param {string} max
|
||||
*/
|
||||
async function setUserDefinedMinAndMax(page, min, max) {
|
||||
// set minimum value
|
||||
const minRangeInput = page.getByRole('listitem').filter({ hasText: 'Minimum Value' }).locator('input[type="number"]');
|
||||
await minRangeInput.click();
|
||||
await minRangeInput.fill(min);
|
||||
|
||||
// set maximum value
|
||||
const maxRangeInput = page.getByRole('listitem').filter({ hasText: 'Maximum Value' }).locator('input[type="number"]');
|
||||
await maxRangeInput.click();
|
||||
await maxRangeInput.fill(max);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -179,7 +199,7 @@ async function testYTicks(page, values) {
|
||||
let promises = [yTicks.count().then(c => expect(c).toBe(values.length))];
|
||||
|
||||
for (let i = 0, l = values.length; i < l; i += 1) {
|
||||
promises.push(expect(yTicks.nth(i)).toHaveText(values[i])); // eslint-disable-line
|
||||
promises.push(expect.soft(yTicks.nth(i)).toHaveText(values[i])); // eslint-disable-line
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
After Width: | Height: | Size: 19 KiB |
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 15 KiB |
After Width: | Height: | Size: 19 KiB |
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 16 KiB |
@ -160,35 +160,16 @@ async function testRegularTicks(page) {
|
||||
*/
|
||||
async function testLogTicks(page) {
|
||||
const yTicks = await page.locator('.gl-plot-y-tick-label');
|
||||
expect(await yTicks.count()).toBe(28);
|
||||
expect(await yTicks.count()).toBe(9);
|
||||
await expect(yTicks.nth(0)).toHaveText('-2.98');
|
||||
await expect(yTicks.nth(1)).toHaveText('-2.50');
|
||||
await expect(yTicks.nth(2)).toHaveText('-2.00');
|
||||
await expect(yTicks.nth(3)).toHaveText('-1.51');
|
||||
await expect(yTicks.nth(4)).toHaveText('-1.20');
|
||||
await expect(yTicks.nth(5)).toHaveText('-1.00');
|
||||
await expect(yTicks.nth(6)).toHaveText('-0.80');
|
||||
await expect(yTicks.nth(7)).toHaveText('-0.58');
|
||||
await expect(yTicks.nth(8)).toHaveText('-0.40');
|
||||
await expect(yTicks.nth(9)).toHaveText('-0.20');
|
||||
await expect(yTicks.nth(10)).toHaveText('-0.00');
|
||||
await expect(yTicks.nth(11)).toHaveText('0.20');
|
||||
await expect(yTicks.nth(12)).toHaveText('0.40');
|
||||
await expect(yTicks.nth(13)).toHaveText('0.58');
|
||||
await expect(yTicks.nth(14)).toHaveText('0.80');
|
||||
await expect(yTicks.nth(15)).toHaveText('1.00');
|
||||
await expect(yTicks.nth(16)).toHaveText('1.20');
|
||||
await expect(yTicks.nth(17)).toHaveText('1.51');
|
||||
await expect(yTicks.nth(18)).toHaveText('2.00');
|
||||
await expect(yTicks.nth(19)).toHaveText('2.50');
|
||||
await expect(yTicks.nth(20)).toHaveText('2.98');
|
||||
await expect(yTicks.nth(21)).toHaveText('3.50');
|
||||
await expect(yTicks.nth(22)).toHaveText('4.00');
|
||||
await expect(yTicks.nth(23)).toHaveText('4.50');
|
||||
await expect(yTicks.nth(24)).toHaveText('5.31');
|
||||
await expect(yTicks.nth(25)).toHaveText('7.00');
|
||||
await expect(yTicks.nth(26)).toHaveText('8.00');
|
||||
await expect(yTicks.nth(27)).toHaveText('9.00');
|
||||
await expect(yTicks.nth(1)).toHaveText('-1.51');
|
||||
await expect(yTicks.nth(2)).toHaveText('-0.58');
|
||||
await expect(yTicks.nth(3)).toHaveText('-0.00');
|
||||
await expect(yTicks.nth(4)).toHaveText('0.58');
|
||||
await expect(yTicks.nth(5)).toHaveText('1.51');
|
||||
await expect(yTicks.nth(6)).toHaveText('2.98');
|
||||
await expect(yTicks.nth(7)).toHaveText('5.31');
|
||||
await expect(yTicks.nth(8)).toHaveText('9.00');
|
||||
}
|
||||
|
||||
/**
|
||||
@ -205,7 +186,8 @@ async function enableEditMode(page) {
|
||||
*/
|
||||
async function enableLogMode(page) {
|
||||
// turn on log mode
|
||||
await page.locator('text=Y Axis Label Log mode Auto scale Padding >> input[type="checkbox"]').first().check();
|
||||
await page.getByRole('listitem').filter({ hasText: 'Log mode' }).getByRole('checkbox').check();
|
||||
// await page.locator('text=Y Axis Label Log mode Auto scale Padding >> input[type="checkbox"]').first().check();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -213,7 +195,7 @@ async function enableLogMode(page) {
|
||||
*/
|
||||
async function disableLogMode(page) {
|
||||
// turn off log mode
|
||||
await page.locator('text=Y Axis Label Log mode Auto scale Padding >> input[type="checkbox"]').first().uncheck();
|
||||
await page.getByRole('listitem').filter({ hasText: 'Log mode' }).getByRole('checkbox').uncheck();
|
||||
}
|
||||
|
||||
/**
|
||||
|
200
e2e/tests/functional/plugins/plot/overlayPlot.e2e.spec.js
Normal file
@ -0,0 +1,200 @@
|
||||
/*****************************************************************************
|
||||
* Open MCT, Copyright (c) 2014-2022, 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.
|
||||
*****************************************************************************/
|
||||
|
||||
/*
|
||||
Tests to verify log plot functionality. Note this test suite if very much under active development and should not
|
||||
necessarily be used for reference when writing new tests in this area.
|
||||
*/
|
||||
|
||||
const { test, expect } = require('../../../../pluginFixtures');
|
||||
const { createDomainObjectWithDefaults } = require('../../../../appActions');
|
||||
|
||||
test.describe('Overlay Plot', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('./', { waitUntil: 'networkidle' });
|
||||
});
|
||||
|
||||
test('Plot legend color is in sync with plot series color', async ({ page }) => {
|
||||
const overlayPlot = await createDomainObjectWithDefaults(page, {
|
||||
type: "Overlay Plot"
|
||||
});
|
||||
|
||||
await createDomainObjectWithDefaults(page, {
|
||||
type: "Sine Wave Generator",
|
||||
parent: overlayPlot.uuid
|
||||
});
|
||||
|
||||
await page.goto(overlayPlot.url);
|
||||
|
||||
// navigate to plot series color palette
|
||||
await page.click('.l-browse-bar__actions__edit');
|
||||
await page.locator('li.c-tree__item.menus-to-left .c-disclosure-triangle').click();
|
||||
await page.locator('.c-click-swatch--menu').click();
|
||||
await page.locator('.c-palette__item[style="background: rgb(255, 166, 61);"]').click();
|
||||
|
||||
// gets color for swatch located in legend
|
||||
const element = await page.waitForSelector('.plot-series-color-swatch');
|
||||
const color = await element.evaluate((el) => {
|
||||
return window.getComputedStyle(el).getPropertyValue('background-color');
|
||||
});
|
||||
|
||||
expect(color).toBe('rgb(255, 166, 61)');
|
||||
});
|
||||
|
||||
test('The elements pool supports dragging series into multiple y-axis buckets', async ({ page }) => {
|
||||
const overlayPlot = await createDomainObjectWithDefaults(page, {
|
||||
type: "Overlay Plot"
|
||||
});
|
||||
|
||||
const swgA = await createDomainObjectWithDefaults(page, {
|
||||
type: "Sine Wave Generator",
|
||||
parent: overlayPlot.uuid
|
||||
});
|
||||
const swgB = await createDomainObjectWithDefaults(page, {
|
||||
type: "Sine Wave Generator",
|
||||
parent: overlayPlot.uuid
|
||||
});
|
||||
const swgC = await createDomainObjectWithDefaults(page, {
|
||||
type: "Sine Wave Generator",
|
||||
parent: overlayPlot.uuid
|
||||
});
|
||||
const swgD = await createDomainObjectWithDefaults(page, {
|
||||
type: "Sine Wave Generator",
|
||||
parent: overlayPlot.uuid
|
||||
});
|
||||
const swgE = await createDomainObjectWithDefaults(page, {
|
||||
type: "Sine Wave Generator",
|
||||
parent: overlayPlot.uuid
|
||||
});
|
||||
|
||||
await page.goto(overlayPlot.url);
|
||||
await page.click('button[title="Edit"]');
|
||||
|
||||
// Expand the elements pool vertically
|
||||
await page.locator('.l-pane.l-pane--vertical-handle-before', {
|
||||
hasText: 'Elements'
|
||||
}).locator('.l-pane__handle').hover();
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(0, 100);
|
||||
await page.mouse.up();
|
||||
|
||||
const yAxis1PropertyGroup = page.locator('[aria-label="Y Axis Properties"]');
|
||||
const yAxis2PropertyGroup = page.locator('[aria-label="Y Axis 2 Properties"]');
|
||||
const yAxis3PropertyGroup = page.locator('[aria-label="Y Axis 3 Properties"]');
|
||||
|
||||
// Assert that Y Axis 1 property group is visible only
|
||||
await expect(yAxis1PropertyGroup).toBeVisible();
|
||||
await expect(yAxis2PropertyGroup).toBeHidden();
|
||||
await expect(yAxis3PropertyGroup).toBeHidden();
|
||||
|
||||
// Drag swg a, c, e into Y Axis 2
|
||||
await page.locator(`#inspector-elements-tree >> text=${swgA.name}`).dragTo(page.locator('[aria-label="Element Item Group Y Axis 2"]'));
|
||||
await page.locator(`#inspector-elements-tree >> text=${swgC.name}`).dragTo(page.locator('[aria-label="Element Item Group Y Axis 2"]'));
|
||||
await page.locator(`#inspector-elements-tree >> text=${swgE.name}`).dragTo(page.locator('[aria-label="Element Item Group Y Axis 2"]'));
|
||||
|
||||
// Assert that Y Axis 1 and Y Axis 2 property groups are visible only
|
||||
await expect(yAxis1PropertyGroup).toBeVisible();
|
||||
await expect(yAxis2PropertyGroup).toBeVisible();
|
||||
await expect(yAxis3PropertyGroup).toBeHidden();
|
||||
|
||||
const yAxis1Group = page.getByLabel("Y Axis 1");
|
||||
const yAxis2Group = page.getByLabel("Y Axis 2");
|
||||
const yAxis3Group = page.getByLabel("Y Axis 3");
|
||||
|
||||
// Drag swg b into Y Axis 3
|
||||
await page.locator(`#inspector-elements-tree >> text=${swgB.name}`).dragTo(page.locator('[aria-label="Element Item Group Y Axis 3"]'));
|
||||
|
||||
// Assert that all Y Axis property groups are visible
|
||||
await expect(yAxis1PropertyGroup).toBeVisible();
|
||||
await expect(yAxis2PropertyGroup).toBeVisible();
|
||||
await expect(yAxis3PropertyGroup).toBeVisible();
|
||||
|
||||
// Verify that the elements are in the correct buckets and in the correct order
|
||||
expect(yAxis1Group.getByRole('listitem', { name: swgD.name })).toBeTruthy();
|
||||
expect(yAxis1Group.getByRole('listitem').nth(0).getByText(swgD.name)).toBeTruthy();
|
||||
expect(yAxis2Group.getByRole('listitem', { name: swgE.name })).toBeTruthy();
|
||||
expect(yAxis2Group.getByRole('listitem').nth(0).getByText(swgE.name)).toBeTruthy();
|
||||
expect(yAxis2Group.getByRole('listitem', { name: swgC.name })).toBeTruthy();
|
||||
expect(yAxis2Group.getByRole('listitem').nth(1).getByText(swgC.name)).toBeTruthy();
|
||||
expect(yAxis2Group.getByRole('listitem', { name: swgA.name })).toBeTruthy();
|
||||
expect(yAxis2Group.getByRole('listitem').nth(2).getByText(swgA.name)).toBeTruthy();
|
||||
expect(yAxis3Group.getByRole('listitem', { name: swgB.name })).toBeTruthy();
|
||||
expect(yAxis3Group.getByRole('listitem').nth(0).getByText(swgB.name)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Clicking on an item in the elements pool brings up the plot preview with data points', async ({ page }) => {
|
||||
const overlayPlot = await createDomainObjectWithDefaults(page, {
|
||||
type: "Overlay Plot"
|
||||
});
|
||||
|
||||
const swgA = await createDomainObjectWithDefaults(page, {
|
||||
type: "Sine Wave Generator",
|
||||
parent: overlayPlot.uuid
|
||||
});
|
||||
|
||||
await page.goto(overlayPlot.url);
|
||||
await page.click('button[title="Edit"]');
|
||||
|
||||
await page.locator(`#inspector-elements-tree >> text=${swgA.name}`).click();
|
||||
await page.locator('.js-overlay canvas').nth(1);
|
||||
const plotPixelSize = await getCanvasPixelsWithData(page);
|
||||
expect(plotPixelSize).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {import('@playwright/test').Page} page
|
||||
*/
|
||||
async function getCanvasPixelsWithData(page) {
|
||||
const getTelemValuePromise = new Promise(resolve => page.exposeFunction('getCanvasValue', resolve));
|
||||
|
||||
await page.evaluate(() => {
|
||||
// The document canvas is where the plot points and lines are drawn.
|
||||
// The only way to access the canvas is using document (using page.evaluate)
|
||||
let data;
|
||||
let canvas;
|
||||
let ctx;
|
||||
canvas = document.querySelector('.js-overlay canvas');
|
||||
ctx = canvas.getContext('2d');
|
||||
data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
const imageDataValues = Object.values(data);
|
||||
let plotPixels = [];
|
||||
// Each pixel consists of four values within the ImageData.data array. The for loop iterates by multiples of four.
|
||||
// The values associated with each pixel are R (red), G (green), B (blue), and A (alpha), in that order.
|
||||
for (let i = 0; i < imageDataValues.length;) {
|
||||
if (imageDataValues[i] > 0) {
|
||||
plotPixels.push({
|
||||
startIndex: i,
|
||||
endIndex: i + 3,
|
||||
value: `rgb(${imageDataValues[i]}, ${imageDataValues[i + 1]}, ${imageDataValues[i + 2]}, ${imageDataValues[i + 3]})`
|
||||
});
|
||||
}
|
||||
|
||||
i = i + 4;
|
||||
|
||||
}
|
||||
|
||||
window.getCanvasValue(plotPixels.length);
|
||||
});
|
||||
|
||||
return getTelemValuePromise;
|
||||
}
|
139
e2e/tests/functional/plugins/plot/stackedPlot.e2e.spec.js
Normal file
@ -0,0 +1,139 @@
|
||||
/*****************************************************************************
|
||||
* Open MCT, Copyright (c) 2014-2022, 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.
|
||||
*****************************************************************************/
|
||||
|
||||
/*
|
||||
Tests to verify log plot functionality. Note this test suite if very much under active development and should not
|
||||
necessarily be used for reference when writing new tests in this area.
|
||||
*/
|
||||
|
||||
const { test, expect } = require('../../../../pluginFixtures');
|
||||
const { createDomainObjectWithDefaults } = require('../../../../appActions');
|
||||
|
||||
test.describe('Stacked Plot', () => {
|
||||
let stackedPlot;
|
||||
let swgA;
|
||||
let swgB;
|
||||
let swgC;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
//Open a browser, navigate to the main page, and wait until all networkevents to resolve
|
||||
await page.goto('/', { waitUntil: 'networkidle' });
|
||||
stackedPlot = await createDomainObjectWithDefaults(page, {
|
||||
type: "Stacked Plot"
|
||||
});
|
||||
|
||||
swgA = await createDomainObjectWithDefaults(page, {
|
||||
type: "Sine Wave Generator",
|
||||
parent: stackedPlot.uuid
|
||||
});
|
||||
swgB = await createDomainObjectWithDefaults(page, {
|
||||
type: "Sine Wave Generator",
|
||||
parent: stackedPlot.uuid
|
||||
});
|
||||
swgC = await createDomainObjectWithDefaults(page, {
|
||||
type: "Sine Wave Generator",
|
||||
parent: stackedPlot.uuid
|
||||
});
|
||||
});
|
||||
|
||||
test('Using the remove action removes the correct plot', async ({ page }) => {
|
||||
const swgAElementsPoolItem = page.locator('#inspector-elements-tree').locator('.c-object-label', { hasText: swgA.name });
|
||||
const swgBElementsPoolItem = page.locator('#inspector-elements-tree').locator('.c-object-label', { hasText: swgB.name });
|
||||
const swgCElementsPoolItem = page.locator('#inspector-elements-tree').locator('.c-object-label', { hasText: swgC.name });
|
||||
|
||||
await page.goto(stackedPlot.url);
|
||||
|
||||
await page.click('button[title="Edit"]');
|
||||
|
||||
// Expand the elements pool vertically
|
||||
await page.locator('.l-pane__handle').nth(2).hover({ trial: true });
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(0, 100);
|
||||
await page.mouse.up();
|
||||
|
||||
await swgBElementsPoolItem.click({ button: 'right' });
|
||||
await page.getByRole('menuitem').filter({ hasText: /Remove/ }).click();
|
||||
await page.getByRole('button').filter({ hasText: "OK" }).click();
|
||||
|
||||
await expect(page.locator('#inspector-elements-tree .js-elements-pool__item')).toHaveCount(2);
|
||||
|
||||
// Confirm that the elements pool contains the items we expect
|
||||
await expect(swgAElementsPoolItem).toHaveCount(1);
|
||||
await expect(swgBElementsPoolItem).toHaveCount(0);
|
||||
await expect(swgCElementsPoolItem).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('Selecting a child plot while in browse and edit modes shows its properties in the inspector', async ({ page }) => {
|
||||
await page.goto(stackedPlot.url);
|
||||
|
||||
// Click on the 1st plot
|
||||
await page.locator(`[aria-label="Stacked Plot Item ${swgA.name}"] canvas`).nth(1).click();
|
||||
|
||||
// Assert that the inspector shows the Y Axis properties for swgA
|
||||
await expect(page.locator('[aria-label="Plot Series Properties"] >> h2')).toContainText("Plot Series");
|
||||
await expect(page.getByRole('list', { name: "Y Axis Properties" }).locator("h2")).toContainText("Y Axis");
|
||||
await expect(page.locator('[aria-label="Plot Series Properties"] .c-object-label')).toContainText(swgA.name);
|
||||
|
||||
// Click on the 2nd plot
|
||||
await page.locator(`[aria-label="Stacked Plot Item ${swgB.name}"] canvas`).nth(1).click();
|
||||
|
||||
// Assert that the inspector shows the Y Axis properties for swgB
|
||||
await expect(page.locator('[aria-label="Plot Series Properties"] >> h2')).toContainText("Plot Series");
|
||||
await expect(page.getByRole('list', { name: "Y Axis Properties" }).locator("h2")).toContainText("Y Axis");
|
||||
await expect(page.locator('[aria-label="Plot Series Properties"] .c-object-label')).toContainText(swgB.name);
|
||||
|
||||
// Click on the 3rd plot
|
||||
await page.locator(`[aria-label="Stacked Plot Item ${swgC.name}"] canvas`).nth(1).click();
|
||||
|
||||
// Assert that the inspector shows the Y Axis properties for swgC
|
||||
await expect(page.locator('[aria-label="Plot Series Properties"] >> h2')).toContainText("Plot Series");
|
||||
await expect(page.getByRole('list', { name: "Y Axis Properties" }).locator("h2")).toContainText("Y Axis");
|
||||
await expect(page.locator('[aria-label="Plot Series Properties"] .c-object-label')).toContainText(swgC.name);
|
||||
|
||||
// Go into edit mode
|
||||
await page.click('button[title="Edit"]');
|
||||
|
||||
// Click on canvas for the 1st plot
|
||||
await page.locator(`[aria-label="Stacked Plot Item ${swgA.name}"]`).click();
|
||||
|
||||
// Assert that the inspector shows the Y Axis properties for swgA
|
||||
await expect(page.locator('[aria-label="Plot Series Properties"] >> h2')).toContainText("Plot Series");
|
||||
await expect(page.getByRole('list', { name: "Y Axis Properties" }).locator("h2")).toContainText("Y Axis");
|
||||
await expect(page.locator('[aria-label="Plot Series Properties"] .c-object-label')).toContainText(swgA.name);
|
||||
|
||||
//Click on canvas for the 2nd plot
|
||||
await page.locator(`[aria-label="Stacked Plot Item ${swgB.name}"]`).click();
|
||||
|
||||
// Assert that the inspector shows the Y Axis properties for swgB
|
||||
await expect(page.locator('[aria-label="Plot Series Properties"] >> h2')).toContainText("Plot Series");
|
||||
await expect(page.getByRole('list', { name: "Y Axis Properties" }).locator("h2")).toContainText("Y Axis");
|
||||
await expect(page.locator('[aria-label="Plot Series Properties"] .c-object-label')).toContainText(swgB.name);
|
||||
|
||||
//Click on canvas for the 3rd plot
|
||||
await page.locator(`[aria-label="Stacked Plot Item ${swgC.name}"]`).click();
|
||||
|
||||
// Assert that the inspector shows the Y Axis properties for swgC
|
||||
await expect(page.locator('[aria-label="Plot Series Properties"] >> h2')).toContainText("Plot Series");
|
||||
await expect(page.getByRole('list', { name: "Y Axis Properties" }).locator("h2")).toContainText("Y Axis");
|
||||
await expect(page.locator('[aria-label="Plot Series Properties"] .c-object-label')).toContainText(swgC.name);
|
||||
});
|
||||
});
|
132
e2e/tests/functional/recentObjects.e2e.spec.js
Normal file
@ -0,0 +1,132 @@
|
||||
/*****************************************************************************
|
||||
* Open MCT, Copyright (c) 2014-2023, United States Government
|
||||
* as represented by the Administrator of the National Aeronautics and Space
|
||||
* Administration. All rights reserved.
|
||||
*
|
||||
* Open MCT is licensed under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0.
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*
|
||||
* Open MCT includes source code licensed under additional open source
|
||||
* licenses. See the Open Source Licenses file (LICENSES.md) included with
|
||||
* this source code distribution or the Licensing information page available
|
||||
* at runtime from the About dialog for additional information.
|
||||
*****************************************************************************/
|
||||
|
||||
const { test, expect } = require('../../pluginFixtures.js');
|
||||
const { createDomainObjectWithDefaults } = require('../../appActions.js');
|
||||
|
||||
test.describe('Recent Objects', () => {
|
||||
let recentObjectsList;
|
||||
let clock;
|
||||
let folderA;
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('./', { waitUntil: 'networkidle' });
|
||||
|
||||
// Set Recent Objects List locator for subsequent tests
|
||||
recentObjectsList = page.getByRole('list', {
|
||||
name: 'Recent Objects'
|
||||
});
|
||||
|
||||
// Create a folder and nest a Clock within it
|
||||
folderA = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Folder'
|
||||
});
|
||||
clock = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Clock',
|
||||
parent: folderA.uuid
|
||||
});
|
||||
|
||||
// Drag the Recent Objects panel up a bit
|
||||
await page.locator('div:nth-child(2) > .l-pane__handle').hover();
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(0, 100);
|
||||
await page.mouse.up();
|
||||
});
|
||||
test('Recent Objects CRUD operations', async ({ page }) => {
|
||||
// Verify that both created objects appear in the list and are in the correct order
|
||||
expect(recentObjectsList.getByRole('listitem', { name: clock.name })).toBeTruthy();
|
||||
expect(recentObjectsList.getByRole('listitem', { name: folderA.name })).toBeTruthy();
|
||||
expect(recentObjectsList.getByRole('listitem', { name: clock.name }).locator('a').getByText(folderA.name)).toBeTruthy();
|
||||
expect(recentObjectsList.getByRole('listitem').nth(0).getByText(clock.name)).toBeTruthy();
|
||||
expect(recentObjectsList.getByRole('listitem', { name: clock.name }).locator('a').getByText(folderA.name)).toBeTruthy();
|
||||
expect(recentObjectsList.getByRole('listitem').nth(1).getByText(folderA.name)).toBeTruthy();
|
||||
|
||||
// Navigate to the folder by clicking on the main object name in the recent objects list item
|
||||
await page.getByRole('listitem', { name: folderA.name }).getByText(folderA.name).click();
|
||||
await page.waitForURL(`**/${folderA.uuid}?*`);
|
||||
expect(recentObjectsList.getByRole('listitem').nth(0).getByText(folderA.name)).toBeTruthy();
|
||||
|
||||
// Rename
|
||||
folderA.name = `${folderA.name}-NEW!`;
|
||||
await page.locator('.l-browse-bar__object-name').fill("");
|
||||
await page.locator('.l-browse-bar__object-name').fill(folderA.name);
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
// Verify rename has been applied in recent objects list item and objects paths
|
||||
expect(await page.getByRole('navigation', {
|
||||
name: `${clock.name} Breadcrumb`
|
||||
}).locator('a').filter({
|
||||
hasText: folderA.name
|
||||
}).count()).toBeGreaterThan(0);
|
||||
expect(recentObjectsList.getByRole('listitem', { name: folderA.name })).toBeTruthy();
|
||||
|
||||
// Delete
|
||||
await page.click('button[title="Show selected item in tree"]');
|
||||
// Delete the folder via the left tree pane treeitem context menu
|
||||
await page.getByRole('treeitem', { name: new RegExp(folderA.name) }).locator('a').click({
|
||||
button: 'right'
|
||||
});
|
||||
await page.getByRole('menuitem', { name: /Remove/ }).click();
|
||||
await page.getByRole('button', { name: 'OK' }).click();
|
||||
|
||||
// Verify that the folder and clock are no longer in the recent objects list
|
||||
await expect(recentObjectsList.getByRole('listitem', { name: folderA.name })).toBeHidden();
|
||||
await expect(recentObjectsList.getByRole('listitem', { name: clock.name })).toBeHidden();
|
||||
});
|
||||
test("Clicking on an object in the path of a recent object navigates to the object", async ({ page, openmctConfig }) => {
|
||||
const { myItemsFolderName } = openmctConfig;
|
||||
test.info().annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/nasa/openmct/issues/6151'
|
||||
});
|
||||
await page.goto('./#/browse/mine');
|
||||
|
||||
// Navigate to the folder by clicking on its entry in the Clock's breadcrumb
|
||||
const waitForFolderNavigation = page.waitForURL(`**/${folderA.uuid}?*`);
|
||||
await page.getByRole('navigation', {
|
||||
name: `${clock.name} Breadcrumb`
|
||||
}).locator('a').filter({
|
||||
hasText: folderA.name
|
||||
}).click();
|
||||
|
||||
// Verify that the hash URL updates correctly
|
||||
await waitForFolderNavigation;
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
expect(page.url()).toMatch(new RegExp(`.*${folderA.uuid}\?.*`));
|
||||
|
||||
// Navigate to My Items by clicking on its entry in the Clock's breadcrumb
|
||||
const waitForMyItemsNavigation = page.waitForURL(`**/mine?*`);
|
||||
await page.getByRole('navigation', {
|
||||
name: `${clock.name} Breadcrumb`
|
||||
}).locator('a').filter({
|
||||
hasText: myItemsFolderName
|
||||
}).click();
|
||||
|
||||
// Verify that the hash URL updates correctly
|
||||
await waitForMyItemsNavigation;
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
expect(page.url()).toMatch(new RegExp(`.*mine\?.*`));
|
||||
});
|
||||
test.fixme("Clicking on the 'target button' scrolls the object into view in the tree and highlights it", async ({ page }) => {
|
||||
});
|
||||
test.fixme("Tests for context menu actions from recent objects", async ({ page }) => {
|
||||
});
|
||||
});
|
@ -72,7 +72,7 @@ test.describe('Grand Search', () => {
|
||||
await page.locator('[aria-label="OpenMCT Search"] [aria-label="Search Input"]').fill('Cl');
|
||||
await Promise.all([
|
||||
page.waitForNavigation(),
|
||||
page.locator('text=Clock A').click()
|
||||
page.locator('[aria-label="Clock A clock result"] >> text=Clock A').click()
|
||||
]);
|
||||
await expect(page.locator('.is-object-type-clock')).toBeVisible();
|
||||
|
||||
|
@ -116,7 +116,9 @@ async function getAndAssertTreeItems(page, expected) {
|
||||
* @param {string} name
|
||||
*/
|
||||
async function expandTreePaneItemByName(page, name) {
|
||||
const treePane = page.locator('#tree-pane');
|
||||
const treePane = page.getByRole('tree', {
|
||||
name: 'Main Tree'
|
||||
});
|
||||
const treeItem = treePane.locator(`role=treeitem[expanded=false][name=/${name}/]`);
|
||||
const expandTriangle = treeItem.locator('.c-disclosure-triangle');
|
||||
await expandTriangle.click();
|
||||
|
@ -57,7 +57,7 @@ test.describe('Visual - Tree Pane', () => {
|
||||
name: 'Z Clock'
|
||||
});
|
||||
|
||||
const treePane = "#tree-pane";
|
||||
const treePane = "[role=tree][aria-label='Main Tree']";
|
||||
|
||||
await percySnapshot(page, `Tree Pane w/ collapsed tree (theme: ${theme})`, {
|
||||
scope: treePane
|
||||
@ -94,7 +94,7 @@ test.describe('Visual - Tree Pane', () => {
|
||||
* @param {string} name
|
||||
*/
|
||||
async function expandTreePaneItemByName(page, name) {
|
||||
const treePane = page.locator('#tree-pane');
|
||||
const treePane = page.getByTestId('tree-pane');
|
||||
const treeItem = treePane.locator(`role=treeitem[expanded=false][name=/${name}/]`);
|
||||
const expandTriangle = treeItem.locator('.c-disclosure-triangle');
|
||||
await expandTriangle.click();
|
||||
|
@ -65,7 +65,7 @@ export default class ExampleUserProvider extends EventEmitter {
|
||||
this.user = undefined;
|
||||
this.loggedIn = false;
|
||||
this.autoLoginUser = undefined;
|
||||
this.status = STATUSES[1];
|
||||
this.status = STATUSES[0];
|
||||
this.pollQuestion = undefined;
|
||||
this.defaultStatusRole = defaultStatusRole;
|
||||
|
||||
@ -124,6 +124,7 @@ export default class ExampleUserProvider extends EventEmitter {
|
||||
}
|
||||
|
||||
setStatusForRole(role, status) {
|
||||
status.timestamp = Date.now();
|
||||
this.status = status;
|
||||
this.emit('statusChange', {
|
||||
role,
|
||||
@ -133,14 +134,23 @@ export default class ExampleUserProvider extends EventEmitter {
|
||||
return true;
|
||||
}
|
||||
|
||||
getPollQuestion() {
|
||||
return Promise.resolve({
|
||||
question: 'Set "GO" if your position is ready for a boarding action on the Klingon cruiser',
|
||||
timestamp: Date.now()
|
||||
});
|
||||
// eslint-disable-next-line require-await
|
||||
async getPollQuestion() {
|
||||
if (this.pollQuestion) {
|
||||
return this.pollQuestion;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
setPollQuestion(pollQuestion) {
|
||||
if (!pollQuestion) {
|
||||
// If the poll question is undefined, set it to a blank string.
|
||||
// This behavior better reflects how other telemetry systems
|
||||
// deal with undefined poll questions.
|
||||
pollQuestion = '';
|
||||
}
|
||||
|
||||
this.pollQuestion = {
|
||||
question: pollQuestion,
|
||||
timestamp: Date.now()
|
||||
|
@ -37,8 +37,9 @@ define([
|
||||
infinityValues: false
|
||||
};
|
||||
|
||||
function GeneratorProvider(openmct) {
|
||||
this.workerInterface = new WorkerInterface(openmct);
|
||||
function GeneratorProvider(openmct, StalenessProvider) {
|
||||
this.openmct = openmct;
|
||||
this.workerInterface = new WorkerInterface(openmct, StalenessProvider);
|
||||
}
|
||||
|
||||
GeneratorProvider.prototype.canProvideTelemetry = function (domainObject) {
|
||||
@ -81,6 +82,7 @@ define([
|
||||
workerRequest[prop] = Number(workerRequest[prop]);
|
||||
});
|
||||
|
||||
workerRequest.id = this.openmct.objects.makeKeyString(domainObject.identifier);
|
||||
workerRequest.name = domainObject.name;
|
||||
|
||||
return workerRequest;
|
||||
|
157
example/generator/SinewaveStalenessProvider.js
Normal file
@ -0,0 +1,157 @@
|
||||
/*****************************************************************************
|
||||
* Open MCT, Copyright (c) 2014-2022, 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 EventEmitter from 'EventEmitter';
|
||||
|
||||
export default class SinewaveLimitProvider extends EventEmitter {
|
||||
#openmct;
|
||||
#observingStaleness;
|
||||
#watchingTheClock;
|
||||
#isRealTime;
|
||||
|
||||
constructor(openmct) {
|
||||
super();
|
||||
|
||||
this.#openmct = openmct;
|
||||
this.#observingStaleness = {};
|
||||
this.#watchingTheClock = false;
|
||||
this.#isRealTime = undefined;
|
||||
}
|
||||
|
||||
supportsStaleness(domainObject) {
|
||||
return domainObject.type === 'generator';
|
||||
}
|
||||
|
||||
isStale(domainObject, options) {
|
||||
if (!this.#providingStaleness(domainObject)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = this.#getObjectKeyString(domainObject);
|
||||
|
||||
if (!this.#observerExists(id)) {
|
||||
this.#createObserver(id);
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
isStale: this.#observingStaleness[id].isStale,
|
||||
utc: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
subscribeToStaleness(domainObject, callback) {
|
||||
const id = this.#getObjectKeyString(domainObject);
|
||||
|
||||
if (this.#isRealTime === undefined) {
|
||||
this.#updateRealTime(this.#openmct.time.clock());
|
||||
}
|
||||
|
||||
this.#handleClockUpdate();
|
||||
|
||||
if (this.#observerExists(id)) {
|
||||
this.#addCallbackToObserver(id, callback);
|
||||
} else {
|
||||
this.#createObserver(id, callback);
|
||||
}
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
if (this.#providingStaleness(domainObject)) {
|
||||
this.#updateStaleness(id, !this.#observingStaleness[id].isStale);
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
this.#updateStaleness(id, false);
|
||||
this.#handleClockUpdate();
|
||||
this.#destroyObserver(id);
|
||||
};
|
||||
}
|
||||
|
||||
#handleClockUpdate() {
|
||||
let observers = Object.values(this.#observingStaleness).length > 0;
|
||||
|
||||
if (observers && !this.#watchingTheClock) {
|
||||
this.#watchingTheClock = true;
|
||||
this.#openmct.time.on('clock', this.#updateRealTime, this);
|
||||
} else if (!observers && this.#watchingTheClock) {
|
||||
this.#watchingTheClock = false;
|
||||
this.#openmct.time.off('clock', this.#updateRealTime, this);
|
||||
}
|
||||
}
|
||||
|
||||
#updateRealTime(clock) {
|
||||
this.#isRealTime = clock !== undefined;
|
||||
|
||||
if (!this.#isRealTime) {
|
||||
Object.keys(this.#observingStaleness).forEach((id) => {
|
||||
this.#updateStaleness(id, false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#updateStaleness(id, isStale) {
|
||||
this.#observingStaleness[id].isStale = isStale;
|
||||
this.#observingStaleness[id].utc = Date.now();
|
||||
this.#observingStaleness[id].callback({
|
||||
isStale: this.#observingStaleness[id].isStale,
|
||||
utc: this.#observingStaleness[id].utc
|
||||
});
|
||||
this.emit('stalenessEvent', {
|
||||
id,
|
||||
isStale: this.#observingStaleness[id].isStale
|
||||
});
|
||||
}
|
||||
|
||||
#createObserver(id, callback) {
|
||||
this.#observingStaleness[id] = {
|
||||
isStale: false,
|
||||
utc: Date.now()
|
||||
};
|
||||
|
||||
if (typeof callback === 'function') {
|
||||
this.#addCallbackToObserver(id, callback);
|
||||
}
|
||||
}
|
||||
|
||||
#destroyObserver(id) {
|
||||
if (this.#observingStaleness[id]) {
|
||||
delete this.#observingStaleness[id];
|
||||
}
|
||||
}
|
||||
|
||||
#providingStaleness(domainObject) {
|
||||
return domainObject.telemetry?.staleness === true && this.#isRealTime;
|
||||
}
|
||||
|
||||
#getObjectKeyString(object) {
|
||||
return this.#openmct.objects.makeKeyString(object.identifier);
|
||||
}
|
||||
|
||||
#addCallbackToObserver(id, callback) {
|
||||
this.#observingStaleness[id].callback = callback;
|
||||
}
|
||||
|
||||
#observerExists(id) {
|
||||
return this.#observingStaleness?.[id];
|
||||
}
|
||||
}
|
@ -25,14 +25,24 @@ define([
|
||||
], function (
|
||||
{ v4: uuid }
|
||||
) {
|
||||
function WorkerInterface(openmct) {
|
||||
function WorkerInterface(openmct, StalenessProvider) {
|
||||
// eslint-disable-next-line no-undef
|
||||
const workerUrl = `${openmct.getAssetPath()}${__OPENMCT_ROOT_RELATIVE__}generatorWorker.js`;
|
||||
this.StalenessProvider = StalenessProvider;
|
||||
this.worker = new Worker(workerUrl);
|
||||
this.worker.onmessage = this.onMessage.bind(this);
|
||||
this.callbacks = {};
|
||||
this.staleTelemetryIds = {};
|
||||
|
||||
this.watchStaleness();
|
||||
}
|
||||
|
||||
WorkerInterface.prototype.watchStaleness = function () {
|
||||
this.StalenessProvider.on('stalenessEvent', ({ id, isStale}) => {
|
||||
this.staleTelemetryIds[id] = isStale;
|
||||
});
|
||||
};
|
||||
|
||||
WorkerInterface.prototype.onMessage = function (message) {
|
||||
message = message.data;
|
||||
var callback = this.callbacks[message.id];
|
||||
@ -83,11 +93,12 @@ define([
|
||||
};
|
||||
|
||||
WorkerInterface.prototype.subscribe = function (request, cb) {
|
||||
function callback(message) {
|
||||
cb(message.data);
|
||||
}
|
||||
|
||||
var messageId = this.dispatch('subscribe', request, callback);
|
||||
const id = request.id;
|
||||
const messageId = this.dispatch('subscribe', request, (message) => {
|
||||
if (!this.staleTelemetryIds[id]) {
|
||||
cb(message.data);
|
||||
}
|
||||
});
|
||||
|
||||
return function () {
|
||||
this.dispatch('unsubscribe', {
|
||||
|
@ -20,158 +20,163 @@
|
||||
* at runtime from the About dialog for additional information.
|
||||
*****************************************************************************/
|
||||
|
||||
define([
|
||||
"./GeneratorProvider",
|
||||
"./SinewaveLimitProvider",
|
||||
"./StateGeneratorProvider",
|
||||
"./GeneratorMetadataProvider"
|
||||
], function (
|
||||
GeneratorProvider,
|
||||
SinewaveLimitProvider,
|
||||
StateGeneratorProvider,
|
||||
GeneratorMetadataProvider
|
||||
) {
|
||||
import GeneratorProvider from "./GeneratorProvider";
|
||||
import SinewaveLimitProvider from "./SinewaveLimitProvider";
|
||||
import SinewaveStalenessProvider from "./SinewaveStalenessProvider";
|
||||
import StateGeneratorProvider from "./StateGeneratorProvider";
|
||||
import GeneratorMetadataProvider from "./GeneratorMetadataProvider";
|
||||
|
||||
return function (openmct) {
|
||||
export default function (openmct) {
|
||||
|
||||
openmct.types.addType("example.state-generator", {
|
||||
name: "State Generator",
|
||||
description: "For development use. Generates example enumerated telemetry by cycling through a given set of states.",
|
||||
cssClass: "icon-generator-telemetry",
|
||||
creatable: true,
|
||||
form: [
|
||||
{
|
||||
name: "State Duration (seconds)",
|
||||
control: "numberfield",
|
||||
cssClass: "l-input-sm l-numeric",
|
||||
key: "duration",
|
||||
required: true,
|
||||
property: [
|
||||
"telemetry",
|
||||
"duration"
|
||||
]
|
||||
}
|
||||
],
|
||||
initialize: function (object) {
|
||||
object.telemetry = {
|
||||
duration: 5
|
||||
};
|
||||
openmct.types.addType("example.state-generator", {
|
||||
name: "State Generator",
|
||||
description: "For development use. Generates example enumerated telemetry by cycling through a given set of states.",
|
||||
cssClass: "icon-generator-telemetry",
|
||||
creatable: true,
|
||||
form: [
|
||||
{
|
||||
name: "State Duration (seconds)",
|
||||
control: "numberfield",
|
||||
cssClass: "l-input-sm l-numeric",
|
||||
key: "duration",
|
||||
required: true,
|
||||
property: [
|
||||
"telemetry",
|
||||
"duration"
|
||||
]
|
||||
}
|
||||
});
|
||||
],
|
||||
initialize: function (object) {
|
||||
object.telemetry = {
|
||||
duration: 5
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
openmct.telemetry.addProvider(new StateGeneratorProvider());
|
||||
openmct.telemetry.addProvider(new StateGeneratorProvider());
|
||||
|
||||
openmct.types.addType("generator", {
|
||||
name: "Sine Wave Generator",
|
||||
description: "For development use. Generates example streaming telemetry data using a simple sine wave algorithm.",
|
||||
cssClass: "icon-generator-telemetry",
|
||||
creatable: true,
|
||||
form: [
|
||||
{
|
||||
name: "Period",
|
||||
control: "numberfield",
|
||||
cssClass: "l-input-sm l-numeric",
|
||||
key: "period",
|
||||
required: true,
|
||||
property: [
|
||||
"telemetry",
|
||||
"period"
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Amplitude",
|
||||
control: "numberfield",
|
||||
cssClass: "l-numeric",
|
||||
key: "amplitude",
|
||||
required: true,
|
||||
property: [
|
||||
"telemetry",
|
||||
"amplitude"
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Offset",
|
||||
control: "numberfield",
|
||||
cssClass: "l-numeric",
|
||||
key: "offset",
|
||||
required: true,
|
||||
property: [
|
||||
"telemetry",
|
||||
"offset"
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Data Rate (hz)",
|
||||
control: "numberfield",
|
||||
cssClass: "l-input-sm l-numeric",
|
||||
key: "dataRateInHz",
|
||||
required: true,
|
||||
property: [
|
||||
"telemetry",
|
||||
"dataRateInHz"
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Phase (radians)",
|
||||
control: "numberfield",
|
||||
cssClass: "l-input-sm l-numeric",
|
||||
key: "phase",
|
||||
required: true,
|
||||
property: [
|
||||
"telemetry",
|
||||
"phase"
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Randomness",
|
||||
control: "numberfield",
|
||||
cssClass: "l-input-sm l-numeric",
|
||||
key: "randomness",
|
||||
required: true,
|
||||
property: [
|
||||
"telemetry",
|
||||
"randomness"
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Loading Delay (ms)",
|
||||
control: "numberfield",
|
||||
cssClass: "l-input-sm l-numeric",
|
||||
key: "loadDelay",
|
||||
required: true,
|
||||
property: [
|
||||
"telemetry",
|
||||
"loadDelay"
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Include Infinity Values",
|
||||
control: "toggleSwitch",
|
||||
cssClass: "l-input",
|
||||
key: "infinityValues",
|
||||
property: [
|
||||
"telemetry",
|
||||
"infinityValues"
|
||||
]
|
||||
}
|
||||
],
|
||||
initialize: function (object) {
|
||||
object.telemetry = {
|
||||
period: 10,
|
||||
amplitude: 1,
|
||||
offset: 0,
|
||||
dataRateInHz: 1,
|
||||
phase: 0,
|
||||
randomness: 0,
|
||||
loadDelay: 0,
|
||||
infinityValues: false
|
||||
};
|
||||
openmct.types.addType("generator", {
|
||||
name: "Sine Wave Generator",
|
||||
description: "For development use. Generates example streaming telemetry data using a simple sine wave algorithm.",
|
||||
cssClass: "icon-generator-telemetry",
|
||||
creatable: true,
|
||||
form: [
|
||||
{
|
||||
name: "Period",
|
||||
control: "numberfield",
|
||||
cssClass: "l-input-sm l-numeric",
|
||||
key: "period",
|
||||
required: true,
|
||||
property: [
|
||||
"telemetry",
|
||||
"period"
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Amplitude",
|
||||
control: "numberfield",
|
||||
cssClass: "l-numeric",
|
||||
key: "amplitude",
|
||||
required: true,
|
||||
property: [
|
||||
"telemetry",
|
||||
"amplitude"
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Offset",
|
||||
control: "numberfield",
|
||||
cssClass: "l-numeric",
|
||||
key: "offset",
|
||||
required: true,
|
||||
property: [
|
||||
"telemetry",
|
||||
"offset"
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Data Rate (hz)",
|
||||
control: "numberfield",
|
||||
cssClass: "l-input-sm l-numeric",
|
||||
key: "dataRateInHz",
|
||||
required: true,
|
||||
property: [
|
||||
"telemetry",
|
||||
"dataRateInHz"
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Phase (radians)",
|
||||
control: "numberfield",
|
||||
cssClass: "l-input-sm l-numeric",
|
||||
key: "phase",
|
||||
required: true,
|
||||
property: [
|
||||
"telemetry",
|
||||
"phase"
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Randomness",
|
||||
control: "numberfield",
|
||||
cssClass: "l-input-sm l-numeric",
|
||||
key: "randomness",
|
||||
required: true,
|
||||
property: [
|
||||
"telemetry",
|
||||
"randomness"
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Loading Delay (ms)",
|
||||
control: "numberfield",
|
||||
cssClass: "l-input-sm l-numeric",
|
||||
key: "loadDelay",
|
||||
required: true,
|
||||
property: [
|
||||
"telemetry",
|
||||
"loadDelay"
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Include Infinity Values",
|
||||
control: "toggleSwitch",
|
||||
cssClass: "l-input",
|
||||
key: "infinityValues",
|
||||
property: [
|
||||
"telemetry",
|
||||
"infinityValues"
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Provide Staleness Updates",
|
||||
control: "toggleSwitch",
|
||||
cssClass: "l-input",
|
||||
key: "staleness",
|
||||
property: [
|
||||
"telemetry",
|
||||
"staleness"
|
||||
]
|
||||
}
|
||||
});
|
||||
],
|
||||
initialize: function (object) {
|
||||
object.telemetry = {
|
||||
period: 10,
|
||||
amplitude: 1,
|
||||
offset: 0,
|
||||
dataRateInHz: 1,
|
||||
phase: 0,
|
||||
randomness: 0,
|
||||
loadDelay: 0,
|
||||
infinityValues: false,
|
||||
staleness: false
|
||||
};
|
||||
}
|
||||
});
|
||||
const stalenessProvider = new SinewaveStalenessProvider(openmct);
|
||||
|
||||
openmct.telemetry.addProvider(new GeneratorProvider(openmct));
|
||||
openmct.telemetry.addProvider(new GeneratorMetadataProvider());
|
||||
openmct.telemetry.addProvider(new SinewaveLimitProvider());
|
||||
};
|
||||
|
||||
});
|
||||
openmct.telemetry.addProvider(new GeneratorProvider(openmct, stalenessProvider));
|
||||
openmct.telemetry.addProvider(new GeneratorMetadataProvider());
|
||||
openmct.telemetry.addProvider(new SinewaveLimitProvider());
|
||||
openmct.telemetry.addProvider(stalenessProvider);
|
||||
}
|
||||
|
@ -107,6 +107,15 @@ export default function () {
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Image Thumbnail',
|
||||
key: 'thumbnail-url',
|
||||
format: 'thumbnail',
|
||||
hints: {
|
||||
thumbnail: 1
|
||||
},
|
||||
source: 'url'
|
||||
},
|
||||
{
|
||||
name: 'Image Download Name',
|
||||
key: 'imageDownloadName',
|
||||
@ -143,6 +152,16 @@ export default function () {
|
||||
]
|
||||
});
|
||||
|
||||
const formatThumbnail = {
|
||||
format: function (url) {
|
||||
return `${url}?w=100&h=100`;
|
||||
}
|
||||
};
|
||||
|
||||
openmct.telemetry.addFormat({
|
||||
key: 'thumbnail',
|
||||
...formatThumbnail
|
||||
});
|
||||
openmct.telemetry.addProvider(getRealtimeProvider());
|
||||
openmct.telemetry.addProvider(getHistoricalProvider());
|
||||
openmct.telemetry.addProvider(getLadProvider());
|
||||
@ -242,6 +261,13 @@ function pointForTimestamp(timestamp, name, imageSamples, delay) {
|
||||
const url = imageSamples[Math.floor(timestamp / delay) % imageSamples.length];
|
||||
const urlItems = url.split('/');
|
||||
const imageDownloadName = `example.imagery.${urlItems[urlItems.length - 1]}`;
|
||||
const navCamTransformations = {
|
||||
"translateX": 0,
|
||||
"translateY": 18,
|
||||
"rotation": 0,
|
||||
"scale": 0.3,
|
||||
"cameraAngleOfView": 70
|
||||
};
|
||||
|
||||
return {
|
||||
name,
|
||||
@ -249,8 +275,9 @@ function pointForTimestamp(timestamp, name, imageSamples, delay) {
|
||||
local: Math.floor(timestamp / delay) * delay,
|
||||
url,
|
||||
sunOrientation: getCompassValues(0, 360),
|
||||
cameraPan: getCompassValues(0, 360),
|
||||
cameraAzimuth: getCompassValues(0, 360),
|
||||
heading: getCompassValues(0, 360),
|
||||
transformations: navCamTransformations,
|
||||
imageDownloadName
|
||||
};
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openmct",
|
||||
"version": "2.1.6-SNAPSHOT",
|
||||
"version": "2.1.6",
|
||||
"description": "The Open MCT core platform",
|
||||
"devDependencies": {
|
||||
"@babel/eslint-parser": "7.18.9",
|
||||
@ -22,7 +22,7 @@
|
||||
"d3-selection": "3.0.0",
|
||||
"eslint": "8.32.0",
|
||||
"eslint-plugin-compat": "4.0.2",
|
||||
"eslint-plugin-playwright": "0.11.2",
|
||||
"eslint-plugin-playwright": "0.12.0",
|
||||
"eslint-plugin-vue": "9.9.0",
|
||||
"eslint-plugin-you-dont-need-lodash-underscore": "6.12.0",
|
||||
"eventemitter3": "1.2.0",
|
||||
@ -41,6 +41,7 @@
|
||||
"karma-sourcemap-loader": "0.3.8",
|
||||
"karma-spec-reporter": "0.0.36",
|
||||
"karma-webpack": "5.0.0",
|
||||
"kdbush": "^3.0.0",
|
||||
"location-bar": "3.0.1",
|
||||
"lodash": "4.17.21",
|
||||
"mini-css-extract-plugin": "2.7.2",
|
||||
@ -54,6 +55,7 @@
|
||||
"plotly.js-gl2d-dist": "2.17.1",
|
||||
"printj": "1.3.1",
|
||||
"resolve-url-loader": "5.0.0",
|
||||
"sanitize-html": "2.8.1",
|
||||
"sass": "1.57.1",
|
||||
"sass-loader": "13.2.0",
|
||||
"sinon": "15.0.1",
|
||||
|
@ -256,6 +256,15 @@ define([
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* MCT's annotation API that enables
|
||||
* human-created comments and categorization linked to data products
|
||||
* @type {module:openmct.AnnotationAPI}
|
||||
* @memberof module:openmct.MCT#
|
||||
* @name annotation
|
||||
*/
|
||||
this.annotation = new api.AnnotationAPI(this);
|
||||
|
||||
// Plugins that are installed by default
|
||||
this.install(this.plugins.Plot());
|
||||
this.install(this.plugins.TelemetryTable.default());
|
||||
|
@ -52,6 +52,29 @@ const ANNOTATION_LAST_CREATED = 'annotationLastCreated';
|
||||
* @property {String} foregroundColor eg. "#ffffff"
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {import('../objects/ObjectAPI').DomainObject} DomainObject
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {import('../objects/ObjectAPI').Identifier} Identifier
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {import('../../../openmct').OpenMCT} OpenMCT
|
||||
*/
|
||||
|
||||
/**
|
||||
* An interface for interacting with annotations of domain objects.
|
||||
* An annotation of a domain object is an operator created object for the purposes
|
||||
* of further describing data in plots, notebooks, maps, etc. For example, an annotation
|
||||
* could be a tag on a plot notating an interesting set of points labeled SCIENCE. It could
|
||||
* also be set of notebook entries the operator has tagged DRIVING when a robot monitored by OpenMCT
|
||||
* about rationals behind why the robot has taken a certain path.
|
||||
* Annotations are discoverable using search, and are typically rendered in OpenMCT views to bring attention
|
||||
* to other users.
|
||||
* @constructor
|
||||
*/
|
||||
export default class AnnotationAPI extends EventEmitter {
|
||||
|
||||
/**
|
||||
@ -81,24 +104,26 @@ export default class AnnotationAPI extends EventEmitter {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the a generic annotation
|
||||
* Creates an annotation on a given domain object (e.g., a plot) and a set of targets (e.g., telemetry objects)
|
||||
* @typedef {Object} CreateAnnotationOptions
|
||||
* @property {String} name a name for the new parameter
|
||||
* @property {import('../objects/ObjectAPI').DomainObject} domainObject the domain object to create
|
||||
* @property {ANNOTATION_TYPES} annotationType the type of annotation to create
|
||||
* @property {Tag[]} tags
|
||||
* @property {String} contentText
|
||||
* @property {import('../objects/ObjectAPI').Identifier[]} targets
|
||||
* @property {String} name a name for the new annotation (e.g., "Plot annnotation")
|
||||
* @property {DomainObject} domainObject the domain object this annotation was created with
|
||||
* @property {ANNOTATION_TYPES} annotationType the type of annotation to create (e.g., PLOT_SPATIAL)
|
||||
* @property {Tag[]} tags tags to add to the annotation, e.g., SCIENCE for science related annotations
|
||||
* @property {String} contentText Some text to add to the annotation, e.g. ("This annotation is about science")
|
||||
* @property {Object<string, Object>} targets The targets ID keystrings and their specific properties.
|
||||
* For plots, this will be a bounding box, e.g.: {maxY: 100, minY: 0, maxX: 100, minX: 0}
|
||||
* For notebooks, this will be an entry ID, e.g.: {entryId: "entry-ecb158f5-d23c-45e1-a704-649b382622ba"}
|
||||
* @property {DomainObject>} targetDomainObjects the targets ID keystrings and the domain objects this annotation points to (e.g., telemetry objects for a plot)
|
||||
*/
|
||||
/**
|
||||
* @method create
|
||||
* @param {CreateAnnotationOptions} options
|
||||
* @returns {Promise<import('../objects/ObjectAPI').DomainObject>} a promise which will resolve when the domain object
|
||||
* @returns {Promise<DomainObject>} a promise which will resolve when the domain object
|
||||
* has been created, or be rejected if it cannot be saved
|
||||
*/
|
||||
async create({name, domainObject, annotationType, tags, contentText, targets}) {
|
||||
async create({name, domainObject, annotationType, tags, contentText, targets, targetDomainObjects}) {
|
||||
if (!Object.keys(this.ANNOTATION_TYPES).includes(annotationType)) {
|
||||
throw new Error(`Unknown annotation type: ${annotationType}`);
|
||||
}
|
||||
@ -107,6 +132,10 @@ export default class AnnotationAPI extends EventEmitter {
|
||||
throw new Error(`At least one target is required to create an annotation`);
|
||||
}
|
||||
|
||||
if (!Object.keys(targetDomainObjects).length) {
|
||||
throw new Error(`At least one targetDomainObject is required to create an annotation`);
|
||||
}
|
||||
|
||||
const domainObjectKeyString = this.openmct.objects.makeKeyString(domainObject.identifier);
|
||||
const originalPathObjects = await this.openmct.objects.getOriginalPath(domainObjectKeyString);
|
||||
const originalContextPath = this.openmct.objects.getRelativePath(originalPathObjects);
|
||||
@ -139,7 +168,9 @@ export default class AnnotationAPI extends EventEmitter {
|
||||
const success = await this.openmct.objects.save(createdObject);
|
||||
if (success) {
|
||||
this.emit('annotationCreated', createdObject);
|
||||
this.#updateAnnotationModified(domainObject);
|
||||
Object.values(targetDomainObjects).forEach(targetDomainObject => {
|
||||
this.#updateAnnotationModified(targetDomainObject);
|
||||
});
|
||||
|
||||
return createdObject;
|
||||
} else {
|
||||
@ -147,8 +178,15 @@ export default class AnnotationAPI extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
#updateAnnotationModified(domainObject) {
|
||||
this.openmct.objects.mutate(domainObject, this.ANNOTATION_LAST_CREATED, Date.now());
|
||||
#updateAnnotationModified(targetDomainObject) {
|
||||
// As certain telemetry objects are immutable, we'll need to check here first
|
||||
// to see if we can add the annotation last created property.
|
||||
// TODO: This should be removed once we have a better way to handle immutable telemetry objects
|
||||
if (targetDomainObject.isMutable) {
|
||||
this.openmct.objects.mutate(targetDomainObject, this.ANNOTATION_LAST_CREATED, Date.now());
|
||||
} else {
|
||||
this.emit('targetDomainObjectAnnotated', targetDomainObject);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -162,7 +200,7 @@ export default class AnnotationAPI extends EventEmitter {
|
||||
|
||||
/**
|
||||
* @method isAnnotation
|
||||
* @param {import('../objects/ObjectAPI').DomainObject} domainObject domainObject the domainObject in question
|
||||
* @param {DomainObject} domainObject the domainObject in question
|
||||
* @returns {Boolean} Returns true if the domain object is an annotation
|
||||
*/
|
||||
isAnnotation(domainObject) {
|
||||
@ -190,56 +228,19 @@ export default class AnnotationAPI extends EventEmitter {
|
||||
|
||||
/**
|
||||
* @method getAnnotations
|
||||
* @param {String} query - The keystring of the domain object to search for annotations for
|
||||
* @returns {import('../objects/ObjectAPI').DomainObject[]} Returns an array of domain objects that match the search query
|
||||
* @param {Identifier} domainObjectIdentifier - The domain object identifier to use to search for annotations. For example, a telemetry object identifier.
|
||||
* @returns {DomainObject[]} Returns an array of annotations that match the search query
|
||||
*/
|
||||
async getAnnotations(query) {
|
||||
const searchResults = (await Promise.all(this.openmct.objects.search(query, null, this.openmct.objects.SEARCH_TYPES.ANNOTATIONS))).flat();
|
||||
async getAnnotations(domainObjectIdentifier) {
|
||||
const keyStringQuery = this.openmct.objects.makeKeyString(domainObjectIdentifier);
|
||||
const searchResults = (await Promise.all(this.openmct.objects.search(keyStringQuery, null, this.openmct.objects.SEARCH_TYPES.ANNOTATIONS))).flat();
|
||||
|
||||
return searchResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* @method addSingleAnnotationTag
|
||||
* @param {import('../objects/ObjectAPI').DomainObject=} existingAnnotation - An optional annotation to add the tag to. If not specified, we will create an annotation.
|
||||
* @param {import('../objects/ObjectAPI').DomainObject} targetDomainObject - The domain object the annotation will point to.
|
||||
* @param {Object=} targetSpecificDetails - Optional object to add to the target object. E.g., for notebooks this would be an entryID
|
||||
* @param {AnnotationType} annotationType - The type of annotation this is for.
|
||||
* @returns {import('../objects/ObjectAPI').DomainObject[]} Returns the annotation that was either created or passed as an existingAnnotation
|
||||
*/
|
||||
async addSingleAnnotationTag(existingAnnotation, targetDomainObject, targetSpecificDetails, annotationType, tag) {
|
||||
if (!existingAnnotation) {
|
||||
const targets = {};
|
||||
const targetKeyString = this.openmct.objects.makeKeyString(targetDomainObject.identifier);
|
||||
targets[targetKeyString] = targetSpecificDetails;
|
||||
const contentText = `${annotationType} tag`;
|
||||
const annotationCreationArguments = {
|
||||
name: contentText,
|
||||
domainObject: targetDomainObject,
|
||||
annotationType,
|
||||
tags: [tag],
|
||||
contentText,
|
||||
targets
|
||||
};
|
||||
const newAnnotation = await this.create(annotationCreationArguments);
|
||||
|
||||
return newAnnotation;
|
||||
} else {
|
||||
if (!existingAnnotation.tags.includes(tag)) {
|
||||
throw new Error(`Existing annotation did not contain tag ${tag}`);
|
||||
}
|
||||
|
||||
if (existingAnnotation._deleted) {
|
||||
this.unDeleteAnnotation(existingAnnotation);
|
||||
}
|
||||
|
||||
return existingAnnotation;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @method deleteAnnotations
|
||||
* @param {import('../objects/ObjectAPI').DomainObject[]} existingAnnotation - An array of annotations to delete (set _deleted to true)
|
||||
* @param {DomainObject[]} existingAnnotation - An array of annotations to delete (set _deleted to true)
|
||||
*/
|
||||
deleteAnnotations(annotations) {
|
||||
if (!annotations) {
|
||||
@ -255,7 +256,7 @@ export default class AnnotationAPI extends EventEmitter {
|
||||
|
||||
/**
|
||||
* @method deleteAnnotations
|
||||
* @param {import('../objects/ObjectAPI').DomainObject} existingAnnotation - An annotations to undelete (set _deleted to false)
|
||||
* @param {DomainObject} annotation - An annotation to undelete (set _deleted to false)
|
||||
*/
|
||||
unDeleteAnnotation(annotation) {
|
||||
if (!annotation) {
|
||||
@ -265,6 +266,39 @@ export default class AnnotationAPI extends EventEmitter {
|
||||
this.openmct.objects.mutate(annotation, '_deleted', false);
|
||||
}
|
||||
|
||||
getTagsFromAnnotations(annotations, filterDuplicates = true) {
|
||||
if (!annotations) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let tagsFromAnnotations = annotations.flatMap((annotation) => {
|
||||
if (annotation._deleted) {
|
||||
return [];
|
||||
} else {
|
||||
return annotation.tags;
|
||||
}
|
||||
});
|
||||
|
||||
if (filterDuplicates) {
|
||||
tagsFromAnnotations = tagsFromAnnotations.filter((tag, index, tagArray) => {
|
||||
return tagArray.indexOf(tag) === index;
|
||||
});
|
||||
}
|
||||
|
||||
const fullTagModels = this.#addTagMetaInformationToTags(tagsFromAnnotations);
|
||||
|
||||
return fullTagModels;
|
||||
}
|
||||
|
||||
#addTagMetaInformationToTags(tags) {
|
||||
return tags.map(tagKey => {
|
||||
const tagModel = this.availableTags[tagKey];
|
||||
tagModel.tagID = tagKey;
|
||||
|
||||
return tagModel;
|
||||
});
|
||||
}
|
||||
|
||||
#getMatchingTags(query) {
|
||||
if (!query) {
|
||||
return [];
|
||||
@ -283,12 +317,7 @@ export default class AnnotationAPI extends EventEmitter {
|
||||
|
||||
#addTagMetaInformationToResults(results, matchingTagKeys) {
|
||||
const tagsAddedToResults = results.map(result => {
|
||||
const fullTagModels = result.tags.map(tagKey => {
|
||||
const tagModel = this.availableTags[tagKey];
|
||||
tagModel.tagID = tagKey;
|
||||
|
||||
return tagModel;
|
||||
});
|
||||
const fullTagModels = this.#addTagMetaInformationToTags(result.tags);
|
||||
|
||||
return {
|
||||
fullTagModels,
|
||||
@ -338,6 +367,33 @@ export default class AnnotationAPI extends EventEmitter {
|
||||
return combinedResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* @method #breakApartSeparateTargets
|
||||
* @param {Array} results A set of search results that could have the multiple targets for the same result
|
||||
* @returns {Array} The same set of results, but with each target separated out into its own result
|
||||
*/
|
||||
#breakApartSeparateTargets(results) {
|
||||
const separateResults = [];
|
||||
results.forEach(result => {
|
||||
Object.keys(result.targets).forEach(targetID => {
|
||||
const separatedResult = {
|
||||
...result
|
||||
};
|
||||
separatedResult.targets = {
|
||||
[targetID]: result.targets[targetID]
|
||||
};
|
||||
separatedResult.targetModels = result.targetModels.filter(targetModel => {
|
||||
const targetKeyString = this.openmct.objects.makeKeyString(targetModel.identifier);
|
||||
|
||||
return targetKeyString === targetID;
|
||||
});
|
||||
separateResults.push(separatedResult);
|
||||
});
|
||||
});
|
||||
|
||||
return separateResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* @method searchForTags
|
||||
* @param {String} query A query to match against tags. E.g., "dr" will match the tags "drilling" and "driving"
|
||||
@ -360,7 +416,8 @@ export default class AnnotationAPI extends EventEmitter {
|
||||
const resultsWithValidPath = appliedTargetsModels.filter(result => {
|
||||
return this.openmct.objects.isReachable(result.targetModels?.[0]?.originalPath);
|
||||
});
|
||||
const breakApartSeparateTargets = this.#breakApartSeparateTargets(resultsWithValidPath);
|
||||
|
||||
return resultsWithValidPath;
|
||||
return breakApartSeparateTargets;
|
||||
}
|
||||
}
|
||||
|
@ -108,6 +108,7 @@ describe("The Annotation API", () => {
|
||||
annotationType: openmct.annotation.ANNOTATION_TYPES.NOTEBOOK,
|
||||
tags: ['sometag'],
|
||||
contentText: "fooContext",
|
||||
targetDomainObjects: [mockDomainObject],
|
||||
targets: {'fooTarget': {}}
|
||||
};
|
||||
const annotationObject = await openmct.annotation.create(annotationCreationArguments);
|
||||
@ -124,27 +125,39 @@ describe("The Annotation API", () => {
|
||||
});
|
||||
|
||||
describe("Tagging", () => {
|
||||
let tagCreationArguments;
|
||||
beforeEach(() => {
|
||||
tagCreationArguments = {
|
||||
name: 'Test Annotation',
|
||||
domainObject: mockDomainObject,
|
||||
annotationType: openmct.annotation.ANNOTATION_TYPES.NOTEBOOK,
|
||||
tags: ['aWonderfulTag'],
|
||||
contentText: 'fooContext',
|
||||
targets: {'fooNameSpace:some-object': {entryId: 'fooBarEntry'}},
|
||||
targetDomainObjects: [mockDomainObject]
|
||||
};
|
||||
});
|
||||
it("can create a tag", async () => {
|
||||
const annotationObject = await openmct.annotation.addSingleAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag');
|
||||
const annotationObject = await openmct.annotation.create(tagCreationArguments);
|
||||
expect(annotationObject).toBeDefined();
|
||||
expect(annotationObject.type).toEqual('annotation');
|
||||
expect(annotationObject.tags).toContain('aWonderfulTag');
|
||||
});
|
||||
it("can delete a tag", async () => {
|
||||
const annotationObject = await openmct.annotation.addSingleAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag');
|
||||
const annotationObject = await openmct.annotation.create(tagCreationArguments);
|
||||
expect(annotationObject).toBeDefined();
|
||||
openmct.annotation.deleteAnnotations([annotationObject]);
|
||||
expect(annotationObject._deleted).toBeTrue();
|
||||
});
|
||||
it("throws an error if deleting non-existent tag", async () => {
|
||||
const annotationObject = await openmct.annotation.addSingleAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag');
|
||||
const annotationObject = await openmct.annotation.create(tagCreationArguments);
|
||||
expect(annotationObject).toBeDefined();
|
||||
expect(() => {
|
||||
openmct.annotation.removeAnnotationTag(annotationObject, 'ThisTagShouldNotExist');
|
||||
}).toThrow();
|
||||
});
|
||||
it("can remove all tags", async () => {
|
||||
const annotationObject = await openmct.annotation.addSingleAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag');
|
||||
const annotationObject = await openmct.annotation.create(tagCreationArguments);
|
||||
expect(annotationObject).toBeDefined();
|
||||
expect(() => {
|
||||
openmct.annotation.deleteAnnotations([annotationObject]);
|
||||
@ -152,13 +165,13 @@ describe("The Annotation API", () => {
|
||||
expect(annotationObject._deleted).toBeTrue();
|
||||
});
|
||||
it("can add/delete/add a tag", async () => {
|
||||
let annotationObject = await openmct.annotation.addSingleAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag');
|
||||
let annotationObject = await openmct.annotation.create(tagCreationArguments);
|
||||
expect(annotationObject).toBeDefined();
|
||||
expect(annotationObject.type).toEqual('annotation');
|
||||
expect(annotationObject.tags).toContain('aWonderfulTag');
|
||||
openmct.annotation.deleteAnnotations([annotationObject]);
|
||||
expect(annotationObject._deleted).toBeTrue();
|
||||
annotationObject = await openmct.annotation.addSingleAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag');
|
||||
annotationObject = await openmct.annotation.create(tagCreationArguments);
|
||||
expect(annotationObject).toBeDefined();
|
||||
expect(annotationObject.type).toEqual('annotation');
|
||||
expect(annotationObject.tags).toContain('aWonderfulTag');
|
||||
|
@ -1,47 +1,35 @@
|
||||
import CompositionAPI from './CompositionAPI';
|
||||
import { createOpenMct, resetApplicationState } from '../../utils/testing';
|
||||
import CompositionCollection from './CompositionCollection';
|
||||
|
||||
describe('The Composition API', function () {
|
||||
let publicAPI;
|
||||
let compositionAPI;
|
||||
let topicService;
|
||||
let mutationTopic;
|
||||
|
||||
beforeEach(function () {
|
||||
beforeEach(function (done) {
|
||||
publicAPI = createOpenMct();
|
||||
compositionAPI = publicAPI.composition;
|
||||
|
||||
mutationTopic = jasmine.createSpyObj('mutationTopic', [
|
||||
'listen'
|
||||
]);
|
||||
topicService = jasmine.createSpy('topicService');
|
||||
topicService.and.returnValue(mutationTopic);
|
||||
publicAPI = {};
|
||||
publicAPI.objects = jasmine.createSpyObj('ObjectAPI', [
|
||||
'get',
|
||||
'mutate',
|
||||
'observe',
|
||||
'areIdsEqual'
|
||||
const mockObjectProvider = jasmine.createSpyObj("mock provider", [
|
||||
"create",
|
||||
"update",
|
||||
"get"
|
||||
]);
|
||||
|
||||
publicAPI.objects.areIdsEqual.and.callFake(function (id1, id2) {
|
||||
return id1.namespace === id2.namespace && id1.key === id2.key;
|
||||
mockObjectProvider.create.and.returnValue(Promise.resolve(true));
|
||||
mockObjectProvider.update.and.returnValue(Promise.resolve(true));
|
||||
mockObjectProvider.get.and.callFake((identifier) => {
|
||||
return Promise.resolve({identifier});
|
||||
});
|
||||
|
||||
publicAPI.composition = jasmine.createSpyObj('CompositionAPI', [
|
||||
'checkPolicy'
|
||||
]);
|
||||
publicAPI.composition.checkPolicy.and.returnValue(true);
|
||||
publicAPI.objects.addProvider('test', mockObjectProvider);
|
||||
publicAPI.objects.addProvider('custom', mockObjectProvider);
|
||||
|
||||
publicAPI.objects.eventEmitter = jasmine.createSpyObj('eventemitter', [
|
||||
'on'
|
||||
]);
|
||||
publicAPI.objects.get.and.callFake(function (identifier) {
|
||||
return Promise.resolve({identifier: identifier});
|
||||
});
|
||||
publicAPI.$injector = jasmine.createSpyObj('$injector', [
|
||||
'get'
|
||||
]);
|
||||
publicAPI.$injector.get.and.returnValue(topicService);
|
||||
compositionAPI = new CompositionAPI(publicAPI);
|
||||
publicAPI.on('start', done);
|
||||
publicAPI.startHeadless();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
return resetApplicationState(publicAPI);
|
||||
});
|
||||
|
||||
it('returns falsy if an object does not support composition', function () {
|
||||
@ -106,6 +94,9 @@ describe('The Composition API', function () {
|
||||
let listener;
|
||||
beforeEach(function () {
|
||||
listener = jasmine.createSpy('reorderListener');
|
||||
spyOn(publicAPI.objects, 'mutate');
|
||||
publicAPI.objects.mutate.and.callThrough();
|
||||
|
||||
composition.on('reorder', listener);
|
||||
|
||||
return composition.load();
|
||||
@ -136,18 +127,20 @@ describe('The Composition API', function () {
|
||||
});
|
||||
});
|
||||
it('supports adding an object to composition', function () {
|
||||
let addListener = jasmine.createSpy('addListener');
|
||||
let mockChildObject = {
|
||||
identifier: {
|
||||
key: 'mock-key',
|
||||
namespace: ''
|
||||
}
|
||||
};
|
||||
composition.on('add', addListener);
|
||||
composition.add(mockChildObject);
|
||||
|
||||
expect(domainObject.composition.length).toBe(4);
|
||||
expect(domainObject.composition[3]).toEqual(mockChildObject.identifier);
|
||||
return new Promise((resolve) => {
|
||||
composition.on('add', resolve);
|
||||
composition.add(mockChildObject);
|
||||
}).then(() => {
|
||||
expect(domainObject.composition.length).toBe(4);
|
||||
expect(domainObject.composition[3]).toEqual(mockChildObject.identifier);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -224,7 +224,7 @@ export default class CompositionProvider {
|
||||
* @private
|
||||
* @param {DomainObject} oldDomainObject
|
||||
*/
|
||||
#onMutation(oldDomainObject) {
|
||||
#onMutation(newDomainObject, oldDomainObject) {
|
||||
const id = objectUtils.makeKeyString(oldDomainObject.identifier);
|
||||
const listeners = this.#listeningTo[id];
|
||||
|
||||
@ -232,8 +232,8 @@ export default class CompositionProvider {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldComposition = listeners.composition.map(objectUtils.makeKeyString);
|
||||
const newComposition = oldDomainObject.composition.map(objectUtils.makeKeyString);
|
||||
const oldComposition = oldDomainObject.composition.map(objectUtils.makeKeyString);
|
||||
const newComposition = newDomainObject.composition.map(objectUtils.makeKeyString);
|
||||
|
||||
const added = _.difference(newComposition, oldComposition).map(objectUtils.parseKeyString);
|
||||
const removed = _.difference(oldComposition, newComposition).map(objectUtils.parseKeyString);
|
||||
@ -248,8 +248,6 @@ export default class CompositionProvider {
|
||||
};
|
||||
}
|
||||
|
||||
listeners.composition = newComposition.map(objectUtils.parseKeyString);
|
||||
|
||||
added.forEach(function (addedChild) {
|
||||
listeners.add.forEach(notify(addedChild));
|
||||
});
|
||||
|
@ -99,8 +99,7 @@ export default class DefaultCompositionProvider extends CompositionProvider {
|
||||
objectListeners = this.listeningTo[keyString] = {
|
||||
add: [],
|
||||
remove: [],
|
||||
reorder: [],
|
||||
composition: [].slice.apply(domainObject.composition)
|
||||
reorder: []
|
||||
};
|
||||
}
|
||||
|
||||
@ -172,8 +171,9 @@ export default class DefaultCompositionProvider extends CompositionProvider {
|
||||
*/
|
||||
add(parent, childId) {
|
||||
if (!this.includes(parent, childId)) {
|
||||
parent.composition.push(childId);
|
||||
this.publicAPI.objects.mutate(parent, 'composition', parent.composition);
|
||||
const composition = structuredClone(parent.composition);
|
||||
composition.push(childId);
|
||||
this.publicAPI.objects.mutate(parent, 'composition', composition);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -30,7 +30,7 @@
|
||||
id="fileElem"
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".json"
|
||||
:accept="acceptableFileTypes"
|
||||
style="display:none"
|
||||
>
|
||||
<button
|
||||
@ -72,6 +72,13 @@ export default {
|
||||
},
|
||||
removable() {
|
||||
return (this.fileInfo || this.model.value) && this.model.removable;
|
||||
},
|
||||
acceptableFileTypes() {
|
||||
if (this.model.type) {
|
||||
return this.model.type;
|
||||
}
|
||||
|
||||
return 'application/json';
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@ -80,7 +87,13 @@ export default {
|
||||
methods: {
|
||||
handleFiles() {
|
||||
const fileList = this.$refs.fileInput.files;
|
||||
this.readFile(fileList[0]);
|
||||
const file = fileList[0];
|
||||
|
||||
if (this.acceptableFileTypes === 'application/json') {
|
||||
this.readFile(file);
|
||||
} else {
|
||||
this.handleRawFile(file);
|
||||
}
|
||||
},
|
||||
readFile(file) {
|
||||
const self = this;
|
||||
@ -104,6 +117,21 @@ export default {
|
||||
|
||||
fileReader.readAsText(file);
|
||||
},
|
||||
handleRawFile(file) {
|
||||
const fileInfo = {
|
||||
name: file.name,
|
||||
body: file
|
||||
};
|
||||
|
||||
this.fileInfo = Object.assign({}, fileInfo);
|
||||
|
||||
const data = {
|
||||
model: this.model,
|
||||
value: fileInfo
|
||||
};
|
||||
|
||||
this.$emit('onChange', data);
|
||||
},
|
||||
selectFile() {
|
||||
this.$refs.fileInput.click();
|
||||
},
|
||||
|
@ -75,21 +75,23 @@ class MutableDomainObject {
|
||||
return eventOff;
|
||||
}
|
||||
$set(path, value) {
|
||||
const oldModel = JSON.parse(JSON.stringify(this));
|
||||
const oldValue = _.get(oldModel, path);
|
||||
MutableDomainObject.mutateObject(this, path, value);
|
||||
|
||||
//Emit secret synchronization event first, so that all objects are in sync before subsequent events fired.
|
||||
this._globalEventEmitter.emit(qualifiedEventName(this, '$_synchronize_model'), this);
|
||||
|
||||
//Emit a general "any object" event
|
||||
this._globalEventEmitter.emit(ANY_OBJECT_EVENT, this);
|
||||
this._globalEventEmitter.emit(ANY_OBJECT_EVENT, this, oldModel);
|
||||
//Emit wildcard event, with path so that callback knows what changed
|
||||
this._globalEventEmitter.emit(qualifiedEventName(this, '*'), this, path, value);
|
||||
this._globalEventEmitter.emit(qualifiedEventName(this, '*'), this, path, value, oldModel, oldValue);
|
||||
|
||||
//Emit events specific to properties affected
|
||||
let parentPropertiesList = path.split('.');
|
||||
for (let index = parentPropertiesList.length; index > 0; index--) {
|
||||
let parentPropertyPath = parentPropertiesList.slice(0, index).join('.');
|
||||
this._globalEventEmitter.emit(qualifiedEventName(this, parentPropertyPath), _.get(this, parentPropertyPath));
|
||||
this._globalEventEmitter.emit(qualifiedEventName(this, parentPropertyPath), _.get(this, parentPropertyPath), _.get(oldModel, parentPropertyPath));
|
||||
}
|
||||
|
||||
//TODO: Emit events for listeners of child properties when parent changes.
|
||||
|
@ -189,13 +189,11 @@ export default class ObjectAPI {
|
||||
/**
|
||||
* Get a domain object.
|
||||
*
|
||||
* @method get
|
||||
* @memberof module:openmct.ObjectProvider#
|
||||
* @param {string} key the key for the domain object to load
|
||||
* @param {AbortController.signal} abortSignal (optional) signal to abort fetch requests
|
||||
* @param {boolean} forceRemote defaults to false. If true, will skip cached and
|
||||
* @param {AbortSignal} abortSignal (optional) signal to abort fetch requests
|
||||
* @param {boolean} [forceRemote=false] defaults to false. If true, will skip cached and
|
||||
* dirty/in-transaction objects use and the provider.get method
|
||||
* @returns {Promise} a promise which will resolve when the domain object
|
||||
* @returns {Promise<DomainObject>} a promise which will resolve when the domain object
|
||||
* has been saved, or be rejected if it cannot be saved
|
||||
*/
|
||||
get(identifier, abortSignal, forceRemote = false) {
|
||||
@ -220,31 +218,28 @@ export default class ObjectAPI {
|
||||
const provider = this.getProvider(identifier);
|
||||
|
||||
if (!provider) {
|
||||
throw new Error('No Provider Matched');
|
||||
throw new Error(`No Provider Matched for keyString "${this.makeKeyString(identifier)}}"`);
|
||||
}
|
||||
|
||||
if (!provider.get) {
|
||||
throw new Error('Provider does not support get!');
|
||||
}
|
||||
|
||||
let objectPromise = provider.get(identifier, abortSignal).then(result => {
|
||||
let objectPromise = provider.get(identifier, abortSignal).then(domainObject => {
|
||||
delete this.cache[keystring];
|
||||
domainObject = this.applyGetInterceptors(identifier, domainObject);
|
||||
|
||||
result = this.applyGetInterceptors(identifier, result);
|
||||
if (result.isMutable) {
|
||||
result.$refresh(result);
|
||||
} else {
|
||||
let mutableDomainObject = this.toMutable(result);
|
||||
mutableDomainObject.$refresh(result);
|
||||
if (this.supportsMutation(identifier)) {
|
||||
const mutableDomainObject = this.toMutable(domainObject);
|
||||
mutableDomainObject.$refresh(domainObject);
|
||||
this.destroyMutable(mutableDomainObject);
|
||||
}
|
||||
|
||||
return result;
|
||||
}).catch((result) => {
|
||||
console.warn(`Failed to retrieve ${keystring}:`, result);
|
||||
|
||||
return domainObject;
|
||||
}).catch((error) => {
|
||||
console.warn(`Failed to retrieve ${keystring}:`, error);
|
||||
delete this.cache[keystring];
|
||||
|
||||
result = this.applyGetInterceptors(identifier);
|
||||
const result = this.applyGetInterceptors(identifier);
|
||||
|
||||
return result;
|
||||
});
|
||||
@ -650,7 +645,7 @@ export default class ObjectAPI {
|
||||
* @param {module:openmct.DomainObject} object the object to observe
|
||||
* @param {string} path the property to observe
|
||||
* @param {Function} callback a callback to invoke when new values for
|
||||
* this property are observed
|
||||
* this property are observed.
|
||||
* @method observe
|
||||
* @memberof module:openmct.ObjectAPI#
|
||||
*/
|
||||
@ -740,6 +735,46 @@ export default class ObjectAPI {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and construct an `objectPath` from a `navigationPath`.
|
||||
*
|
||||
* A `navigationPath` is a string of the form `"/browse/<keyString>/<keyString>/..."` that is used
|
||||
* by the Open MCT router to navigate to a specific object.
|
||||
*
|
||||
* Throws an error if the `navigationPath` is malformed.
|
||||
*
|
||||
* @param {string} navigationPath
|
||||
* @returns {DomainObject[]} objectPath
|
||||
*/
|
||||
async getRelativeObjectPath(navigationPath) {
|
||||
if (!navigationPath.startsWith('/browse/')) {
|
||||
throw new Error(`Malformed navigation path: "${navigationPath}"`);
|
||||
}
|
||||
|
||||
navigationPath = navigationPath.replace('/browse/', '');
|
||||
|
||||
if (!navigationPath || navigationPath === '/') {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Remove any query params and split on '/'
|
||||
const keyStrings = navigationPath.split('?')?.[0].split('/');
|
||||
|
||||
if (keyStrings[0] !== 'ROOT') {
|
||||
keyStrings.unshift('ROOT');
|
||||
}
|
||||
|
||||
const objectPath = (await Promise.all(
|
||||
keyStrings.map(
|
||||
keyString => this.supportsMutation(keyString)
|
||||
? this.getMutable(utils.parseKeyString(keyString))
|
||||
: this.get(utils.parseKeyString(keyString))
|
||||
)
|
||||
)).reverse();
|
||||
|
||||
return objectPath;
|
||||
}
|
||||
|
||||
isObjectPathToALink(domainObject, objectPath) {
|
||||
return objectPath !== undefined
|
||||
&& objectPath.length > 1
|
||||
|
@ -399,7 +399,7 @@ describe("The Object API", () => {
|
||||
unlisten = objectAPI.observe(mutableSecondInstance, 'otherAttribute', mutationCallback);
|
||||
objectAPI.mutate(mutable, 'otherAttribute', 'some-new-value');
|
||||
}).then(function () {
|
||||
expect(mutationCallback).toHaveBeenCalledWith('some-new-value');
|
||||
expect(mutationCallback).toHaveBeenCalledWith('some-new-value', 'other-attribute-value');
|
||||
unlisten();
|
||||
});
|
||||
});
|
||||
@ -419,14 +419,20 @@ describe("The Object API", () => {
|
||||
|
||||
objectAPI.mutate(mutable, 'objectAttribute.embeddedObject.embeddedKey', 'updated-embedded-value');
|
||||
}).then(function () {
|
||||
expect(embeddedKeyCallback).toHaveBeenCalledWith('updated-embedded-value');
|
||||
expect(embeddedKeyCallback).toHaveBeenCalledWith('updated-embedded-value', 'embedded-value');
|
||||
expect(embeddedObjectCallback).toHaveBeenCalledWith({
|
||||
embeddedKey: 'updated-embedded-value'
|
||||
}, {
|
||||
embeddedKey: 'embedded-value'
|
||||
});
|
||||
expect(objectAttributeCallback).toHaveBeenCalledWith({
|
||||
embeddedObject: {
|
||||
embeddedKey: 'updated-embedded-value'
|
||||
}
|
||||
}, {
|
||||
embeddedObject: {
|
||||
embeddedKey: 'embedded-value'
|
||||
}
|
||||
});
|
||||
|
||||
listeners.forEach(listener => listener());
|
||||
|
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="c-overlay">
|
||||
<div class="c-overlay js-overlay">
|
||||
<div
|
||||
class="c-overlay__blocker"
|
||||
@click="destroy"
|
||||
@ -26,7 +26,7 @@
|
||||
v-for="(button, index) in buttons"
|
||||
ref="buttons"
|
||||
:key="index"
|
||||
class="c-button"
|
||||
class="c-button js-overlay__button"
|
||||
tabindex="0"
|
||||
:class="{'c-button--major': focusIndex===index}"
|
||||
@focus="focusIndex=index"
|
||||
|
@ -36,6 +36,7 @@ export default class TelemetryAPI {
|
||||
this.formatMapCache = new WeakMap();
|
||||
this.formatters = new Map();
|
||||
this.limitProviders = [];
|
||||
this.stalenessProviders = [];
|
||||
this.metadataCache = new WeakMap();
|
||||
this.metadataProviders = [new DefaultMetadataProvider(this.openmct)];
|
||||
this.noRequestProviderForAllObjects = false;
|
||||
@ -114,6 +115,10 @@ export default class TelemetryAPI {
|
||||
if (provider.supportsLimits) {
|
||||
this.limitProviders.unshift(provider);
|
||||
}
|
||||
|
||||
if (provider.supportsStaleness) {
|
||||
this.stalenessProviders.unshift(provider);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -125,7 +130,7 @@ export default class TelemetryAPI {
|
||||
return provider.supportsSubscribe.apply(provider, args);
|
||||
}
|
||||
|
||||
return this.subscriptionProviders.filter(supportsDomainObject)[0];
|
||||
return this.subscriptionProviders.find(supportsDomainObject);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -138,25 +143,25 @@ export default class TelemetryAPI {
|
||||
return provider.supportsRequest.apply(provider, args);
|
||||
}
|
||||
|
||||
return this.requestProviders.filter(supportsDomainObject)[0];
|
||||
return this.requestProviders.find(supportsDomainObject);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
#findMetadataProvider(domainObject) {
|
||||
return this.metadataProviders.filter(function (p) {
|
||||
return p.supportsMetadata(domainObject);
|
||||
})[0];
|
||||
return this.metadataProviders.find((provider) => {
|
||||
return provider.supportsMetadata(domainObject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
#findLimitEvaluator(domainObject) {
|
||||
return this.limitProviders.filter(function (p) {
|
||||
return p.supportsLimits(domainObject);
|
||||
})[0];
|
||||
return this.limitProviders.find((provider) => {
|
||||
return provider.supportsLimits(domainObject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -351,6 +356,101 @@ export default class TelemetryAPI {
|
||||
}.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to staleness updates for a specific domain object.
|
||||
* The callback will be called whenever staleness changes.
|
||||
*
|
||||
* @method subscribeToStaleness
|
||||
* @memberof module:openmct.TelemetryAPI~StalenessProvider#
|
||||
* @param {module:openmct.DomainObject} domainObject the object
|
||||
* to watch for staleness updates
|
||||
* @param {Function} callback the callback to invoke with staleness data,
|
||||
* as it is received: ex.
|
||||
* {
|
||||
* isStale: <Boolean>,
|
||||
* timestamp: <timestamp>
|
||||
* }
|
||||
* @returns {Function} a function which may be called to terminate
|
||||
* the subscription to staleness updates
|
||||
*/
|
||||
subscribeToStaleness(domainObject, callback) {
|
||||
const provider = this.#findStalenessProvider(domainObject);
|
||||
|
||||
if (!this.stalenessSubscriberCache) {
|
||||
this.stalenessSubscriberCache = {};
|
||||
}
|
||||
|
||||
const keyString = objectUtils.makeKeyString(domainObject.identifier);
|
||||
let stalenessSubscriber = this.stalenessSubscriberCache[keyString];
|
||||
|
||||
if (!stalenessSubscriber) {
|
||||
stalenessSubscriber = this.stalenessSubscriberCache[keyString] = {
|
||||
callbacks: [callback]
|
||||
};
|
||||
if (provider) {
|
||||
stalenessSubscriber.unsubscribe = provider
|
||||
.subscribeToStaleness(domainObject, (stalenessResponse) => {
|
||||
stalenessSubscriber.callbacks.forEach((cb) => {
|
||||
cb(stalenessResponse);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
stalenessSubscriber.unsubscribe = () => {};
|
||||
}
|
||||
} else {
|
||||
stalenessSubscriber.callbacks.push(callback);
|
||||
}
|
||||
|
||||
return function unsubscribe() {
|
||||
stalenessSubscriber.callbacks = stalenessSubscriber.callbacks.filter((cb) => {
|
||||
return cb !== callback;
|
||||
});
|
||||
if (stalenessSubscriber.callbacks.length === 0) {
|
||||
stalenessSubscriber.unsubscribe();
|
||||
delete this.stalenessSubscriberCache[keyString];
|
||||
}
|
||||
}.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request telemetry staleness for a domain object.
|
||||
*
|
||||
* @method isStale
|
||||
* @memberof module:openmct.TelemetryAPI~StalenessProvider#
|
||||
* @param {module:openmct.DomainObject} domainObject the object
|
||||
* which has associated telemetry staleness
|
||||
* @returns {Promise.<StalenessResponseObject>} a promise for a StalenessResponseObject
|
||||
* or undefined if no provider exists
|
||||
*/
|
||||
async isStale(domainObject) {
|
||||
const provider = this.#findStalenessProvider(domainObject);
|
||||
|
||||
if (!provider) {
|
||||
return;
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
const options = { signal: abortController.signal };
|
||||
this.requestAbortControllers.add(abortController);
|
||||
|
||||
try {
|
||||
const staleness = await provider.isStale(domainObject, options);
|
||||
|
||||
return staleness;
|
||||
} finally {
|
||||
this.requestAbortControllers.delete(abortController);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
#findStalenessProvider(domainObject) {
|
||||
return this.stalenessProviders.find((provider) => {
|
||||
return provider.supportsStaleness(domainObject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get telemetry metadata for a given domain object. Returns a telemetry
|
||||
* metadata manager which provides methods for interrogating telemetry
|
||||
@ -661,6 +761,29 @@ export default class TelemetryAPI {
|
||||
* @memberof module:openmct.TelemetryAPI~
|
||||
*/
|
||||
|
||||
/**
|
||||
* Provides telemetry staleness data. To subscribe to telemetry stalenes,
|
||||
* new StalenessProvider implementations should be
|
||||
* [registered]{@link module:openmct.TelemetryAPI#addProvider}.
|
||||
*
|
||||
* @interface StalenessProvider
|
||||
* @property {function} supportsStaleness receieves a domainObject and
|
||||
* returns a boolean to indicate it will provide staleness
|
||||
* @property {function} subscribeToStaleness receieves a domainObject to
|
||||
* be subscribed to and a callback to invoke with a StalenessResponseObject
|
||||
* @property {function} isStale an asynchronous method called with a domainObject
|
||||
* and an options object which currently has an abort signal, ex.
|
||||
* { signal: <AbortController.signal> }
|
||||
* this method should return a current StalenessResponseObject
|
||||
* @memberof module:openmct.TelemetryAPI~
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} StalenessResponseObject
|
||||
* @property {Boolean} isStale boolean representing the staleness state
|
||||
* @property {Number} timestamp Unix timestamp in milliseconds
|
||||
*/
|
||||
|
||||
/**
|
||||
* An interface for retrieving telemetry data associated with a domain
|
||||
* object.
|
||||
|
@ -180,6 +180,7 @@ export default class TelemetryCollection extends EventEmitter {
|
||||
let beforeStartOfBounds;
|
||||
let afterEndOfBounds;
|
||||
let added = [];
|
||||
let addedIndices = [];
|
||||
|
||||
// loop through, sort and dedupe
|
||||
for (let datum of data) {
|
||||
@ -212,6 +213,7 @@ export default class TelemetryCollection extends EventEmitter {
|
||||
let index = endIndex || startIndex;
|
||||
|
||||
this.boundedTelemetry.splice(index, 0, datum);
|
||||
addedIndices.push(index);
|
||||
added.push(datum);
|
||||
}
|
||||
|
||||
@ -230,7 +232,7 @@ export default class TelemetryCollection extends EventEmitter {
|
||||
this.emit('add', this.boundedTelemetry);
|
||||
}
|
||||
} else {
|
||||
this.emit('add', added);
|
||||
this.emit('add', added, addedIndices);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -330,7 +332,8 @@ export default class TelemetryCollection extends EventEmitter {
|
||||
this.boundedTelemetry = added;
|
||||
}
|
||||
|
||||
this.emit('add', added);
|
||||
// Assumption is that added will be of length 1 here, so just send the last index of the boundedTelemetry in the add event
|
||||
this.emit('add', added, [this.boundedTelemetry.length]);
|
||||
}
|
||||
} else {
|
||||
// user bounds change, reset
|
||||
|
@ -32,14 +32,18 @@ class IndependentTimeContext extends TimeContext {
|
||||
this.openmct = openmct;
|
||||
this.unlisteners = [];
|
||||
this.globalTimeContext = globalTimeContext;
|
||||
this.upstreamTimeContext = undefined;
|
||||
// We always start with the global time context.
|
||||
// This upstream context will be undefined when an independent time context is added later.
|
||||
this.upstreamTimeContext = this.globalTimeContext;
|
||||
this.objectPath = objectPath;
|
||||
this.refreshContext = this.refreshContext.bind(this);
|
||||
this.resetContext = this.resetContext.bind(this);
|
||||
this.removeIndependentContext = this.removeIndependentContext.bind(this);
|
||||
|
||||
this.refreshContext();
|
||||
|
||||
this.globalTimeContext.on('refreshContext', this.refreshContext);
|
||||
this.globalTimeContext.on('removeOwnContext', this.removeIndependentContext);
|
||||
}
|
||||
|
||||
bounds(newBounds) {
|
||||
@ -202,16 +206,16 @@ class IndependentTimeContext extends TimeContext {
|
||||
}
|
||||
|
||||
getUpstreamContext() {
|
||||
const objectKey = this.openmct.objects.makeKeyString(this.objectPath[this.objectPath.length - 1].identifier);
|
||||
const doesObjectHaveTimeContext = this.globalTimeContext.independentContexts.get(objectKey);
|
||||
if (doesObjectHaveTimeContext) {
|
||||
// If a view has an independent context, don't return an upstream context
|
||||
// Be aware that when a new independent time context is created, we assign the global context as default
|
||||
if (this.hasOwnContext()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let timeContext = this.globalTimeContext;
|
||||
this.objectPath.some((item, index) => {
|
||||
const key = this.openmct.objects.makeKeyString(item.identifier);
|
||||
//last index is the view object itself
|
||||
// we're only interested in parents, not self, so index > 0
|
||||
const itemContext = this.globalTimeContext.independentContexts.get(key);
|
||||
if (index > 0 && itemContext && itemContext.hasOwnContext()) {
|
||||
//upstream time context
|
||||
@ -225,6 +229,43 @@ class IndependentTimeContext extends TimeContext {
|
||||
|
||||
return timeContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the time context of a view to follow any upstream time contexts as necessary (defaulting to the global context)
|
||||
* This needs to be separate from refreshContext
|
||||
*/
|
||||
removeIndependentContext(viewKey) {
|
||||
const key = this.openmct.objects.makeKeyString(this.objectPath[0].identifier);
|
||||
if (viewKey && key === viewKey) {
|
||||
//this is necessary as the upstream context gets reassigned after this
|
||||
this.stopFollowingTimeContext();
|
||||
|
||||
let timeContext = this.globalTimeContext;
|
||||
|
||||
this.objectPath.some((item, index) => {
|
||||
const objectKey = this.openmct.objects.makeKeyString(item.identifier);
|
||||
// we're only interested in any parents, not self, so index > 0
|
||||
const itemContext = this.globalTimeContext.independentContexts.get(objectKey);
|
||||
if (index > 0 && itemContext && itemContext.hasOwnContext()) {
|
||||
//upstream time context
|
||||
timeContext = itemContext;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
this.upstreamTimeContext = timeContext;
|
||||
|
||||
this.followTimeContext();
|
||||
|
||||
// Emit bounds so that views that are changing context get the upstream bounds
|
||||
this.emit('bounds', this.bounds());
|
||||
// now that the view's context is set, tell others to check theirs in case they were following this view's context.
|
||||
this.globalTimeContext.emit('refreshContext', viewKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default IndependentTimeContext;
|
||||
|
@ -149,7 +149,7 @@ class TimeAPI extends GlobalTimeContext {
|
||||
|
||||
return () => {
|
||||
//follow any upstream time context
|
||||
this.emit('refreshContext');
|
||||
this.emit('removeOwnContext', key);
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -93,21 +93,82 @@ describe("The Independent Time API", function () {
|
||||
});
|
||||
|
||||
it("follows a parent time context given the objectPath", () => {
|
||||
let timeContext = api.getContextForView([{
|
||||
api.getContextForView([{
|
||||
identifier: {
|
||||
namespace: '',
|
||||
key: 'blah'
|
||||
}
|
||||
}]);
|
||||
let destroyTimeContext = api.addIndependentContext('blah', independentBounds);
|
||||
let timeContext = api.getContextForView([{
|
||||
identifier: {
|
||||
namespace: '',
|
||||
key: domainObjectKey
|
||||
}
|
||||
}, {
|
||||
identifier: {
|
||||
namespace: '',
|
||||
key: 'blah'
|
||||
}
|
||||
}]);
|
||||
expect(timeContext.bounds()).toEqual(independentBounds);
|
||||
destroyTimeContext();
|
||||
expect(timeContext.bounds()).toEqual(bounds);
|
||||
});
|
||||
|
||||
it("uses an object's independent time context if the parent doesn't have one", () => {
|
||||
const domainObjectKey2 = `${domainObjectKey}-2`;
|
||||
const domainObjectKey3 = `${domainObjectKey}-3`;
|
||||
let timeContext = api.getContextForView([{
|
||||
identifier: {
|
||||
namespace: '',
|
||||
key: domainObjectKey
|
||||
}
|
||||
}]);
|
||||
let destroyTimeContext = api.addIndependentContext('blah', independentBounds);
|
||||
let timeContext2 = api.getContextForView([{
|
||||
identifier: {
|
||||
namespace: '',
|
||||
key: domainObjectKey2
|
||||
}
|
||||
}]);
|
||||
let timeContext3 = api.getContextForView([{
|
||||
identifier: {
|
||||
namespace: '',
|
||||
key: domainObjectKey3
|
||||
}
|
||||
}]);
|
||||
// all bounds follow global time context
|
||||
expect(timeContext.bounds()).toEqual(bounds);
|
||||
expect(timeContext2.bounds()).toEqual(bounds);
|
||||
expect(timeContext3.bounds()).toEqual(bounds);
|
||||
// only first item has own context
|
||||
let destroyTimeContext = api.addIndependentContext(domainObjectKey, independentBounds);
|
||||
expect(timeContext.bounds()).toEqual(independentBounds);
|
||||
expect(timeContext2.bounds()).toEqual(bounds);
|
||||
expect(timeContext3.bounds()).toEqual(bounds);
|
||||
// first and second item have own context
|
||||
let destroyTimeContext2 = api.addIndependentContext(domainObjectKey2, independentBounds);
|
||||
expect(timeContext.bounds()).toEqual(independentBounds);
|
||||
expect(timeContext2.bounds()).toEqual(independentBounds);
|
||||
expect(timeContext3.bounds()).toEqual(bounds);
|
||||
// all items have own time context
|
||||
let destroyTimeContext3 = api.addIndependentContext(domainObjectKey3, independentBounds);
|
||||
expect(timeContext.bounds()).toEqual(independentBounds);
|
||||
expect(timeContext2.bounds()).toEqual(independentBounds);
|
||||
expect(timeContext3.bounds()).toEqual(independentBounds);
|
||||
//remove own contexts one at a time - should revert to global time context
|
||||
destroyTimeContext();
|
||||
expect(timeContext.bounds()).toEqual(bounds);
|
||||
expect(timeContext2.bounds()).toEqual(independentBounds);
|
||||
expect(timeContext3.bounds()).toEqual(independentBounds);
|
||||
destroyTimeContext2();
|
||||
expect(timeContext.bounds()).toEqual(bounds);
|
||||
expect(timeContext2.bounds()).toEqual(bounds);
|
||||
expect(timeContext3.bounds()).toEqual(independentBounds);
|
||||
destroyTimeContext3();
|
||||
expect(timeContext.bounds()).toEqual(bounds);
|
||||
expect(timeContext2.bounds()).toEqual(bounds);
|
||||
expect(timeContext3.bounds()).toEqual(bounds);
|
||||
});
|
||||
|
||||
it("Allows setting of valid bounds", function () {
|
||||
|
@ -291,5 +291,6 @@ export default class StatusAPI extends EventEmitter {
|
||||
* The Status type
|
||||
* @typedef {Object} Status
|
||||
* @property {String} key - A unique identifier for this status
|
||||
* @property {Number} label - A human readable label for this status
|
||||
* @property {String} label - A human readable label for this status
|
||||
* @property {Number} timestamp - The time that the status was set.
|
||||
*/
|
||||
|
@ -29,7 +29,7 @@
|
||||
<td class="js-second-data">{{ formattedTimestamp }}</td>
|
||||
<td
|
||||
class="js-third-data"
|
||||
:class="valueClass"
|
||||
:class="valueClasses"
|
||||
>{{ value }}</td>
|
||||
<td
|
||||
v-if="hasUnits"
|
||||
@ -63,6 +63,12 @@ export default {
|
||||
hasUnits: {
|
||||
type: Boolean,
|
||||
requred: true
|
||||
},
|
||||
isStale: {
|
||||
type: Boolean,
|
||||
default() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@ -81,14 +87,22 @@ export default {
|
||||
|
||||
return this.formats[this.valueKey].format(this.datum);
|
||||
},
|
||||
valueClass() {
|
||||
if (!this.datum) {
|
||||
return '';
|
||||
valueClasses() {
|
||||
let classes = [];
|
||||
|
||||
if (this.isStale) {
|
||||
classes.push('is-stale');
|
||||
}
|
||||
|
||||
const limit = this.limitEvaluator.evaluate(this.datum, this.valueMetadata);
|
||||
if (this.datum) {
|
||||
const limit = this.limitEvaluator.evaluate(this.datum, this.valueMetadata);
|
||||
|
||||
return limit ? limit.cssClass : '';
|
||||
if (limit) {
|
||||
classes.push(limit.cssClass);
|
||||
}
|
||||
}
|
||||
|
||||
return classes;
|
||||
|
||||
},
|
||||
formattedTimestamp() {
|
||||
|
@ -21,7 +21,10 @@
|
||||
*****************************************************************************/
|
||||
|
||||
<template>
|
||||
<div class="c-lad-table-wrapper u-style-receiver js-style-receiver">
|
||||
<div
|
||||
class="c-lad-table-wrapper u-style-receiver js-style-receiver"
|
||||
:class="staleClass"
|
||||
>
|
||||
<table class="c-table c-lad-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@ -38,6 +41,7 @@
|
||||
:domain-object="ladRow.domainObject"
|
||||
:path-to-table="objectPath"
|
||||
:has-units="hasUnits"
|
||||
:is-stale="staleObjects.includes(ladRow.key)"
|
||||
@rowContextClick="updateViewContext"
|
||||
/>
|
||||
</tbody>
|
||||
@ -46,7 +50,9 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import LadRow from './LADRow.vue';
|
||||
import StalenessUtils from '@/utils/staleness';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@ -66,7 +72,8 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
items: [],
|
||||
viewContext: {}
|
||||
viewContext: {},
|
||||
staleObjects: []
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@ -80,6 +87,13 @@ export default {
|
||||
});
|
||||
|
||||
return itemsWithUnits.length !== 0;
|
||||
},
|
||||
staleClass() {
|
||||
if (this.staleObjects.length !== 0) {
|
||||
return 'is-stale';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@ -88,11 +102,17 @@ export default {
|
||||
this.composition.on('remove', this.removeItem);
|
||||
this.composition.on('reorder', this.reorder);
|
||||
this.composition.load();
|
||||
this.stalenessSubscription = {};
|
||||
},
|
||||
destroyed() {
|
||||
this.composition.off('add', this.addItem);
|
||||
this.composition.off('remove', this.removeItem);
|
||||
this.composition.off('reorder', this.reorder);
|
||||
|
||||
Object.values(this.stalenessSubscription).forEach(stalenessSubscription => {
|
||||
stalenessSubscription.unsubscribe();
|
||||
stalenessSubscription.stalenessUtils.destroy();
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
addItem(domainObject) {
|
||||
@ -101,23 +121,55 @@ export default {
|
||||
item.key = this.openmct.objects.makeKeyString(domainObject.identifier);
|
||||
|
||||
this.items.push(item);
|
||||
|
||||
this.stalenessSubscription[item.key] = {};
|
||||
this.stalenessSubscription[item.key].stalenessUtils = new StalenessUtils(this.openmct, domainObject);
|
||||
this.openmct.telemetry.isStale(domainObject).then((stalenessResponse) => {
|
||||
if (stalenessResponse !== undefined) {
|
||||
this.handleStaleness(item.key, stalenessResponse);
|
||||
}
|
||||
});
|
||||
const stalenessSubscription = this.openmct.telemetry.subscribeToStaleness(domainObject, (stalenessResponse) => {
|
||||
this.handleStaleness(item.key, stalenessResponse);
|
||||
});
|
||||
|
||||
this.stalenessSubscription[item.key].unsubscribe = stalenessSubscription;
|
||||
},
|
||||
removeItem(identifier) {
|
||||
let index = this.items.findIndex(item => this.openmct.objects.makeKeyString(identifier) === item.key);
|
||||
const SKIP_CHECK = true;
|
||||
const keystring = this.openmct.objects.makeKeyString(identifier);
|
||||
const index = this.items.findIndex(item => keystring === item.key);
|
||||
|
||||
this.items.splice(index, 1);
|
||||
|
||||
this.stalenessSubscription[keystring].unsubscribe();
|
||||
this.handleStaleness(keystring, { isStale: false }, SKIP_CHECK);
|
||||
},
|
||||
reorder(reorderPlan) {
|
||||
let oldItems = this.items.slice();
|
||||
const oldItems = this.items.slice();
|
||||
reorderPlan.forEach((reorderEvent) => {
|
||||
this.$set(this.items, reorderEvent.newIndex, oldItems[reorderEvent.oldIndex]);
|
||||
});
|
||||
},
|
||||
metadataHasUnits(valueMetadatas) {
|
||||
let metadataWithUnits = valueMetadatas.filter(metadatum => metadatum.unit);
|
||||
const metadataWithUnits = valueMetadatas.filter(metadatum => metadatum.unit);
|
||||
|
||||
return metadataWithUnits.length > 0;
|
||||
},
|
||||
handleStaleness(id, stalenessResponse, skipCheck = false) {
|
||||
if (skipCheck || this.stalenessSubscription[id].stalenessUtils.shouldUpdateStaleness(stalenessResponse)) {
|
||||
const index = this.staleObjects.indexOf(id);
|
||||
if (stalenessResponse.isStale) {
|
||||
if (index === -1) {
|
||||
this.staleObjects.push(id);
|
||||
}
|
||||
} else {
|
||||
if (index !== -1) {
|
||||
this.staleObjects.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
updateViewContext(rowContext) {
|
||||
this.viewContext.row = rowContext;
|
||||
},
|
||||
|
@ -21,42 +21,50 @@
|
||||
*****************************************************************************/
|
||||
|
||||
<template>
|
||||
<table class="c-table c-lad-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Timestamp</th>
|
||||
<th>Value</th>
|
||||
<th v-if="hasUnits">Unit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template
|
||||
v-for="ladTable in ladTableObjects"
|
||||
>
|
||||
<tr
|
||||
:key="ladTable.key"
|
||||
class="c-table__group-header js-lad-table-set__table-headers"
|
||||
>
|
||||
<td colspan="10">
|
||||
{{ ladTable.domainObject.name }}
|
||||
</td>
|
||||
<div
|
||||
class="c-lad-table-wrapper u-style-receiver js-style-receiver"
|
||||
:class="staleClass"
|
||||
>
|
||||
<table class="c-table c-lad-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Timestamp</th>
|
||||
<th>Value</th>
|
||||
<th v-if="hasUnits">Unit</th>
|
||||
</tr>
|
||||
<lad-row
|
||||
v-for="ladRow in ladTelemetryObjects[ladTable.key]"
|
||||
:key="ladRow.key"
|
||||
:domain-object="ladRow.domainObject"
|
||||
:path-to-table="ladTable.objectPath"
|
||||
:has-units="hasUnits"
|
||||
@rowContextClick="updateViewContext"
|
||||
/>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template
|
||||
v-for="ladTable in ladTableObjects"
|
||||
>
|
||||
<tr
|
||||
:key="ladTable.key"
|
||||
class="c-table__group-header js-lad-table-set__table-headers"
|
||||
>
|
||||
<td colspan="10">
|
||||
{{ ladTable.domainObject.name }}
|
||||
</td>
|
||||
</tr>
|
||||
<lad-row
|
||||
v-for="ladRow in ladTelemetryObjects[ladTable.key]"
|
||||
:key="combineKeys(ladTable.key, ladRow.key)"
|
||||
:domain-object="ladRow.domainObject"
|
||||
:path-to-table="ladTable.objectPath"
|
||||
:has-units="hasUnits"
|
||||
:is-stale="staleObjects.includes(combineKeys(ladTable.key, ladRow.key))"
|
||||
@rowContextClick="updateViewContext"
|
||||
/>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import LadRow from './LADRow.vue';
|
||||
import StalenessUtils from '@/utils/staleness';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@ -74,7 +82,8 @@ export default {
|
||||
ladTableObjects: [],
|
||||
ladTelemetryObjects: {},
|
||||
compositions: [],
|
||||
viewContext: {}
|
||||
viewContext: {},
|
||||
staleObjects: []
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@ -95,6 +104,13 @@ export default {
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
staleClass() {
|
||||
if (this.staleObjects.length !== 0) {
|
||||
return 'is-stale';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@ -103,6 +119,8 @@ export default {
|
||||
this.composition.on('remove', this.removeLadTable);
|
||||
this.composition.on('reorder', this.reorderLadTables);
|
||||
this.composition.load();
|
||||
|
||||
this.stalenessSubscription = {};
|
||||
},
|
||||
destroyed() {
|
||||
this.composition.off('add', this.addLadTable);
|
||||
@ -112,6 +130,11 @@ export default {
|
||||
c.composition.off('add', c.addCallback);
|
||||
c.composition.off('remove', c.removeCallback);
|
||||
});
|
||||
|
||||
Object.values(this.stalenessSubscription).forEach(stalenessSubscription => {
|
||||
stalenessSubscription.unsubscribe();
|
||||
stalenessSubscription.stalenessUtils.destroy();
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
addLadTable(domainObject) {
|
||||
@ -137,10 +160,18 @@ export default {
|
||||
removeCallback
|
||||
});
|
||||
},
|
||||
combineKeys(ladKey, telemetryObjectKey) {
|
||||
return `${ladKey}-${telemetryObjectKey}`;
|
||||
},
|
||||
removeLadTable(identifier) {
|
||||
let index = this.ladTableObjects.findIndex(ladTable => this.openmct.objects.makeKeyString(identifier) === ladTable.key);
|
||||
let ladTable = this.ladTableObjects[index];
|
||||
|
||||
this.ladTelemetryObjects[ladTable.key].forEach(telemetryObject => {
|
||||
let combinedKey = this.combineKeys(ladTable.key, telemetryObject.key);
|
||||
this.unwatchStaleness(combinedKey);
|
||||
});
|
||||
|
||||
this.$delete(this.ladTelemetryObjects, ladTable.key);
|
||||
this.ladTableObjects.splice(index, 1);
|
||||
},
|
||||
@ -155,23 +186,61 @@ export default {
|
||||
let telemetryObject = {};
|
||||
telemetryObject.key = this.openmct.objects.makeKeyString(domainObject.identifier);
|
||||
telemetryObject.domainObject = domainObject;
|
||||
const combinedKey = this.combineKeys(ladTable.key, telemetryObject.key);
|
||||
|
||||
let telemetryObjects = this.ladTelemetryObjects[ladTable.key];
|
||||
const telemetryObjects = this.ladTelemetryObjects[ladTable.key];
|
||||
telemetryObjects.push(telemetryObject);
|
||||
|
||||
this.$set(this.ladTelemetryObjects, ladTable.key, telemetryObjects);
|
||||
|
||||
this.stalenessSubscription[combinedKey] = {};
|
||||
this.stalenessSubscription[combinedKey].stalenessUtils = new StalenessUtils(this.openmct, domainObject);
|
||||
|
||||
this.openmct.telemetry.isStale(domainObject).then((stalenessResponse) => {
|
||||
if (stalenessResponse !== undefined) {
|
||||
this.handleStaleness(combinedKey, stalenessResponse);
|
||||
}
|
||||
});
|
||||
const stalenessSubscription = this.openmct.telemetry.subscribeToStaleness(domainObject, (stalenessResponse) => {
|
||||
this.handleStaleness(combinedKey, stalenessResponse);
|
||||
});
|
||||
|
||||
this.stalenessSubscription[combinedKey].unsubscribe = stalenessSubscription;
|
||||
};
|
||||
},
|
||||
removeTelemetryObject(ladTable) {
|
||||
return (identifier) => {
|
||||
let telemetryObjects = this.ladTelemetryObjects[ladTable.key];
|
||||
let index = telemetryObjects.findIndex(telemetryObject => this.openmct.objects.makeKeyString(identifier) === telemetryObject.key);
|
||||
const keystring = this.openmct.objects.makeKeyString(identifier);
|
||||
const telemetryObjects = this.ladTelemetryObjects[ladTable.key];
|
||||
const combinedKey = this.combineKeys(ladTable.key, keystring);
|
||||
let index = telemetryObjects.findIndex(telemetryObject => keystring === telemetryObject.key);
|
||||
|
||||
this.unwatchStaleness(combinedKey);
|
||||
|
||||
telemetryObjects.splice(index, 1);
|
||||
|
||||
this.$set(this.ladTelemetryObjects, ladTable.key, telemetryObjects);
|
||||
};
|
||||
},
|
||||
unwatchStaleness(combinedKey) {
|
||||
const SKIP_CHECK = true;
|
||||
|
||||
this.stalenessSubscription[combinedKey].unsubscribe();
|
||||
this.stalenessSubscription[combinedKey].stalenessUtils.destroy();
|
||||
this.handleStaleness(combinedKey, { isStale: false }, SKIP_CHECK);
|
||||
|
||||
delete this.stalenessSubscription[combinedKey];
|
||||
},
|
||||
handleStaleness(combinedKey, stalenessResponse, skipCheck = false) {
|
||||
if (skipCheck || this.stalenessSubscription[combinedKey].stalenessUtils.shouldUpdateStaleness(stalenessResponse)) {
|
||||
const index = this.staleObjects.indexOf(combinedKey);
|
||||
const foundStaleObject = index > -1;
|
||||
if (stalenessResponse.isStale && !foundStaleObject) {
|
||||
this.staleObjects.push(combinedKey);
|
||||
} else if (!stalenessResponse.isStale && foundStaleObject) {
|
||||
this.staleObjects.splice(index, 1);
|
||||
}
|
||||
}
|
||||
},
|
||||
updateViewContext(rowContext) {
|
||||
this.viewContext.row = rowContext;
|
||||
},
|
||||
|
@ -26,7 +26,7 @@ import TelemetryCriterion from "./criterion/TelemetryCriterion";
|
||||
import { evaluateResults } from './utils/evaluator';
|
||||
import { getLatestTimestamp } from './utils/time';
|
||||
import AllTelemetryCriterion from "./criterion/AllTelemetryCriterion";
|
||||
import {TRIGGER_CONJUNCTION, TRIGGER_LABEL} from "./utils/constants";
|
||||
import { TRIGGER_CONJUNCTION, TRIGGER_LABEL } from "./utils/constants";
|
||||
|
||||
/*
|
||||
* conditionConfiguration = {
|
||||
@ -160,7 +160,8 @@ export default class Condition extends EventEmitter {
|
||||
}
|
||||
|
||||
criterion.on('criterionUpdated', (obj) => this.handleCriterionUpdated(obj));
|
||||
criterion.on('telemetryIsStale', (obj) => this.handleStaleCriterion(obj));
|
||||
criterion.on('telemetryIsOld', (obj) => this.handleOldTelemetryCriterion(obj));
|
||||
criterion.on('telemetryStaleness', () => this.handleTelemetryStaleness());
|
||||
if (!this.criteria) {
|
||||
this.criteria = [];
|
||||
}
|
||||
@ -191,12 +192,14 @@ export default class Condition extends EventEmitter {
|
||||
const newCriterionConfiguration = this.generateCriterion(criterionConfiguration);
|
||||
let newCriterion = new TelemetryCriterion(newCriterionConfiguration, this.openmct);
|
||||
newCriterion.on('criterionUpdated', (obj) => this.handleCriterionUpdated(obj));
|
||||
newCriterion.on('telemetryIsStale', (obj) => this.handleStaleCriterion(obj));
|
||||
newCriterion.on('telemetryIsOld', (obj) => this.handleOldTelemetryCriterion(obj));
|
||||
newCriterion.on('telemetryStaleness', () => this.handleTelemetryStaleness());
|
||||
|
||||
let criterion = found.item;
|
||||
criterion.unsubscribe();
|
||||
criterion.off('criterionUpdated', (obj) => this.handleCriterionUpdated(obj));
|
||||
criterion.off('telemetryIsStale', (obj) => this.handleStaleCriterion(obj));
|
||||
criterion.off('telemetryIsOld', (obj) => this.handleOldTelemetryCriterion(obj));
|
||||
newCriterion.off('telemetryStaleness', () => this.handleTelemetryStaleness());
|
||||
this.criteria.splice(found.index, 1, newCriterion);
|
||||
}
|
||||
}
|
||||
@ -205,12 +208,9 @@ export default class Condition extends EventEmitter {
|
||||
let found = this.findCriterion(id);
|
||||
if (found) {
|
||||
let criterion = found.item;
|
||||
criterion.off('criterionUpdated', (obj) => {
|
||||
this.handleCriterionUpdated(obj);
|
||||
});
|
||||
criterion.off('telemetryIsStale', (obj) => {
|
||||
this.handleStaleCriterion(obj);
|
||||
});
|
||||
criterion.off('criterionUpdated', (obj) => this.handleCriterionUpdated(obj));
|
||||
criterion.off('telemetryIsOld', (obj) => this.handleOldTelemetryCriterion(obj));
|
||||
criterion.off('telemetryStaleness', () => this.handleTelemetryStaleness());
|
||||
criterion.destroy();
|
||||
this.criteria.splice(found.index, 1);
|
||||
|
||||
@ -227,7 +227,7 @@ export default class Condition extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
handleStaleCriterion(updatedCriterion) {
|
||||
handleOldTelemetryCriterion(updatedCriterion) {
|
||||
this.result = evaluateResults(this.criteria.map(criterion => criterion.result), this.trigger);
|
||||
let latestTimestamp = {};
|
||||
latestTimestamp = getLatestTimestamp(
|
||||
@ -239,6 +239,11 @@ export default class Condition extends EventEmitter {
|
||||
this.conditionManager.updateCurrentCondition(latestTimestamp);
|
||||
}
|
||||
|
||||
handleTelemetryStaleness() {
|
||||
this.result = evaluateResults(this.criteria.map(criterion => criterion.result), this.trigger);
|
||||
this.conditionManager.updateCurrentCondition();
|
||||
}
|
||||
|
||||
updateDescription() {
|
||||
const triggerDescription = this.getTriggerDescription();
|
||||
let description = '';
|
||||
|
@ -82,8 +82,10 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import Condition from './Condition.vue';
|
||||
import ConditionManager from '../ConditionManager';
|
||||
import StalenessUtils from '@/utils/staleness';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@ -139,6 +141,13 @@ export default {
|
||||
if (this.stopObservingForChanges) {
|
||||
this.stopObservingForChanges();
|
||||
}
|
||||
|
||||
if (this.stalenessSubscription) {
|
||||
Object.values(this.stalenessSubscription).forEach(stalenessSubscription => {
|
||||
stalenessSubscription.unsubscribe();
|
||||
stalenessSubscription.stalenessUtils.destroy();
|
||||
});
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.composition = this.openmct.composition.get(this.domainObject);
|
||||
@ -150,6 +159,7 @@ export default {
|
||||
this.conditionManager = new ConditionManager(this.domainObject, this.openmct);
|
||||
this.conditionManager.on('conditionSetResultUpdated', this.handleConditionSetResultUpdated);
|
||||
this.updateDefaultCondition();
|
||||
this.stalenessSubscription = {};
|
||||
},
|
||||
methods: {
|
||||
handleConditionSetResultUpdated(data) {
|
||||
@ -210,19 +220,60 @@ export default {
|
||||
return arr;
|
||||
},
|
||||
addTelemetryObject(domainObject) {
|
||||
const keyString = this.openmct.objects.makeKeyString(domainObject.identifier);
|
||||
|
||||
this.telemetryObjs.push(domainObject);
|
||||
this.$emit('telemetryUpdated', this.telemetryObjs);
|
||||
|
||||
if (!this.stalenessSubscription[keyString]) {
|
||||
this.stalenessSubscription[keyString] = {};
|
||||
}
|
||||
|
||||
this.stalenessSubscription[keyString].stalenessUtils = new StalenessUtils(this.openmct, domainObject);
|
||||
|
||||
this.openmct.telemetry.isStale(domainObject).then((stalenessResponse) => {
|
||||
if (stalenessResponse !== undefined) {
|
||||
this.handleStaleness(keyString, stalenessResponse);
|
||||
}
|
||||
});
|
||||
const stalenessSubscription = this.openmct.telemetry.subscribeToStaleness(domainObject, (stalenessResponse) => {
|
||||
this.handleStaleness(keyString, stalenessResponse);
|
||||
});
|
||||
|
||||
this.stalenessSubscription[keyString].unsubscribe = stalenessSubscription;
|
||||
},
|
||||
removeTelemetryObject(identifier) {
|
||||
let index = this.telemetryObjs.findIndex(obj => {
|
||||
const keyString = this.openmct.objects.makeKeyString(identifier);
|
||||
const index = this.telemetryObjs.findIndex(obj => {
|
||||
let objId = this.openmct.objects.makeKeyString(obj.identifier);
|
||||
let id = this.openmct.objects.makeKeyString(identifier);
|
||||
|
||||
return objId === id;
|
||||
return objId === keyString;
|
||||
});
|
||||
|
||||
if (index > -1) {
|
||||
this.telemetryObjs.splice(index, 1);
|
||||
}
|
||||
|
||||
if (this.stalenessSubscription[keyString]) {
|
||||
this.stalenessSubscription[keyString].unsubscribe();
|
||||
this.stalenessSubscription[keyString].stalenessUtils.destroy();
|
||||
this.emitStaleness({
|
||||
keyString,
|
||||
isStale: false
|
||||
});
|
||||
delete this.stalenessSubscription[keyString];
|
||||
}
|
||||
},
|
||||
handleStaleness(keyString, stalenessResponse) {
|
||||
if (this.stalenessSubscription[keyString].stalenessUtils.shouldUpdateStaleness(stalenessResponse)) {
|
||||
this.emitStaleness({
|
||||
keyString,
|
||||
isStale: stalenessResponse.isStale
|
||||
});
|
||||
}
|
||||
},
|
||||
emitStaleness(stalenessObject) {
|
||||
this.$emit('telemetryStaleness', stalenessObject);
|
||||
},
|
||||
addCondition() {
|
||||
this.conditionManager.addCondition();
|
||||
|
@ -21,7 +21,10 @@
|
||||
*****************************************************************************/
|
||||
|
||||
<template>
|
||||
<div class="c-cs">
|
||||
<div
|
||||
class="c-cs"
|
||||
:class="{'is-stale': isStale }"
|
||||
>
|
||||
<section class="c-cs__current-output c-section">
|
||||
<div class="c-cs__content c-cs__current-output-value">
|
||||
<span class="c-cs__current-output-value__label">Current Output</span>
|
||||
@ -50,6 +53,7 @@
|
||||
@conditionSetResultUpdated="updateCurrentOutput"
|
||||
@updateDefaultOutput="updateDefaultOutput"
|
||||
@telemetryUpdated="updateTelemetry"
|
||||
@telemetryStaleness="handleStaleness"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -73,9 +77,15 @@ export default {
|
||||
currentConditionOutput: '',
|
||||
defaultConditionOutput: '',
|
||||
telemetryObjs: [],
|
||||
testData: {}
|
||||
testData: {},
|
||||
staleObjects: []
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isStale() {
|
||||
return this.staleObjects.length !== 0;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.conditionSetIdentifier = this.openmct.objects.makeKeyString(this.domainObject.identifier);
|
||||
this.testData = {
|
||||
@ -95,6 +105,18 @@ export default {
|
||||
},
|
||||
updateTestData(testData) {
|
||||
this.testData = testData;
|
||||
},
|
||||
handleStaleness({ keyString, isStale }) {
|
||||
const index = this.staleObjects.indexOf(keyString);
|
||||
if (isStale) {
|
||||
if (index === -1) {
|
||||
this.staleObjects.push(keyString);
|
||||
}
|
||||
} else {
|
||||
if (index !== -1) {
|
||||
this.staleObjects.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -94,7 +94,7 @@
|
||||
>
|
||||
<span v-if="inputIndex < inputCount-1">and</span>
|
||||
</span>
|
||||
<span v-if="criterion.metadata === 'dataReceived'">seconds</span>
|
||||
<span v-if="criterion.metadata === 'dataReceived' && criterion.operation.name === IS_OLD_KEY">seconds</span>
|
||||
</template>
|
||||
<span v-else>
|
||||
<span
|
||||
@ -122,7 +122,7 @@
|
||||
|
||||
<script>
|
||||
import { OPERATIONS, INPUT_TYPES } from '../utils/operations';
|
||||
import {TRIGGER_CONJUNCTION} from "../utils/constants";
|
||||
import { TRIGGER_CONJUNCTION, IS_OLD_KEY, IS_STALE_KEY } from "../utils/constants";
|
||||
|
||||
export default {
|
||||
inject: ['openmct'],
|
||||
@ -153,7 +153,8 @@ export default {
|
||||
rowLabel: '',
|
||||
operationFormat: '',
|
||||
enumerations: [],
|
||||
inputTypes: INPUT_TYPES
|
||||
inputTypes: INPUT_TYPES,
|
||||
IS_OLD_KEY
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@ -164,7 +165,7 @@ export default {
|
||||
},
|
||||
filteredOps: function () {
|
||||
if (this.criterion.metadata === 'dataReceived') {
|
||||
return this.operations.filter(op => op.name === 'isStale');
|
||||
return this.operations.filter(op => op.name === IS_OLD_KEY || op.name === IS_STALE_KEY);
|
||||
} else {
|
||||
return this.operations.filter(op => op.appliesTo.indexOf(this.operationFormat) !== -1);
|
||||
}
|
||||
|
@ -54,6 +54,10 @@
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
&.is-stale {
|
||||
@include isStaleHolder();
|
||||
}
|
||||
|
||||
/************************** CONDITION SET LAYOUT */
|
||||
&__current-output {
|
||||
flex: 0 0 auto;
|
||||
|
@ -21,8 +21,9 @@
|
||||
*****************************************************************************/
|
||||
|
||||
import TelemetryCriterion from './TelemetryCriterion';
|
||||
import StalenessUtils from '@/utils/staleness';
|
||||
import { evaluateResults } from "../utils/evaluator";
|
||||
import {getLatestTimestamp, subscribeForStaleness} from '../utils/time';
|
||||
import { getLatestTimestamp, checkIfOld } from '../utils/time';
|
||||
import { getOperatorText } from "@/plugins/condition/utils/operations";
|
||||
|
||||
export default class AllTelemetryCriterion extends TelemetryCriterion {
|
||||
@ -38,13 +39,41 @@ export default class AllTelemetryCriterion extends TelemetryCriterion {
|
||||
initialize() {
|
||||
this.telemetryObjects = { ...this.telemetryDomainObjectDefinition.telemetryObjects };
|
||||
this.telemetryDataCache = {};
|
||||
if (this.isValid() && this.isStalenessCheck() && this.isValidInput()) {
|
||||
this.subscribeForStaleData(this.telemetryObjects || {});
|
||||
|
||||
if (this.isValid() && this.isOldCheck() && this.isValidInput()) {
|
||||
this.checkForOldData(this.telemetryObjects || {});
|
||||
}
|
||||
|
||||
if (this.isValid() && this.isStalenessCheck()) {
|
||||
this.subscribeToStaleness(this.telemetryObjects || {});
|
||||
}
|
||||
}
|
||||
|
||||
subscribeForStaleData(telemetryObjects) {
|
||||
checkForOldData(telemetryObjects) {
|
||||
if (!this.ageCheck) {
|
||||
this.ageCheck = {};
|
||||
}
|
||||
|
||||
Object.values(telemetryObjects).forEach((telemetryObject) => {
|
||||
const id = this.openmct.objects.makeKeyString(telemetryObject.identifier);
|
||||
if (!this.ageCheck[id]) {
|
||||
this.ageCheck[id] = checkIfOld((data) => {
|
||||
this.handleOldTelemetry(id, data);
|
||||
}, this.input[0] * 1000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleOldTelemetry(id, data) {
|
||||
if (this.telemetryDataCache) {
|
||||
this.telemetryDataCache[id] = true;
|
||||
this.result = evaluateResults(Object.values(this.telemetryDataCache), this.telemetry);
|
||||
}
|
||||
|
||||
this.emitEvent('telemetryIsOld', data);
|
||||
}
|
||||
|
||||
subscribeToStaleness(telemetryObjects) {
|
||||
if (!this.stalenessSubscription) {
|
||||
this.stalenessSubscription = {};
|
||||
}
|
||||
@ -52,20 +81,32 @@ export default class AllTelemetryCriterion extends TelemetryCriterion {
|
||||
Object.values(telemetryObjects).forEach((telemetryObject) => {
|
||||
const id = this.openmct.objects.makeKeyString(telemetryObject.identifier);
|
||||
if (!this.stalenessSubscription[id]) {
|
||||
this.stalenessSubscription[id] = subscribeForStaleness((data) => {
|
||||
this.handleStaleTelemetry(id, data);
|
||||
}, this.input[0] * 1000);
|
||||
this.stalenessSubscription[id] = {};
|
||||
this.stalenessSubscription[id].stalenessUtils = new StalenessUtils(this.openmct, telemetryObject);
|
||||
this.openmct.telemetry.isStale(telemetryObject).then((stalenessResponse) => {
|
||||
if (stalenessResponse !== undefined) {
|
||||
this.handleStaleTelemetry(id, stalenessResponse);
|
||||
}
|
||||
});
|
||||
this.stalenessSubscription[id].unsubscribe = this.openmct.telemetry.subscribeToStaleness(
|
||||
telemetryObject,
|
||||
(stalenessResponse) => {
|
||||
this.handleStaleTelemetry(id, stalenessResponse);
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleStaleTelemetry(id, data) {
|
||||
handleStaleTelemetry(id, stalenessResponse) {
|
||||
if (this.telemetryDataCache) {
|
||||
this.telemetryDataCache[id] = true;
|
||||
this.result = evaluateResults(Object.values(this.telemetryDataCache), this.telemetry);
|
||||
}
|
||||
if (this.stalenessSubscription[id].stalenessUtils.shouldUpdateStaleness(stalenessResponse)) {
|
||||
this.telemetryDataCache[id] = stalenessResponse.isStale;
|
||||
this.result = evaluateResults(Object.values(this.telemetryDataCache), this.telemetry);
|
||||
|
||||
this.emitEvent('telemetryIsStale', data);
|
||||
this.emitEvent('telemetryStaleness');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isValid() {
|
||||
@ -75,8 +116,13 @@ export default class AllTelemetryCriterion extends TelemetryCriterion {
|
||||
updateTelemetryObjects(telemetryObjects) {
|
||||
this.telemetryObjects = { ...telemetryObjects };
|
||||
this.removeTelemetryDataCache();
|
||||
if (this.isValid() && this.isStalenessCheck() && this.isValidInput()) {
|
||||
this.subscribeForStaleData(this.telemetryObjects || {});
|
||||
|
||||
if (this.isValid() && this.isOldCheck() && this.isValidInput()) {
|
||||
this.checkForOldData(this.telemetryObjects || {});
|
||||
}
|
||||
|
||||
if (this.isValid() && this.isStalenessCheck()) {
|
||||
this.subscribeToStaleness(this.telemetryObjects || {});
|
||||
}
|
||||
}
|
||||
|
||||
@ -91,6 +137,9 @@ export default class AllTelemetryCriterion extends TelemetryCriterion {
|
||||
});
|
||||
telemetryCacheIds.forEach(id => {
|
||||
delete (this.telemetryDataCache[id]);
|
||||
delete (this.ageCheck[id]);
|
||||
this.stalenessSubscription[id].unsubscribe();
|
||||
this.stalenessSubscription[id].stalenessUtils.destroy();
|
||||
delete (this.stalenessSubscription[id]);
|
||||
});
|
||||
}
|
||||
@ -125,10 +174,10 @@ export default class AllTelemetryCriterion extends TelemetryCriterion {
|
||||
updateResult(data, telemetryObjects) {
|
||||
const validatedData = this.isValid() ? data : {};
|
||||
|
||||
if (validatedData) {
|
||||
if (this.isStalenessCheck()) {
|
||||
if (this.stalenessSubscription && this.stalenessSubscription[validatedData.id]) {
|
||||
this.stalenessSubscription[validatedData.id].update(validatedData);
|
||||
if (validatedData && !this.isStalenessCheck()) {
|
||||
if (this.isOldCheck()) {
|
||||
if (this.ageCheck?.[validatedData.id]) {
|
||||
this.ageCheck[validatedData.id].update(validatedData);
|
||||
}
|
||||
|
||||
this.telemetryDataCache[validatedData.id] = false;
|
||||
@ -226,9 +275,17 @@ export default class AllTelemetryCriterion extends TelemetryCriterion {
|
||||
destroy() {
|
||||
delete this.telemetryObjects;
|
||||
delete this.telemetryDataCache;
|
||||
|
||||
if (this.ageCheck) {
|
||||
Object.values(this.ageCheck).forEach((subscription) => subscription.clear);
|
||||
delete this.ageCheck;
|
||||
}
|
||||
|
||||
if (this.stalenessSubscription) {
|
||||
Object.values(this.stalenessSubscription).forEach((subscription) => subscription.clear);
|
||||
delete this.stalenessSubscription;
|
||||
Object.values(this.stalenessSubscription).forEach(subscription => {
|
||||
subscription.unsubscribe();
|
||||
subscription.stalenessUtils.destroy();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -21,8 +21,10 @@
|
||||
*****************************************************************************/
|
||||
|
||||
import EventEmitter from 'EventEmitter';
|
||||
import StalenessUtils from '@/utils/staleness';
|
||||
import { IS_OLD_KEY, IS_STALE_KEY } from '../utils/constants';
|
||||
import { OPERATIONS, getOperatorText } from '../utils/operations';
|
||||
import { subscribeForStaleness } from "../utils/time";
|
||||
import { checkIfOld } from "../utils/time";
|
||||
|
||||
export default class TelemetryCriterion extends EventEmitter {
|
||||
|
||||
@ -44,7 +46,8 @@ export default class TelemetryCriterion extends EventEmitter {
|
||||
this.input = telemetryDomainObjectDefinition.input;
|
||||
this.metadata = telemetryDomainObjectDefinition.metadata;
|
||||
this.result = undefined;
|
||||
this.stalenessSubscription = undefined;
|
||||
this.ageCheck = undefined;
|
||||
this.unsubscribeFromStaleness = undefined;
|
||||
|
||||
this.initialize();
|
||||
this.emitEvent('criterionUpdated', this);
|
||||
@ -57,8 +60,13 @@ export default class TelemetryCriterion extends EventEmitter {
|
||||
}
|
||||
|
||||
this.updateTelemetryObjects(this.telemetryDomainObjectDefinition.telemetryObjects);
|
||||
if (this.isValid() && this.isStalenessCheck() && this.isValidInput()) {
|
||||
this.subscribeForStaleData();
|
||||
|
||||
if (this.isValid() && this.isOldCheck() && this.isValidInput()) {
|
||||
this.checkForOldData();
|
||||
}
|
||||
|
||||
if (this.isValid() && this.isStalenessCheck()) {
|
||||
this.subscribeToStaleness();
|
||||
}
|
||||
}
|
||||
|
||||
@ -66,25 +74,52 @@ export default class TelemetryCriterion extends EventEmitter {
|
||||
return this.telemetryObjectIdAsString && (this.telemetryObjectIdAsString === id);
|
||||
}
|
||||
|
||||
subscribeForStaleData() {
|
||||
if (this.stalenessSubscription) {
|
||||
this.stalenessSubscription.clear();
|
||||
checkForOldData() {
|
||||
if (this.ageCheck) {
|
||||
this.ageCheck.clear();
|
||||
}
|
||||
|
||||
this.stalenessSubscription = subscribeForStaleness(this.handleStaleTelemetry.bind(this), this.input[0] * 1000);
|
||||
this.ageCheck = checkIfOld(this.handleOldTelemetry.bind(this), this.input[0] * 1000);
|
||||
}
|
||||
|
||||
handleStaleTelemetry(data) {
|
||||
handleOldTelemetry(data) {
|
||||
this.result = true;
|
||||
this.emitEvent('telemetryIsStale', data);
|
||||
this.emitEvent('telemetryIsOld', data);
|
||||
}
|
||||
|
||||
subscribeToStaleness() {
|
||||
if (this.unsubscribeFromStaleness) {
|
||||
this.unsubscribeFromStaleness();
|
||||
}
|
||||
|
||||
if (!this.stalenessUtils) {
|
||||
this.stalenessUtils = new StalenessUtils(this.openmct, this.telemetryObject);
|
||||
}
|
||||
|
||||
this.openmct.telemetry.isStale(this.telemetryObject).then(this.handleStaleTelemetry.bind(this));
|
||||
this.unsubscribeFromStaleness = this.openmct.telemetry.subscribeToStaleness(
|
||||
this.telemetryObject,
|
||||
this.handleStaleTelemetry.bind(this)
|
||||
);
|
||||
}
|
||||
|
||||
handleStaleTelemetry(stalenessResponse) {
|
||||
if (stalenessResponse !== undefined && this.stalenessUtils.shouldUpdateStaleness(stalenessResponse)) {
|
||||
this.result = stalenessResponse.isStale;
|
||||
this.emitEvent('telemetryStaleness');
|
||||
}
|
||||
}
|
||||
|
||||
isValid() {
|
||||
return this.telemetryObject && this.metadata && this.operation;
|
||||
}
|
||||
|
||||
isOldCheck() {
|
||||
return this.metadata && this.metadata === 'dataReceived' && this.operation === IS_OLD_KEY;
|
||||
}
|
||||
|
||||
isStalenessCheck() {
|
||||
return this.metadata && this.metadata === 'dataReceived';
|
||||
return this.metadata && this.metadata === 'dataReceived' && this.operation === IS_STALE_KEY;
|
||||
}
|
||||
|
||||
isValidInput() {
|
||||
@ -93,8 +128,13 @@ export default class TelemetryCriterion extends EventEmitter {
|
||||
|
||||
updateTelemetryObjects(telemetryObjects) {
|
||||
this.telemetryObject = telemetryObjects[this.telemetryObjectIdAsString];
|
||||
if (this.isValid() && this.isStalenessCheck() && this.isValidInput()) {
|
||||
this.subscribeForStaleData();
|
||||
|
||||
if (this.isValid() && this.isOldCheck() && this.isValidInput()) {
|
||||
this.checkForOldData();
|
||||
}
|
||||
|
||||
if (this.isValid() && this.isStalenessCheck()) {
|
||||
this.subscribeToStaleness();
|
||||
}
|
||||
}
|
||||
|
||||
@ -130,14 +170,17 @@ export default class TelemetryCriterion extends EventEmitter {
|
||||
|
||||
updateResult(data) {
|
||||
const validatedData = this.isValid() ? data : {};
|
||||
if (this.isStalenessCheck()) {
|
||||
if (this.stalenessSubscription) {
|
||||
this.stalenessSubscription.update(validatedData);
|
||||
}
|
||||
|
||||
this.result = false;
|
||||
} else {
|
||||
this.result = this.computeResult(validatedData);
|
||||
if (!this.isStalenessCheck()) {
|
||||
if (this.isOldCheck()) {
|
||||
if (this.ageCheck) {
|
||||
this.ageCheck.update(validatedData);
|
||||
}
|
||||
|
||||
this.result = false;
|
||||
} else {
|
||||
this.result = this.computeResult(validatedData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -268,8 +311,17 @@ export default class TelemetryCriterion extends EventEmitter {
|
||||
destroy() {
|
||||
delete this.telemetryObject;
|
||||
delete this.telemetryObjectIdAsString;
|
||||
if (this.stalenessSubscription) {
|
||||
delete this.stalenessSubscription;
|
||||
|
||||
if (this.ageCheck) {
|
||||
delete this.ageCheck;
|
||||
}
|
||||
|
||||
if (this.stalenessUtils) {
|
||||
this.stalenessUtils.destroy();
|
||||
}
|
||||
|
||||
if (this.unsubscribeFromStaleness) {
|
||||
this.unsubscribeFromStaleness();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -28,6 +28,7 @@ import Vue from 'vue';
|
||||
import {getApplicableStylesForItem} from "./utils/styleUtils";
|
||||
import ConditionManager from "@/plugins/condition/ConditionManager";
|
||||
import StyleRuleManager from "./StyleRuleManager";
|
||||
import { IS_OLD_KEY } from "./utils/constants";
|
||||
|
||||
describe('the plugin', function () {
|
||||
let conditionSetDefinition;
|
||||
@ -642,7 +643,7 @@ describe('the plugin', function () {
|
||||
|
||||
});
|
||||
|
||||
describe('the condition check for staleness', () => {
|
||||
describe('the condition check if old', () => {
|
||||
let conditionSetDomainObject;
|
||||
|
||||
beforeEach(() => {
|
||||
@ -660,13 +661,13 @@ describe('the plugin', function () {
|
||||
"id": "39584410-cbf9-499e-96dc-76f27e69885d",
|
||||
"configuration": {
|
||||
"name": "Unnamed Condition",
|
||||
"output": "Any stale telemetry",
|
||||
"output": "Any old telemetry",
|
||||
"trigger": "all",
|
||||
"criteria": [
|
||||
{
|
||||
"id": "35400132-63b0-425c-ac30-8197df7d5862",
|
||||
"telemetry": "any",
|
||||
"operation": "isStale",
|
||||
"operation": IS_OLD_KEY,
|
||||
"input": [
|
||||
"0.2"
|
||||
],
|
||||
@ -674,7 +675,7 @@ describe('the plugin', function () {
|
||||
}
|
||||
]
|
||||
},
|
||||
"summary": "Match if all criteria are met: Any telemetry is stale after 5 seconds"
|
||||
"summary": "Match if all criteria are met: Any telemetry is old after 5 seconds"
|
||||
},
|
||||
{
|
||||
"isDefault": true,
|
||||
@ -708,7 +709,7 @@ describe('the plugin', function () {
|
||||
};
|
||||
});
|
||||
|
||||
it('should evaluate as stale when telemetry is not received in the allotted time', (done) => {
|
||||
it('should evaluate as old when telemetry is not received in the allotted time', (done) => {
|
||||
let conditionMgr = new ConditionManager(conditionSetDomainObject, openmct);
|
||||
conditionMgr.on('conditionSetResultUpdated', mockListener);
|
||||
conditionMgr.telemetryObjects = {
|
||||
@ -717,7 +718,7 @@ describe('the plugin', function () {
|
||||
conditionMgr.updateConditionTelemetryObjects();
|
||||
setTimeout(() => {
|
||||
expect(mockListener).toHaveBeenCalledWith({
|
||||
output: 'Any stale telemetry',
|
||||
output: 'Any old telemetry',
|
||||
id: {
|
||||
namespace: '',
|
||||
key: 'cf4456a9-296a-4e6b-b182-62ed29cd15b9'
|
||||
@ -729,7 +730,7 @@ describe('the plugin', function () {
|
||||
}, 400);
|
||||
});
|
||||
|
||||
it('should not evaluate as stale when telemetry is received in the allotted time', (done) => {
|
||||
it('should not evaluate as old when telemetry is received in the allotted time', (done) => {
|
||||
const date = 1;
|
||||
conditionSetDomainObject.configuration.conditionCollection[0].configuration.criteria[0].input = ["0.4"];
|
||||
let conditionMgr = new ConditionManager(conditionSetDomainObject, openmct);
|
||||
|
@ -59,3 +59,6 @@ export const ERROR = {
|
||||
errorText: 'Condition not found'
|
||||
}
|
||||
};
|
||||
|
||||
export const IS_OLD_KEY = 'isStale';
|
||||
export const IS_STALE_KEY = 'isStale.new';
|
||||
|
@ -20,6 +20,8 @@
|
||||
* at runtime from the About dialog for additional information.
|
||||
*****************************************************************************/
|
||||
|
||||
import { IS_OLD_KEY, IS_STALE_KEY } from "./constants";
|
||||
|
||||
function convertToNumbers(input) {
|
||||
let numberInputs = [];
|
||||
input.forEach(inputValue => numberInputs.push(Number(inputValue)));
|
||||
@ -295,7 +297,7 @@ export const OPERATIONS = [
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'isStale',
|
||||
name: IS_OLD_KEY,
|
||||
operation: function () {
|
||||
return false;
|
||||
},
|
||||
@ -305,6 +307,18 @@ export const OPERATIONS = [
|
||||
getDescription: function (values) {
|
||||
return ` is older than ${values[0] || ''} seconds`;
|
||||
}
|
||||
},
|
||||
{
|
||||
name: IS_STALE_KEY,
|
||||
operation: function () {
|
||||
return false;
|
||||
},
|
||||
text: 'is stale',
|
||||
appliesTo: ["number"],
|
||||
inputCount: 0,
|
||||
getDescription: function () {
|
||||
return ' is stale';
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
@ -316,5 +330,5 @@ export const INPUT_TYPES = {
|
||||
export function getOperatorText(operationName, values) {
|
||||
const found = OPERATIONS.find((operation) => operation.name === operationName);
|
||||
|
||||
return found ? found.getDescription(values) : '';
|
||||
return found?.getDescription(values) ?? '';
|
||||
}
|
||||
|
@ -51,26 +51,26 @@ export function getLatestTimestamp(
|
||||
return latest;
|
||||
}
|
||||
|
||||
export function subscribeForStaleness(callback, timeout) {
|
||||
let stalenessTimer = setTimeout(() => {
|
||||
clearTimeout(stalenessTimer);
|
||||
export function checkIfOld(callback, timeout) {
|
||||
let oldCheckTimer = setTimeout(() => {
|
||||
clearTimeout(oldCheckTimer);
|
||||
callback();
|
||||
}, timeout);
|
||||
|
||||
return {
|
||||
update: (data) => {
|
||||
if (stalenessTimer) {
|
||||
clearTimeout(stalenessTimer);
|
||||
if (oldCheckTimer) {
|
||||
clearTimeout(oldCheckTimer);
|
||||
}
|
||||
|
||||
stalenessTimer = setTimeout(() => {
|
||||
clearTimeout(stalenessTimer);
|
||||
oldCheckTimer = setTimeout(() => {
|
||||
clearTimeout(oldCheckTimer);
|
||||
callback(data);
|
||||
}, timeout);
|
||||
},
|
||||
clear: () => {
|
||||
if (stalenessTimer) {
|
||||
clearTimeout(stalenessTimer);
|
||||
if (oldCheckTimer) {
|
||||
clearTimeout(oldCheckTimer);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -19,7 +19,7 @@
|
||||
* this source code distribution or the Licensing information page available
|
||||
* at runtime from the About dialog for additional information.
|
||||
*****************************************************************************/
|
||||
import { subscribeForStaleness } from "./time";
|
||||
import { checkIfOld } from "./time";
|
||||
|
||||
describe('time related utils', () => {
|
||||
let subscription;
|
||||
@ -27,11 +27,11 @@ describe('time related utils', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
mockListener = jasmine.createSpy('listener');
|
||||
subscription = subscribeForStaleness(mockListener, 100);
|
||||
subscription = checkIfOld(mockListener, 100);
|
||||
});
|
||||
|
||||
describe('subscribe for staleness', () => {
|
||||
it('should call listeners when stale', (done) => {
|
||||
describe('check if old', () => {
|
||||
it('should call listeners when old', (done) => {
|
||||
setTimeout(() => {
|
||||
expect(mockListener).toHaveBeenCalled();
|
||||
done();
|
||||
|
@ -31,7 +31,7 @@
|
||||
<div
|
||||
v-if="domainObject"
|
||||
class="c-telemetry-view u-style-receiver"
|
||||
:class="[statusClass]"
|
||||
:class="[itemClasses]"
|
||||
:style="styleObject"
|
||||
:data-font-size="item.fontSize"
|
||||
:data-font="item.font"
|
||||
@ -73,6 +73,7 @@
|
||||
<script>
|
||||
import LayoutFrame from './LayoutFrame.vue';
|
||||
import conditionalStylesMixin from "../mixins/objectStyles-mixin";
|
||||
import stalenessMixin from '@/ui/mixins/staleness-mixin';
|
||||
import { getDefaultNotebook, getNotebookSectionAndPage } from '@/plugins/notebook/utils/notebook-storage.js';
|
||||
|
||||
const DEFAULT_TELEMETRY_DIMENSIONS = [10, 5];
|
||||
@ -102,7 +103,7 @@ export default {
|
||||
components: {
|
||||
LayoutFrame
|
||||
},
|
||||
mixins: [conditionalStylesMixin],
|
||||
mixins: [conditionalStylesMixin, stalenessMixin],
|
||||
inject: ['openmct', 'objectPath', 'currentView'],
|
||||
props: {
|
||||
item: {
|
||||
@ -137,8 +138,18 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
statusClass() {
|
||||
return (this.status) ? `is-status--${this.status}` : '';
|
||||
itemClasses() {
|
||||
let classes = [];
|
||||
|
||||
if (this.status) {
|
||||
classes.push(`is-status--${this.status}`);
|
||||
}
|
||||
|
||||
if (this.isStale) {
|
||||
classes.push('is-stale');
|
||||
}
|
||||
|
||||
return classes;
|
||||
},
|
||||
showLabel() {
|
||||
let displayMode = this.item.displayMode;
|
||||
@ -310,6 +321,7 @@ export default {
|
||||
this.removeSelectable = this.openmct.selection.selectable(
|
||||
this.$el, this.context, this.immediatelySelect || this.initSelect);
|
||||
delete this.immediatelySelect;
|
||||
this.subscribeToStaleness(this.domainObject);
|
||||
},
|
||||
updateTelemetryFormat(format) {
|
||||
this.customStringformatter.setFormat(format);
|
||||
|
@ -25,6 +25,12 @@
|
||||
margin-right: $interiorMargin;
|
||||
}
|
||||
|
||||
&.is-stale {
|
||||
.c-telemetry-view__value {
|
||||
@include isStaleElement();
|
||||
}
|
||||
}
|
||||
|
||||
.c-frame & {
|
||||
@include abs();
|
||||
border: 1px solid transparent;
|
||||
|
@ -31,6 +31,7 @@ export default class DuplicateAction {
|
||||
this.priority = 7;
|
||||
|
||||
this.openmct = openmct;
|
||||
this.transaction = null;
|
||||
}
|
||||
|
||||
invoke(objectPath) {
|
||||
@ -45,7 +46,9 @@ export default class DuplicateAction {
|
||||
.some(objectInPath => this.openmct.objects.areIdsEqual(objectInPath.identifier, this.object.identifier));
|
||||
}
|
||||
|
||||
onSave(changes) {
|
||||
async onSave(changes) {
|
||||
this.startTransaction();
|
||||
|
||||
let inNavigationPath = this.inNavigationPath();
|
||||
if (inNavigationPath && this.openmct.editor.isEditing()) {
|
||||
this.openmct.editor.save();
|
||||
@ -59,7 +62,9 @@ export default class DuplicateAction {
|
||||
const parentDomainObjectpath = changes.location || [this.parent];
|
||||
const parent = parentDomainObjectpath[0];
|
||||
|
||||
return duplicationTask.duplicate(this.object, parent);
|
||||
await duplicationTask.duplicate(this.object, parent);
|
||||
|
||||
return this.saveTransaction();
|
||||
}
|
||||
|
||||
showForm(domainObject, parentDomainObject) {
|
||||
@ -142,4 +147,20 @@ export default class DuplicateAction {
|
||||
&& parentType.definition.creatable
|
||||
&& Array.isArray(parent.composition);
|
||||
}
|
||||
|
||||
startTransaction() {
|
||||
if (!this.openmct.objects.isTransactionActive()) {
|
||||
this.transaction = this.openmct.objects.startTransaction();
|
||||
}
|
||||
}
|
||||
|
||||
async saveTransaction() {
|
||||
if (!this.transaction) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.transaction.commit();
|
||||
this.openmct.objects.endTransaction();
|
||||
this.transaction = null;
|
||||
}
|
||||
}
|
||||
|
@ -59,7 +59,7 @@ export default class CreateAction extends PropertiesAction {
|
||||
_.set(this.domainObject, key, value);
|
||||
});
|
||||
|
||||
const parentDomainObject = parentDomainObjectPath[0];
|
||||
const parentDomainObject = this.openmct.objects.toMutable(parentDomainObjectPath[0]);
|
||||
|
||||
this.domainObject.modified = Date.now();
|
||||
this.domainObject.location = this.openmct.objects.makeKeyString(parentDomainObject.identifier);
|
||||
@ -85,6 +85,7 @@ export default class CreateAction extends PropertiesAction {
|
||||
console.error(err);
|
||||
this.openmct.notifications.error(`Error saving objects: ${err}`);
|
||||
} finally {
|
||||
this.openmct.objects.destroyMutable(parentDomainObject);
|
||||
dialog.dismiss();
|
||||
}
|
||||
|
||||
@ -142,18 +143,21 @@ export default class CreateAction extends PropertiesAction {
|
||||
}
|
||||
};
|
||||
|
||||
this.domainObject = domainObject;
|
||||
this.domainObject = this.openmct.objects.toMutable(domainObject);
|
||||
|
||||
if (definition.initialize) {
|
||||
definition.initialize(domainObject);
|
||||
definition.initialize(this.domainObject);
|
||||
}
|
||||
|
||||
const createWizard = new CreateWizard(this.openmct, domainObject, this.parentDomainObject);
|
||||
const createWizard = new CreateWizard(this.openmct, this.domainObject, this.parentDomainObject);
|
||||
const formStructure = createWizard.getFormStructure(true);
|
||||
formStructure.title = 'Create a New ' + definition.name;
|
||||
|
||||
this.openmct.forms.showForm(formStructure)
|
||||
.then(this._onSave.bind(this))
|
||||
.catch(this._onCancel.bind(this));
|
||||
.catch(this._onCancel.bind(this))
|
||||
.finally(() => {
|
||||
this.openmct.objects.destroyMutable(this.domainObject);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -22,7 +22,7 @@
|
||||
<template>
|
||||
<div
|
||||
class="c-gauge__wrapper js-gauge-wrapper"
|
||||
:class="`c-gauge--${gaugeType}`"
|
||||
:class="gaugeClasses"
|
||||
:title="gaugeTitle"
|
||||
>
|
||||
<template v-if="typeDial">
|
||||
@ -347,12 +347,14 @@
|
||||
|
||||
<script>
|
||||
import { DIAL_VALUE_DEG_OFFSET, getLimitDegree } from '../gauge-limit-util';
|
||||
import stalenessMixin from '@/ui/mixins/staleness-mixin';
|
||||
|
||||
const LIMIT_PADDING_IN_PERCENT = 10;
|
||||
const DEFAULT_CURRENT_VALUE = '--';
|
||||
|
||||
export default {
|
||||
name: 'Gauge',
|
||||
mixins: [stalenessMixin],
|
||||
inject: ['openmct', 'domainObject', 'composition'],
|
||||
data() {
|
||||
let gaugeController = this.domainObject.configuration.gaugeController;
|
||||
@ -403,6 +405,15 @@ export default {
|
||||
|
||||
return VIEWBOX_STR.replace('X', this.digits * DIGITS_RATIO);
|
||||
},
|
||||
gaugeClasses() {
|
||||
let classes = [`c-gauge--${this.gaugeType}`];
|
||||
|
||||
if (this.isStale) {
|
||||
classes.push('is-stale');
|
||||
}
|
||||
|
||||
return classes;
|
||||
},
|
||||
rangeFontSize() {
|
||||
const CHAR_THRESHOLD = 3;
|
||||
const START_PERC = 8.5;
|
||||
@ -553,6 +564,8 @@ export default {
|
||||
this.telemetryObject = domainObject;
|
||||
this.request();
|
||||
this.subscribe();
|
||||
|
||||
this.subscribeToStaleness(domainObject);
|
||||
},
|
||||
addedToComposition(domainObject) {
|
||||
if (this.telemetryObject) {
|
||||
@ -611,6 +624,8 @@ export default {
|
||||
this.unsubscribe = null;
|
||||
}
|
||||
|
||||
this.triggerUnsubscribeFromStaleness();
|
||||
|
||||
this.curVal = DEFAULT_CURRENT_VALUE;
|
||||
this.formats = null;
|
||||
this.limitHigh = '';
|
||||
|
@ -32,6 +32,15 @@ $meterNeedleBorderRadius: 5px;
|
||||
&__wrapper {
|
||||
@include abs();
|
||||
overflow: hidden;
|
||||
|
||||
&.is-stale {
|
||||
@include isStaleHolder();
|
||||
|
||||
[class*=__current-value-text] {
|
||||
fill: $colorTelemStale;
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__current-value-text-wrapper {
|
||||
|
@ -45,6 +45,35 @@ export default class ImageryView {
|
||||
});
|
||||
}
|
||||
|
||||
getViewContext() {
|
||||
if (!this.component) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return this.component.$refs.ImageryContainer;
|
||||
}
|
||||
|
||||
pause() {
|
||||
const imageContext = this.getViewContext();
|
||||
// persist previous pause value to return to after unpausing
|
||||
this.previouslyPaused = imageContext.isPaused;
|
||||
imageContext.thumbnailClicked(imageContext.focusedImageIndex);
|
||||
}
|
||||
unpause() {
|
||||
const pausedStateBefore = this.previouslyPaused;
|
||||
this.previouslyPaused = undefined; // clear value
|
||||
const imageContext = this.getViewContext();
|
||||
imageContext.paused(pausedStateBefore);
|
||||
}
|
||||
|
||||
onPreviewModeChange({ isPreviewing } = {}) {
|
||||
if (isPreviewing) {
|
||||
this.pause();
|
||||
} else {
|
||||
this.unpause();
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.component.$destroy();
|
||||
this.component = undefined;
|
||||
|
@ -26,19 +26,23 @@
|
||||
:style="`width: 100%; height: 100%`"
|
||||
>
|
||||
<CompassHUD
|
||||
v-if="hasCameraFieldOfView"
|
||||
:sun-heading="sunHeading"
|
||||
:camera-angle-of-view="cameraAngleOfView"
|
||||
:camera-pan="cameraPan"
|
||||
:heading="heading"
|
||||
:camera-azimuth="cameraAzimuth"
|
||||
:transformations="transformations"
|
||||
:has-gimble="hasGimble"
|
||||
:normalized-camera-azimuth="normalizedCameraAzimuth"
|
||||
:sun-heading="sunHeading"
|
||||
/>
|
||||
<CompassRose
|
||||
v-if="hasCameraFieldOfView"
|
||||
:camera-angle-of-view="cameraAngleOfView"
|
||||
:camera-pan="cameraPan"
|
||||
:compass-rose-sizing-classes="compassRoseSizingClasses"
|
||||
:heading="heading"
|
||||
:sized-image-dimensions="sizedImageDimensions"
|
||||
:camera-azimuth="cameraAzimuth"
|
||||
:transformations="transformations"
|
||||
:has-gimble="hasGimble"
|
||||
:normalized-camera-azimuth="normalizedCameraAzimuth"
|
||||
:sun-heading="sunHeading"
|
||||
:sized-image-dimensions="sizedImageDimensions"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@ -46,8 +50,7 @@
|
||||
<script>
|
||||
import CompassHUD from './CompassHUD.vue';
|
||||
import CompassRose from './CompassRose.vue';
|
||||
|
||||
const CAMERA_ANGLE_OF_VIEW = 70;
|
||||
import { rotate } from './utils';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@ -55,10 +58,6 @@ export default {
|
||||
CompassRose
|
||||
},
|
||||
props: {
|
||||
compassRoseSizingClasses: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
image: {
|
||||
type: Object,
|
||||
required: true
|
||||
@ -69,23 +68,35 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
hasCameraFieldOfView() {
|
||||
return this.cameraPan !== undefined && this.cameraAngleOfView > 0;
|
||||
hasGimble() {
|
||||
return this.cameraAzimuth !== undefined;
|
||||
},
|
||||
// compass ordinal orientation of camera
|
||||
normalizedCameraAzimuth() {
|
||||
return this.hasGimble
|
||||
? rotate(this.cameraAzimuth)
|
||||
: rotate(this.heading, -this.transformations.rotation || 0);
|
||||
},
|
||||
// horizontal rotation from north in degrees
|
||||
heading() {
|
||||
return this.image.heading;
|
||||
},
|
||||
hasHeading() {
|
||||
return this.heading !== undefined;
|
||||
},
|
||||
// horizontal rotation from north in degrees
|
||||
sunHeading() {
|
||||
return this.image.sunOrientation;
|
||||
},
|
||||
// horizontal rotation from north in degrees
|
||||
cameraPan() {
|
||||
cameraAzimuth() {
|
||||
return this.image.cameraPan;
|
||||
},
|
||||
cameraAngleOfView() {
|
||||
return CAMERA_ANGLE_OF_VIEW;
|
||||
return this.transformations.cameraAngleOfView;
|
||||
},
|
||||
transformations() {
|
||||
return this.image.transformations;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
@ -94,17 +94,33 @@ const COMPASS_POINTS = [
|
||||
|
||||
export default {
|
||||
props: {
|
||||
cameraAngleOfView: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
heading: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
cameraAzimuth: {
|
||||
type: Number,
|
||||
default: undefined
|
||||
},
|
||||
transformations: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
hasGimble: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
normalizedCameraAzimuth: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
sunHeading: {
|
||||
type: Number,
|
||||
default: undefined
|
||||
},
|
||||
cameraAngleOfView: {
|
||||
type: Number,
|
||||
default: undefined
|
||||
},
|
||||
cameraPan: {
|
||||
type: Number,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@ -130,10 +146,13 @@ export default {
|
||||
left: `${ percentage * 100 }%`
|
||||
};
|
||||
},
|
||||
cameraRotation() {
|
||||
return this.transformations?.rotation;
|
||||
},
|
||||
visibleRange() {
|
||||
return [
|
||||
rotate(this.cameraPan, -this.cameraAngleOfView / 2),
|
||||
rotate(this.cameraPan, this.cameraAngleOfView / 2)
|
||||
rotate(this.normalizedCameraAzimuth, -this.cameraAngleOfView / 2),
|
||||
rotate(this.normalizedCameraAzimuth, this.cameraAngleOfView / 2)
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@ -64,18 +64,17 @@
|
||||
class="c-cr__edge"
|
||||
width="100"
|
||||
height="100"
|
||||
fill="url(#paint0_radial)"
|
||||
fill="url(#gradient_edge)"
|
||||
/>
|
||||
<rect
|
||||
v-if="hasSunHeading"
|
||||
class="c-cr__sun"
|
||||
width="100"
|
||||
height="100"
|
||||
fill="url(#paint1_radial)"
|
||||
fill="url(#gradient_sun)"
|
||||
:style="sunHeadingStyle"
|
||||
/>
|
||||
|
||||
<!-- Camera FOV -->
|
||||
<mask
|
||||
id="mask2"
|
||||
class="c-cr__cam-fov-l-mask"
|
||||
@ -108,43 +107,60 @@
|
||||
/>
|
||||
</mask>
|
||||
<g
|
||||
class="c-cr__cam-fov"
|
||||
:style="cameraPanStyle"
|
||||
class="c-cr-cam-and-body"
|
||||
:style="cameraHeadingStyle"
|
||||
>
|
||||
<g mask="url(#mask2)">
|
||||
<rect
|
||||
class="c-cr__cam-fov-r"
|
||||
x="49"
|
||||
width="51"
|
||||
height="100"
|
||||
:style="cameraFOVStyleRightHalf"
|
||||
<!-- Equipment (spacecraft) body holder. Transforms relative to the camera position. -->
|
||||
<g
|
||||
v-if="hasHeading"
|
||||
class="cr-vrover"
|
||||
:style="camAngleAndPositionStyle"
|
||||
>
|
||||
<!-- Equipment body. Rotates relative to the camera pan value for cameras that gimble. -->
|
||||
<path
|
||||
class="cr-vrover__body"
|
||||
:style="gimbledCameraPanStyle"
|
||||
x
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M5 0C2.23858 0 0 2.23858 0 5V95C0 97.7614 2.23858 100 5 100H95C97.7614 100 100 97.7614 100 95V5C100 2.23858 97.7614 0 95 0H5ZM85 59L50 24L15 59H33V75H67.0455V59H85Z"
|
||||
/>
|
||||
</g>
|
||||
<g mask="url(#mask1)">
|
||||
<rect
|
||||
class="c-cr__cam-fov-l"
|
||||
width="51"
|
||||
height="100"
|
||||
:style="cameraFOVStyleLeftHalf"
|
||||
|
||||
<!-- Camera FOV -->
|
||||
<g
|
||||
class="c-cr__cam-fov"
|
||||
>
|
||||
<g mask="url(#mask2)">
|
||||
<rect
|
||||
class="c-cr__cam-fov-r"
|
||||
x="49"
|
||||
width="51"
|
||||
height="100"
|
||||
:style="cameraFOVStyleRightHalf"
|
||||
/>
|
||||
</g>
|
||||
<g mask="url(#mask1)">
|
||||
<rect
|
||||
class="c-cr__cam-fov-l"
|
||||
width="51"
|
||||
height="100"
|
||||
:style="cameraFOVStyleLeftHalf"
|
||||
/>
|
||||
</g>
|
||||
<polygon
|
||||
class="c-cr__cam"
|
||||
points="0,0 100,0 70,40 70,100 30,100 30,40"
|
||||
/>
|
||||
</g>
|
||||
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Spacecraft body -->
|
||||
<path
|
||||
v-if="hasHeading"
|
||||
class="c-cr__spacecraft-body"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M37 49C35.3431 49 34 50.3431 34 52V82C34 83.6569 35.3431 85 37 85H63C64.6569 85 66 83.6569 66 82V52C66 50.3431 64.6569 49 63 49H37ZM50 52L58 60H55V67H45V60H42L50 52Z"
|
||||
:style="headingStyle"
|
||||
/>
|
||||
|
||||
<!-- NSEW and ticks -->
|
||||
<g
|
||||
class="c-cr__nsew"
|
||||
:style="compassRoseStyle"
|
||||
:style="compassDialStyle"
|
||||
>
|
||||
<g class="c-cr__ticks-major">
|
||||
<path d="M50 3L43 10H57L50 3Z" />
|
||||
@ -193,7 +209,7 @@
|
||||
</g>
|
||||
<defs>
|
||||
<radialGradient
|
||||
id="paint0_radial"
|
||||
id="gradient_edge"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
@ -201,7 +217,7 @@
|
||||
gradientTransform="translate(50 50) rotate(90) scale(50)"
|
||||
>
|
||||
<stop
|
||||
offset="0.751387"
|
||||
offset="0.6"
|
||||
stop-opacity="0"
|
||||
/>
|
||||
<stop
|
||||
@ -210,7 +226,7 @@
|
||||
/>
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id="paint1_radial"
|
||||
id="gradient_sun"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
@ -218,12 +234,17 @@
|
||||
gradientTransform="translate(50 -7) rotate(-90) scale(18.5)"
|
||||
>
|
||||
<stop
|
||||
offset="0.716377"
|
||||
offset="0.7"
|
||||
stop-color="#FFCC00"
|
||||
/>
|
||||
<stop
|
||||
offset="0.7"
|
||||
stop-color="#FFCC00"
|
||||
stop-opacity="0.6"
|
||||
/>
|
||||
<stop
|
||||
offset="1"
|
||||
stop-color="#FF9900"
|
||||
stop-color="#FF6600"
|
||||
stop-opacity="0"
|
||||
/>
|
||||
</radialGradient>
|
||||
@ -238,32 +259,34 @@ import { throttle } from 'lodash';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
compassRoseSizingClasses: {
|
||||
type: String,
|
||||
cameraAngleOfView: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
heading: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default() {
|
||||
return 0;
|
||||
}
|
||||
required: true
|
||||
},
|
||||
cameraAzimuth: {
|
||||
type: Number,
|
||||
default: undefined
|
||||
},
|
||||
transformations: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
hasGimble: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
normalizedCameraAzimuth: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
sunHeading: {
|
||||
type: Number,
|
||||
default: undefined
|
||||
},
|
||||
cameraAngleOfView: {
|
||||
type: Number,
|
||||
default: undefined
|
||||
},
|
||||
cameraPan: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default() {
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
sizedImageDimensions: {
|
||||
type: Object,
|
||||
required: true
|
||||
@ -275,11 +298,30 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
compassRoseStyle() {
|
||||
camAngleAndPositionStyle() {
|
||||
const translateX = this.transformations?.translateX;
|
||||
const translateY = this.transformations?.translateY;
|
||||
const rotation = this.transformations?.rotation;
|
||||
const scale = this.transformations?.scale;
|
||||
|
||||
return { transform: `translate(${translateX}%, ${translateY}%) rotate(${rotation}deg) scale(${scale})` };
|
||||
},
|
||||
gimbledCameraPanStyle() {
|
||||
if (!this.hasGimble) {
|
||||
return;
|
||||
}
|
||||
|
||||
const gimbledCameraPan = rotate(this.normalizedCameraAzimuth, -this.heading);
|
||||
|
||||
return {
|
||||
transform: `rotate(${ -gimbledCameraPan }deg)`
|
||||
};
|
||||
},
|
||||
compassDialStyle() {
|
||||
return { transform: `rotate(${ this.north }deg)` };
|
||||
},
|
||||
north() {
|
||||
return this.lockCompass ? rotate(-this.cameraPan) : 0;
|
||||
return this.lockCompass ? rotate(-this.normalizedCameraAzimuth) : 0;
|
||||
},
|
||||
cardinalTextRotateN() {
|
||||
return { transform: `translateY(-27%) rotate(${ -this.north }deg)` };
|
||||
@ -296,13 +338,6 @@ export default {
|
||||
hasHeading() {
|
||||
return this.heading !== undefined;
|
||||
},
|
||||
headingStyle() {
|
||||
const rotation = rotate(this.north, this.heading);
|
||||
|
||||
return {
|
||||
transform: `rotate(${ rotation }deg)`
|
||||
};
|
||||
},
|
||||
hasSunHeading() {
|
||||
return this.sunHeading !== undefined;
|
||||
},
|
||||
@ -313,8 +348,8 @@ export default {
|
||||
transform: `rotate(${ rotation }deg)`
|
||||
};
|
||||
},
|
||||
cameraPanStyle() {
|
||||
const rotation = rotate(this.north, this.cameraPan);
|
||||
cameraHeadingStyle() {
|
||||
const rotation = rotate(this.north, this.normalizedCameraAzimuth);
|
||||
|
||||
return {
|
||||
transform: `rotate(${ rotation }deg)`
|
||||
@ -333,6 +368,24 @@ export default {
|
||||
return {
|
||||
transform: `rotate(${ -this.cameraAngleOfView / 2 }deg)`
|
||||
};
|
||||
},
|
||||
compassRoseSizingClasses() {
|
||||
let compassRoseSizingClasses = '';
|
||||
if (this.sizedImageWidth < 300) {
|
||||
compassRoseSizingClasses = '--rose-small --rose-min';
|
||||
} else if (this.sizedImageWidth < 500) {
|
||||
compassRoseSizingClasses = '--rose-small';
|
||||
} else if (this.sizedImageWidth > 1000) {
|
||||
compassRoseSizingClasses = '--rose-max';
|
||||
}
|
||||
|
||||
return compassRoseSizingClasses;
|
||||
},
|
||||
sizedImageWidth() {
|
||||
return this.sizedImageDimensions.width;
|
||||
},
|
||||
sizedImageHeight() {
|
||||
return this.sizedImageDimensions.height;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
|
@ -1,5 +1,5 @@
|
||||
/***************************** THEME/UI CONSTANTS AND MIXINS */
|
||||
$interfaceKeyColor: #00B9C5;
|
||||
$interfaceKeyColor: #fff;
|
||||
$elemBg: rgba(black, 0.7);
|
||||
|
||||
@mixin sun($position: 'circle closest-side') {
|
||||
@ -100,13 +100,19 @@ $elemBg: rgba(black, 0.7);
|
||||
}
|
||||
|
||||
&__edge {
|
||||
opacity: 0.1;
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
&__sun {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&__cam {
|
||||
fill: $interfaceKeyColor;
|
||||
transform-origin: center;
|
||||
transform: scale(0.15);
|
||||
}
|
||||
|
||||
&__cam-fov-l,
|
||||
&__cam-fov-r {
|
||||
// Cam FOV indication
|
||||
@ -115,7 +121,6 @@ $elemBg: rgba(black, 0.7);
|
||||
}
|
||||
|
||||
&__nsew-text,
|
||||
&__spacecraft-body,
|
||||
&__ticks-major,
|
||||
&__ticks-minor {
|
||||
fill: $color;
|
||||
@ -166,3 +171,15 @@ $elemBg: rgba(black, 0.7);
|
||||
padding-top: $s;
|
||||
}
|
||||
}
|
||||
|
||||
/************************** ROVER */
|
||||
.cr-vrover {
|
||||
$scale: 0.4;
|
||||
transform-origin: center;
|
||||
|
||||
&__body {
|
||||
fill: $interfaceKeyColor;
|
||||
opacity: 0.3;
|
||||
transform-origin: center 7% !important; // Places rotation center at mast position
|
||||
}
|
||||
}
|
||||
|
@ -35,8 +35,15 @@ describe("The Compass component", () => {
|
||||
roll: 90,
|
||||
pitch: 90,
|
||||
cameraTilt: 100,
|
||||
cameraPan: 90,
|
||||
sunAngle: 30
|
||||
cameraAzimuth: 90,
|
||||
sunAngle: 30,
|
||||
transformations: {
|
||||
translateX: 0,
|
||||
translateY: 18,
|
||||
rotation: 0,
|
||||
scale: 0.3,
|
||||
cameraAngleOfView: 70
|
||||
}
|
||||
};
|
||||
let propsData = {
|
||||
naturalAspectRatio: 0.9,
|
||||
@ -44,8 +51,7 @@ describe("The Compass component", () => {
|
||||
sizedImageDimensions: {
|
||||
width: 100,
|
||||
height: 100
|
||||
},
|
||||
compassRoseSizingClasses: '--rose-small --rose-min'
|
||||
}
|
||||
};
|
||||
|
||||
app = new Vue({
|
||||
@ -54,7 +60,6 @@ describe("The Compass component", () => {
|
||||
return propsData;
|
||||
},
|
||||
template: `<Compass
|
||||
:compass-rose-sizing-classes="compassRoseSizingClasses"
|
||||
:image="image"
|
||||
:natural-aspect-ratio="naturalAspectRatio"
|
||||
:sized-image-dimensions="sizedImageDimensions"
|
||||
@ -67,7 +72,7 @@ describe("The Compass component", () => {
|
||||
app.$destroy();
|
||||
});
|
||||
|
||||
describe("when a heading value exists on the image", () => {
|
||||
describe("when a heading value and cameraAngleOfView exists on the image", () => {
|
||||
|
||||
it("should display a compass rose", () => {
|
||||
let compassRoseElement = instance.$el.querySelector(COMPASS_ROSE_CLASS
|
||||
|
@ -39,7 +39,7 @@
|
||||
<img
|
||||
ref="img"
|
||||
class="c-thumb__image"
|
||||
:src="image.url"
|
||||
:src="`${image.thumbnailUrl || image.url}`"
|
||||
fetchpriority="low"
|
||||
@load="imageLoadCompleted"
|
||||
>
|
||||
|
@ -186,17 +186,17 @@ export default {
|
||||
item.remove();
|
||||
});
|
||||
let imagery = this.$el.querySelectorAll(`.${IMAGE_WRAPPER_CLASS}`);
|
||||
imagery.forEach(item => {
|
||||
imagery.forEach(imageElm => {
|
||||
if (clearAllImagery) {
|
||||
item.remove();
|
||||
imageElm.remove();
|
||||
} else {
|
||||
const id = item.getAttributeNS(null, 'id');
|
||||
const id = imageElm.getAttributeNS(null, 'id');
|
||||
if (id) {
|
||||
const timestamp = id.replace(ID_PREFIX, '');
|
||||
if (!this.isImageryInBounds({
|
||||
time: timestamp
|
||||
})) {
|
||||
item.remove();
|
||||
imageElm.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -343,25 +343,25 @@ export default {
|
||||
imageElement.style.display = 'block';
|
||||
}
|
||||
},
|
||||
updateExistingImageWrapper(existingImageWrapper, item, showImagePlaceholders) {
|
||||
updateExistingImageWrapper(existingImageWrapper, image, showImagePlaceholders) {
|
||||
//Update the x co-ordinates of the image wrapper and the url of image
|
||||
//this is to avoid tearing down all elements completely and re-drawing them
|
||||
this.setNSAttributesForElement(existingImageWrapper, {
|
||||
'data-show-image-placeholders': showImagePlaceholders
|
||||
});
|
||||
existingImageWrapper.style.left = `${this.xScale(item.time)}px`;
|
||||
existingImageWrapper.style.left = `${this.xScale(image.time)}px`;
|
||||
|
||||
let imageElement = existingImageWrapper.querySelector('img');
|
||||
this.setNSAttributesForElement(imageElement, {
|
||||
src: item.url
|
||||
src: image.thumbnailUrl || image.url
|
||||
});
|
||||
this.setImageDisplay(imageElement, showImagePlaceholders);
|
||||
},
|
||||
createImageWrapper(index, item, showImagePlaceholders) {
|
||||
const id = `${ID_PREFIX}${item.time}`;
|
||||
createImageWrapper(index, image, showImagePlaceholders) {
|
||||
const id = `${ID_PREFIX}${image.time}`;
|
||||
let imageWrapper = document.createElement('div');
|
||||
imageWrapper.classList.add(IMAGE_WRAPPER_CLASS);
|
||||
imageWrapper.style.left = `${this.xScale(item.time)}px`;
|
||||
imageWrapper.style.left = `${this.xScale(image.time)}px`;
|
||||
this.setNSAttributesForElement(imageWrapper, {
|
||||
id,
|
||||
'data-show-image-placeholders': showImagePlaceholders
|
||||
@ -383,7 +383,7 @@ export default {
|
||||
//create image element
|
||||
let imageElement = document.createElement('img');
|
||||
this.setNSAttributesForElement(imageElement, {
|
||||
src: item.url
|
||||
src: image.thumbnailUrl || image.url
|
||||
});
|
||||
imageElement.style.width = `${IMAGE_SIZE}px`;
|
||||
imageElement.style.height = `${IMAGE_SIZE}px`;
|
||||
@ -392,7 +392,7 @@ export default {
|
||||
//handle mousedown event to show the image in a large view
|
||||
imageWrapper.addEventListener('mousedown', (e) => {
|
||||
if (e.button === 0) {
|
||||
this.expand(item.time);
|
||||
this.expand(image.time);
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -93,9 +93,7 @@
|
||||
></div>
|
||||
<Compass
|
||||
v-if="shouldDisplayCompass"
|
||||
:compass-rose-sizing-classes="compassRoseSizingClasses"
|
||||
:image="focusedImage"
|
||||
:natural-aspect-ratio="focusedImageNaturalAspectRatio"
|
||||
:sized-image-dimensions="sizedImageDimensions"
|
||||
/>
|
||||
</div>
|
||||
@ -172,7 +170,7 @@
|
||||
>
|
||||
<ImageThumbnail
|
||||
v-for="(image, index) in imageHistory"
|
||||
:key="image.url + image.time"
|
||||
:key="`${image.thumbnailUrl || image.url}-${image.time}-${index}`"
|
||||
:image="image"
|
||||
:active="focusedImageIndex === index"
|
||||
:selected="focusedImageIndex === index && isPaused"
|
||||
@ -226,6 +224,9 @@ const SHOW_THUMBS_FULLSIZE_THRESHOLD_HEIGHT = 600;
|
||||
|
||||
const IMAGE_CONTAINER_BORDER_WIDTH = 1;
|
||||
|
||||
const DEFAULT_IMAGE_PAN_ALT_TEXT = "Alt drag to pan";
|
||||
const LINUX_IMAGE_PAN_ALT_TEXT = `Ctrl+${DEFAULT_IMAGE_PAN_ALT_TEXT}`;
|
||||
|
||||
export default {
|
||||
name: 'ImageryView',
|
||||
components: {
|
||||
@ -298,18 +299,6 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
compassRoseSizingClasses() {
|
||||
let compassRoseSizingClasses = '';
|
||||
if (this.sizedImageWidth < 300) {
|
||||
compassRoseSizingClasses = '--rose-small --rose-min';
|
||||
} else if (this.sizedImageWidth < 500) {
|
||||
compassRoseSizingClasses = '--rose-small';
|
||||
} else if (this.sizedImageWidth > 1000) {
|
||||
compassRoseSizingClasses = '--rose-max';
|
||||
}
|
||||
|
||||
return compassRoseSizingClasses;
|
||||
},
|
||||
displayThumbnails() {
|
||||
return (
|
||||
this.forceShowThumbnails
|
||||
@ -321,8 +310,8 @@ export default {
|
||||
},
|
||||
focusImageStyles() {
|
||||
return {
|
||||
'filter': `brightness(${this.filters.brightness}%) contrast(${this.filters.contrast}%)`,
|
||||
'background-image':
|
||||
filter: `brightness(${this.filters.brightness}%) contrast(${this.filters.contrast}%)`,
|
||||
backgroundImage:
|
||||
`${this.imageUrl ? (
|
||||
`url(${this.imageUrl}),
|
||||
repeating-linear-gradient(
|
||||
@ -333,10 +322,10 @@ export default {
|
||||
rgba(125,125,125,.2) 8px
|
||||
)`
|
||||
) : ''}`,
|
||||
'transform': `scale(${this.zoomFactor}) translate(${this.imageTranslateX}px, ${this.imageTranslateY}px)`,
|
||||
'transition': `${!this.pan && this.animateZoom ? 'transform 250ms ease-in' : 'initial'}`,
|
||||
'width': `${this.sizedImageWidth}px`,
|
||||
'height': `${this.sizedImageHeight}px`
|
||||
transform: `scale(${this.zoomFactor}) translate(${this.imageTranslateX}px, ${this.imageTranslateY}px)`,
|
||||
transition: `${!this.pan && this.animateZoom ? 'transform 250ms ease-in' : 'initial'}`,
|
||||
width: `${this.sizedImageWidth}px`,
|
||||
height: `${this.sizedImageHeight}px`
|
||||
};
|
||||
},
|
||||
time() {
|
||||
@ -347,11 +336,12 @@ export default {
|
||||
},
|
||||
imageWrapperStyle() {
|
||||
return {
|
||||
'cursor-zoom-in': this.cursorStates.showCursorZoomIn,
|
||||
'cursor-zoom-out': this.cursorStates.showCursorZoomOut,
|
||||
'pannable': this.cursorStates.isPannable,
|
||||
'paused unnsynced': this.isPaused && !this.isFixed,
|
||||
'stale': false
|
||||
cursorZoomIn: this.cursorStates.showCursorZoomIn,
|
||||
cursorZoomOut: this.cursorStates.showCursorZoomOut,
|
||||
pannable: this.cursorStates.isPannable,
|
||||
paused: this.isPaused && !this.isFixed,
|
||||
unsynced: this.isPaused && !this.isFixed,
|
||||
stale: false
|
||||
};
|
||||
},
|
||||
isImageNew() {
|
||||
@ -432,7 +422,6 @@ export default {
|
||||
shouldDisplayCompass() {
|
||||
const imageHeightAndWidth = this.sizedImageHeight !== 0
|
||||
&& this.sizedImageWidth !== 0;
|
||||
|
||||
const display = this.focusedImage !== undefined
|
||||
&& this.focusedImageNaturalAspectRatio !== undefined
|
||||
&& this.imageContainerWidth !== undefined
|
||||
@ -440,8 +429,12 @@ export default {
|
||||
&& imageHeightAndWidth
|
||||
&& this.zoomFactor === 1
|
||||
&& this.imagePanned !== true;
|
||||
const hasHeading = this.focusedImage?.heading !== undefined;
|
||||
const hasCameraAngleOfView = this.focusedImage?.transformations?.cameraAngleOfView > 0;
|
||||
|
||||
return display;
|
||||
return display
|
||||
&& hasCameraAngleOfView
|
||||
&& hasHeading;
|
||||
},
|
||||
isSpacecraftPositionFresh() {
|
||||
let isFresh = undefined;
|
||||
@ -528,10 +521,10 @@ export default {
|
||||
const navigator = window.navigator.userAgent;
|
||||
|
||||
if (regexLinux.test(navigator)) {
|
||||
return 'Ctrl+Alt drag to pan';
|
||||
return LINUX_IMAGE_PAN_ALT_TEXT;
|
||||
}
|
||||
|
||||
return 'Alt drag to pan';
|
||||
return DEFAULT_IMAGE_PAN_ALT_TEXT;
|
||||
},
|
||||
viewableArea() {
|
||||
if (this.zoomFactor === 1) {
|
||||
@ -591,11 +584,34 @@ export default {
|
||||
},
|
||||
deep: true
|
||||
},
|
||||
focusedImageIndex() {
|
||||
this.trackDuration();
|
||||
this.resetAgeCSS();
|
||||
this.updateRelatedTelemetryForFocusedImage();
|
||||
this.getImageNaturalDimensions();
|
||||
focusedImage: {
|
||||
handler(newImage, oldImage) {
|
||||
const newTime = newImage?.time;
|
||||
const oldTime = oldImage?.time;
|
||||
const newUrl = newImage?.url;
|
||||
const oldUrl = oldImage?.url;
|
||||
|
||||
// Skip if it's all falsy
|
||||
if (!newTime && !oldTime && !newUrl && !oldUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if it's the same image
|
||||
if (newTime === oldTime && newUrl === oldUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update image duration and reset age CSS
|
||||
this.trackDuration();
|
||||
this.resetAgeCSS();
|
||||
|
||||
// Reset image dimensions and calculate new dimensions
|
||||
// on new image load
|
||||
this.getImageNaturalDimensions();
|
||||
|
||||
// Get the related telemetry for the new image
|
||||
this.updateRelatedTelemetryForFocusedImage();
|
||||
}
|
||||
},
|
||||
bounds() {
|
||||
this.scrollHandler();
|
||||
@ -626,6 +642,7 @@ export default {
|
||||
this.spacecraftOrientationKeys = ['heading'];
|
||||
this.cameraKeys = ['cameraPan', 'cameraTilt'];
|
||||
this.sunKeys = ['sunOrientation'];
|
||||
this.transformationsKeys = ['transformations'];
|
||||
|
||||
// related telemetry
|
||||
await this.initializeRelatedTelemetry();
|
||||
@ -691,9 +708,9 @@ export default {
|
||||
},
|
||||
getVisibleLayerStyles(layer) {
|
||||
return {
|
||||
'background-image': `url(${layer.source})`,
|
||||
'transform': `scale(${this.zoomFactor}) translate(${this.imageTranslateX}px, ${this.imageTranslateY}px)`,
|
||||
'transition': `${!this.pan && this.animateZoom ? 'transform 250ms ease-in' : 'initial'}`
|
||||
backgroundImage: `url(${layer.source})`,
|
||||
transform: `scale(${this.zoomFactor}) translate(${this.imageTranslateX}px, ${this.imageTranslateY}px)`,
|
||||
transition: `${!this.pan && this.animateZoom ? 'transform 250ms ease-in' : 'initial'}`
|
||||
};
|
||||
},
|
||||
setTimeContext() {
|
||||
@ -721,14 +738,20 @@ export default {
|
||||
&& visibleActions.find(action => action.key === 'large.view');
|
||||
|
||||
if (viewLargeAction && viewLargeAction.appliesTo(this.objectPath, this.currentView)) {
|
||||
viewLargeAction.onItemClicked();
|
||||
viewLargeAction.invoke(this.objectPath, this.currentView);
|
||||
}
|
||||
},
|
||||
async initializeRelatedTelemetry() {
|
||||
this.relatedTelemetry = new RelatedTelemetry(
|
||||
this.openmct,
|
||||
this.domainObject,
|
||||
[...this.spacecraftPositionKeys, ...this.spacecraftOrientationKeys, ...this.cameraKeys, ...this.sunKeys]
|
||||
[
|
||||
...this.spacecraftPositionKeys,
|
||||
...this.spacecraftOrientationKeys,
|
||||
...this.cameraKeys,
|
||||
...this.sunKeys,
|
||||
...this.transformationsKeys
|
||||
]
|
||||
);
|
||||
|
||||
if (this.relatedTelemetry.hasRelatedTelemetry) {
|
||||
@ -765,26 +788,30 @@ export default {
|
||||
return mostRecent[valueKey];
|
||||
},
|
||||
loadVisibleLayers() {
|
||||
const metaDataValues = this.metadata.valuesForHints(['image'])[0];
|
||||
this.imageFormat = this.openmct.telemetry.getValueFormatter(metaDataValues);
|
||||
let layersMetadata = metaDataValues.layers;
|
||||
if (layersMetadata) {
|
||||
this.layers = layersMetadata;
|
||||
if (this.domainObject.configuration) {
|
||||
let persistedLayers = this.domainObject.configuration.layers;
|
||||
layersMetadata.forEach((layer) => {
|
||||
const persistedLayer = persistedLayers.find(object => object.name === layer.name);
|
||||
if (persistedLayer) {
|
||||
layer.visible = persistedLayer.visible === true;
|
||||
}
|
||||
});
|
||||
this.visibleLayers = this.layers.filter(layer => layer.visible);
|
||||
} else {
|
||||
this.visibleLayers = [];
|
||||
this.layers.forEach((layer) => {
|
||||
layer.visible = false;
|
||||
});
|
||||
const layersMetadata = this.imageMetadataValue.layers;
|
||||
if (!layersMetadata) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.layers = layersMetadata;
|
||||
if (this.domainObject.configuration) {
|
||||
const persistedLayers = this.domainObject.configuration.layers;
|
||||
if (!persistedLayers) {
|
||||
return;
|
||||
}
|
||||
|
||||
layersMetadata.forEach((layer) => {
|
||||
const persistedLayer = persistedLayers.find(object => object.name === layer.name);
|
||||
if (persistedLayer) {
|
||||
layer.visible = persistedLayer.visible === true;
|
||||
}
|
||||
});
|
||||
this.visibleLayers = this.layers.filter(layer => layer.visible);
|
||||
} else {
|
||||
this.visibleLayers = [];
|
||||
this.layers.forEach((layer) => {
|
||||
layer.visible = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
persistVisibleLayers() {
|
||||
@ -837,6 +864,15 @@ export default {
|
||||
this.$set(this.focusedImageRelatedTelemetry, key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// set configuration for compass
|
||||
this.transformationsKeys.forEach(key => {
|
||||
const transformations = this.relatedTelemetry[key];
|
||||
|
||||
if (transformations !== undefined) {
|
||||
this.$set(this.imageHistory[this.focusedImageIndex], key, transformations);
|
||||
}
|
||||
});
|
||||
},
|
||||
trackLatestRelatedTelemetry() {
|
||||
[...this.spacecraftPositionKeys, ...this.spacecraftOrientationKeys, ...this.cameraKeys, ...this.sunKeys].forEach(key => {
|
||||
|
@ -29,7 +29,7 @@
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
|
||||
&.unnsynced{
|
||||
&.unsynced{
|
||||
@include sUnsynced();
|
||||
}
|
||||
|
||||
|
@ -21,6 +21,9 @@
|
||||
*****************************************************************************/
|
||||
|
||||
const DEFAULT_DURATION_FORMATTER = 'duration';
|
||||
const IMAGE_HINT_KEY = 'image';
|
||||
const IMAGE_THUMBNAIL_HINT_KEY = 'thumbnail';
|
||||
const IMAGE_DOWNLOAD_NAME_HINT_KEY = 'imageDownloadName';
|
||||
|
||||
export default {
|
||||
inject: ['openmct', 'domainObject', 'objectPath'],
|
||||
@ -32,13 +35,20 @@ export default {
|
||||
this.setDataTimeContext();
|
||||
this.openmct.objectViews.on('clearData', this.dataCleared);
|
||||
|
||||
// set
|
||||
// Get metadata and formatters
|
||||
this.keyString = this.openmct.objects.makeKeyString(this.domainObject.identifier);
|
||||
this.metadata = this.openmct.telemetry.getMetadata(this.domainObject);
|
||||
this.imageHints = { ...this.metadata.valuesForHints(['image'])[0] };
|
||||
|
||||
this.imageMetadataValue = { ...this.metadata.valuesForHints([IMAGE_HINT_KEY])[0] };
|
||||
this.imageFormatter = this.getFormatter(this.imageMetadataValue.key);
|
||||
|
||||
this.imageThumbnailMetadataValue = { ...this.metadata.valuesForHints([IMAGE_THUMBNAIL_HINT_KEY])[0] };
|
||||
this.imageThumbnailFormatter = this.imageThumbnailMetadataValue.key
|
||||
? this.getFormatter(this.imageThumbnailMetadataValue.key)
|
||||
: null;
|
||||
|
||||
this.durationFormatter = this.getFormatter(this.timeSystem.durationFormat || DEFAULT_DURATION_FORMATTER);
|
||||
this.imageFormatter = this.openmct.telemetry.getValueFormatter(this.imageHints);
|
||||
this.imageDownloadNameHints = { ...this.metadata.valuesForHints(['imageDownloadName'])[0]};
|
||||
this.imageDownloadNameMetadataValue = { ...this.metadata.valuesForHints([IMAGE_DOWNLOAD_NAME_HINT_KEY])[0]};
|
||||
|
||||
// initialize
|
||||
this.timeKey = this.timeSystem.key;
|
||||
@ -66,9 +76,14 @@ export default {
|
||||
this.telemetryCollection.destroy();
|
||||
},
|
||||
methods: {
|
||||
dataAdded(dataToAdd) {
|
||||
const normalizedDataToAdd = dataToAdd.map(datum => this.normalizeDatum(datum));
|
||||
this.imageHistory = this.imageHistory.concat(normalizedDataToAdd);
|
||||
dataAdded(addedItems, addedItemIndices) {
|
||||
const normalizedDataToAdd = addedItems.map(datum => this.normalizeDatum(datum));
|
||||
let newImageHistory = this.imageHistory.slice();
|
||||
normalizedDataToAdd.forEach(((datum, index) => {
|
||||
newImageHistory.splice(addedItemIndices[index] ?? -1, 0, datum);
|
||||
}));
|
||||
//Assign just once so imageHistory watchers don't get called too often
|
||||
this.imageHistory = newImageHistory;
|
||||
},
|
||||
dataCleared() {
|
||||
this.imageHistory = [];
|
||||
@ -105,12 +120,19 @@ export default {
|
||||
|
||||
return this.imageFormatter.format(datum);
|
||||
},
|
||||
formatImageThumbnailUrl(datum) {
|
||||
if (!datum || !this.imageThumbnailFormatter) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.imageThumbnailFormatter.format(datum);
|
||||
},
|
||||
formatTime(datum) {
|
||||
if (!datum) {
|
||||
return;
|
||||
}
|
||||
|
||||
let dateTimeStr = this.timeFormatter.format(datum);
|
||||
const dateTimeStr = this.timeFormatter.format(datum);
|
||||
|
||||
// Replace ISO "T" with a space to allow wrapping
|
||||
return dateTimeStr.replace("T", " ");
|
||||
@ -118,7 +140,7 @@ export default {
|
||||
getImageDownloadName(datum) {
|
||||
let imageDownloadName = '';
|
||||
if (datum) {
|
||||
const key = this.imageDownloadNameHints.key;
|
||||
const key = this.imageDownloadNameMetadataValue.key;
|
||||
imageDownloadName = datum[key];
|
||||
}
|
||||
|
||||
@ -136,9 +158,6 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
// forcibly reset the imageContainer size to prevent an aspect ratio distortion
|
||||
delete this.imageContainerWidth;
|
||||
delete this.imageContainerHeight;
|
||||
this.bounds = bounds; // setting bounds for ImageryView watcher
|
||||
},
|
||||
timeSystemChange() {
|
||||
@ -150,6 +169,7 @@ export default {
|
||||
normalizeDatum(datum) {
|
||||
const formattedTime = this.formatTime(datum);
|
||||
const url = this.formatImageUrl(datum);
|
||||
const thumbnailUrl = this.formatImageThumbnailUrl(datum);
|
||||
const time = this.parseTime(formattedTime);
|
||||
const imageDownloadName = this.getImageDownloadName(datum);
|
||||
|
||||
@ -157,13 +177,14 @@ export default {
|
||||
...datum,
|
||||
formattedTime,
|
||||
url,
|
||||
thumbnailUrl,
|
||||
time,
|
||||
imageDownloadName
|
||||
};
|
||||
},
|
||||
getFormatter(key) {
|
||||
let metadataValue = this.metadata.value(key) || { format: key };
|
||||
let valueFormatter = this.openmct.telemetry.getValueFormatter(metadataValue);
|
||||
const metadataValue = this.metadata.value(key) || { format: key };
|
||||
const valueFormatter = this.openmct.telemetry.getValueFormatter(metadataValue);
|
||||
|
||||
return valueFormatter;
|
||||
}
|
||||
|
@ -35,6 +35,10 @@ const MAIN_IMAGE_CLASS = '.js-imageryView-image';
|
||||
const NEW_IMAGE_CLASS = '.c-imagery__age.c-imagery--new';
|
||||
const REFRESH_CSS_MS = 500;
|
||||
|
||||
function formatThumbnail(url) {
|
||||
return url.replace('logo-openmct.svg', 'logo-nasa.svg');
|
||||
}
|
||||
|
||||
function getImageInfo(doc) {
|
||||
let imageElement = doc.querySelectorAll(MAIN_IMAGE_CLASS)[0];
|
||||
let timestamp = imageElement.dataset.openmctImageTimestamp;
|
||||
@ -124,6 +128,16 @@ describe("The Imagery View Layouts", () => {
|
||||
},
|
||||
"source": "url"
|
||||
},
|
||||
{
|
||||
"name": "Image Thumbnail",
|
||||
"key": "thumbnail-url",
|
||||
"format": "thumbnail",
|
||||
"hints": {
|
||||
"thumbnail": 1,
|
||||
"priority": 3
|
||||
},
|
||||
"source": "url"
|
||||
},
|
||||
{
|
||||
"name": "Name",
|
||||
"key": "name",
|
||||
@ -200,6 +214,11 @@ describe("The Imagery View Layouts", () => {
|
||||
|
||||
originalRouterPath = openmct.router.path;
|
||||
|
||||
openmct.telemetry.addFormat({
|
||||
key: 'thumbnail',
|
||||
format: formatThumbnail
|
||||
});
|
||||
|
||||
openmct.on('start', done);
|
||||
openmct.startHeadless();
|
||||
});
|
||||
@ -384,15 +403,32 @@ describe("The Imagery View Layouts", () => {
|
||||
//Looks like we need Vue.nextTick here so that computed properties settle down
|
||||
await Vue.nextTick();
|
||||
const layerEls = parent.querySelectorAll('.js-layer-image');
|
||||
console.log(layerEls);
|
||||
expect(layerEls.length).toEqual(1);
|
||||
});
|
||||
|
||||
it("should use the image thumbnailUrl for thumbnails", async () => {
|
||||
await Vue.nextTick();
|
||||
const fullSizeImageUrl = imageTelemetry[5].url;
|
||||
const thumbnailUrl = formatThumbnail(imageTelemetry[5].url);
|
||||
|
||||
// Ensure thumbnails are shown w/ thumbnail Urls
|
||||
const thumbnails = parent.querySelectorAll(`img[src='${thumbnailUrl}']`);
|
||||
expect(thumbnails.length).toBeGreaterThan(0);
|
||||
|
||||
// Click a thumbnail
|
||||
parent.querySelectorAll(`img[src='${thumbnailUrl}']`)[0].click();
|
||||
await Vue.nextTick();
|
||||
|
||||
// Ensure full size image is shown w/ full size url
|
||||
const fullSizeImages = parent.querySelectorAll(`img[src='${fullSizeImageUrl}']`);
|
||||
expect(fullSizeImages.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should show the clicked thumbnail as the main image", async () => {
|
||||
//Looks like we need Vue.nextTick here so that computed properties settle down
|
||||
await Vue.nextTick();
|
||||
const target = imageTelemetry[5].url;
|
||||
parent.querySelectorAll(`img[src='${target}']`)[0].click();
|
||||
const thumbnailUrl = formatThumbnail(imageTelemetry[5].url);
|
||||
parent.querySelectorAll(`img[src='${thumbnailUrl}']`)[0].click();
|
||||
await Vue.nextTick();
|
||||
const imageInfo = getImageInfo(parent);
|
||||
|
||||
@ -417,7 +453,7 @@ describe("The Imagery View Layouts", () => {
|
||||
|
||||
it("should show that an image is not new", async () => {
|
||||
await Vue.nextTick();
|
||||
const target = imageTelemetry[4].url;
|
||||
const target = formatThumbnail(imageTelemetry[4].url);
|
||||
parent.querySelectorAll(`img[src='${target}']`)[0].click();
|
||||
|
||||
await Vue.nextTick();
|
||||
|
@ -30,6 +30,7 @@ export default class LinkAction {
|
||||
this.priority = 7;
|
||||
|
||||
this.openmct = openmct;
|
||||
this.transaction = null;
|
||||
}
|
||||
|
||||
appliesTo(objectPath) {
|
||||
@ -48,7 +49,9 @@ export default class LinkAction {
|
||||
}
|
||||
|
||||
onSave(changes) {
|
||||
let inNavigationPath = this.inNavigationPath();
|
||||
this.startTransaction();
|
||||
|
||||
const inNavigationPath = this.inNavigationPath();
|
||||
if (inNavigationPath && this.openmct.editor.isEditing()) {
|
||||
this.openmct.editor.save();
|
||||
}
|
||||
@ -57,6 +60,8 @@ export default class LinkAction {
|
||||
const parent = parentDomainObjectpath[0];
|
||||
|
||||
this.linkInNewParent(this.object, parent);
|
||||
|
||||
return this.saveTransaction();
|
||||
}
|
||||
|
||||
linkInNewParent(child, newParent) {
|
||||
@ -128,4 +133,19 @@ export default class LinkAction {
|
||||
return parentCandidate && this.openmct.composition.checkPolicy(parentCandidate, this.object);
|
||||
};
|
||||
}
|
||||
startTransaction() {
|
||||
if (!this.openmct.objects.isTransactionActive()) {
|
||||
this.transaction = this.openmct.objects.startTransaction();
|
||||
}
|
||||
}
|
||||
|
||||
async saveTransaction() {
|
||||
if (!this.transaction) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.transaction.commit();
|
||||
this.openmct.objects.endTransaction();
|
||||
this.transaction = null;
|
||||
}
|
||||
}
|
||||
|
@ -29,6 +29,7 @@ export default class MoveAction {
|
||||
this.priority = 7;
|
||||
|
||||
this.openmct = openmct;
|
||||
this.transaction = null;
|
||||
}
|
||||
|
||||
invoke(objectPath) {
|
||||
@ -60,6 +61,8 @@ export default class MoveAction {
|
||||
}
|
||||
|
||||
async onSave(changes) {
|
||||
this.startTransaction();
|
||||
|
||||
let inNavigationPath = this.inNavigationPath(this.object);
|
||||
if (inNavigationPath && this.openmct.editor.isEditing()) {
|
||||
this.openmct.editor.save();
|
||||
@ -81,6 +84,8 @@ export default class MoveAction {
|
||||
this.addToNewParent(this.object, parent);
|
||||
this.removeFromOldParent(this.object);
|
||||
|
||||
await this.saveTransaction();
|
||||
|
||||
if (!inNavigationPath) {
|
||||
return;
|
||||
}
|
||||
@ -189,4 +194,20 @@ export default class MoveAction {
|
||||
&& childType.definition.creatable
|
||||
&& Array.isArray(parent.composition);
|
||||
}
|
||||
|
||||
startTransaction() {
|
||||
if (!this.openmct.objects.isTransactionActive()) {
|
||||
this.transaction = this.openmct.objects.startTransaction();
|
||||
}
|
||||
}
|
||||
|
||||
async saveTransaction() {
|
||||
if (!this.transaction) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.transaction.commit();
|
||||
this.openmct.objects.endTransaction();
|
||||
this.transaction = null;
|
||||
}
|
||||
}
|
||||
|
@ -25,13 +25,14 @@ import Notebook from './components/Notebook.vue';
|
||||
import Agent from '@/utils/agent/Agent';
|
||||
|
||||
export default class NotebookViewProvider {
|
||||
constructor(openmct, name, key, type, cssClass, snapshotContainer) {
|
||||
constructor(openmct, name, key, type, cssClass, snapshotContainer, entryUrlWhitelist) {
|
||||
this.openmct = openmct;
|
||||
this.key = key;
|
||||
this.name = `${name} View`;
|
||||
this.type = type;
|
||||
this.cssClass = cssClass;
|
||||
this.snapshotContainer = snapshotContainer;
|
||||
this.entryUrlWhitelist = entryUrlWhitelist;
|
||||
}
|
||||
|
||||
canView(domainObject) {
|
||||
@ -43,6 +44,7 @@ export default class NotebookViewProvider {
|
||||
let openmct = this.openmct;
|
||||
let snapshotContainer = this.snapshotContainer;
|
||||
let agent = new Agent(window);
|
||||
let entryUrlWhitelist = this.entryUrlWhitelist;
|
||||
|
||||
return {
|
||||
show(container) {
|
||||
@ -54,7 +56,8 @@ export default class NotebookViewProvider {
|
||||
provide: {
|
||||
openmct,
|
||||
snapshotContainer,
|
||||
agent
|
||||
agent,
|
||||
entryUrlWhitelist
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
@ -162,10 +162,12 @@
|
||||
:selected-section="selectedSection"
|
||||
:read-only="false"
|
||||
:is-locked="selectedPage.isLocked"
|
||||
:selected-entry-id="selectedEntryId"
|
||||
@cancelEdit="cancelTransaction"
|
||||
@editingEntry="startTransaction"
|
||||
@deleteEntry="deleteEntry"
|
||||
@updateEntry="updateEntry"
|
||||
@entry-selection="entrySelection(entry)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
@ -234,6 +236,7 @@ export default {
|
||||
sidebarCoversEntries: false,
|
||||
filteredAndSortedEntries: [],
|
||||
notebookAnnotations: {},
|
||||
selectedEntryId: '',
|
||||
activeTransaction: false,
|
||||
savingTransaction: false
|
||||
};
|
||||
@ -321,6 +324,7 @@ export default {
|
||||
this.formatSidebar();
|
||||
this.setSectionAndPageFromUrl();
|
||||
|
||||
this.openmct.selection.on('change', this.updateSelection);
|
||||
this.transaction = null;
|
||||
|
||||
window.addEventListener('orientationchange', this.formatSidebar);
|
||||
@ -346,6 +350,7 @@ export default {
|
||||
|
||||
window.removeEventListener('orientationchange', this.formatSidebar);
|
||||
window.removeEventListener('hashchange', this.setSectionAndPageFromUrl);
|
||||
this.openmct.selection.off('change', this.updateSelection);
|
||||
},
|
||||
updated: function () {
|
||||
this.$nextTick(() => {
|
||||
@ -375,15 +380,20 @@ export default {
|
||||
}
|
||||
});
|
||||
},
|
||||
updateSelection(selection) {
|
||||
if (selection?.[0]?.[0]?.context?.targetDetails?.entryId === undefined) {
|
||||
this.selectedEntryId = '';
|
||||
}
|
||||
},
|
||||
async loadAnnotations() {
|
||||
if (!this.openmct.annotation.getAvailableTags().length) {
|
||||
// don't bother loading annotations if there are no tags
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastLocalAnnotationCreation = this.domainObject.annotationLastCreated ?? 0;
|
||||
|
||||
const query = this.openmct.objects.makeKeyString(this.domainObject.identifier);
|
||||
const foundAnnotations = await this.openmct.annotation.getAnnotations(query);
|
||||
const foundAnnotations = await this.openmct.annotation.getAnnotations(this.domainObject.identifier);
|
||||
foundAnnotations.forEach((foundAnnotation) => {
|
||||
const targetId = Object.keys(foundAnnotation.targets)[0];
|
||||
const entryId = foundAnnotation.targets[targetId].entryId;
|
||||
@ -941,6 +951,9 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
entrySelection(entry) {
|
||||
this.selectedEntryId = entry.id;
|
||||
},
|
||||
endTransaction() {
|
||||
this.openmct.objects.endTransaction();
|
||||
this.transaction = null;
|
||||
|
@ -12,14 +12,15 @@
|
||||
<a
|
||||
class="c-ne__embed__link"
|
||||
:class="embed.cssClass"
|
||||
@click="changeLocation"
|
||||
@click="navigateToItemInTime"
|
||||
>{{ embed.name }}</a>
|
||||
<PopupMenu :popup-menu-items="popupMenuItems" />
|
||||
<button
|
||||
class="c-ne__embed__actions c-icon-button icon-3-dots"
|
||||
title="More options"
|
||||
@click.prevent.stop="showMenuItems($event)"
|
||||
></button>
|
||||
</div>
|
||||
<div
|
||||
v-if="embed.snapshot"
|
||||
class="c-ne__embed__time"
|
||||
>
|
||||
<div class="c-ne__embed__time">
|
||||
{{ createdOn }}
|
||||
</div>
|
||||
</div>
|
||||
@ -32,17 +33,14 @@ import PreviewAction from '../../../ui/preview/PreviewAction';
|
||||
import RemoveDialog from '../utils/removeDialog';
|
||||
import PainterroInstance from '../utils/painterroInstance';
|
||||
import SnapshotTemplate from './snapshot-template.html';
|
||||
import objectPathToUrl from '@/tools/url';
|
||||
|
||||
import { updateNotebookImageDomainObject } from '../utils/notebook-image';
|
||||
import ImageExporter from '../../../exporters/ImageExporter';
|
||||
|
||||
import PopupMenu from './PopupMenu.vue';
|
||||
import Vue from 'vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
PopupMenu
|
||||
},
|
||||
inject: ['openmct', 'snapshotContainer'],
|
||||
props: {
|
||||
embed: {
|
||||
@ -72,7 +70,7 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
popupMenuItems: []
|
||||
menuActions: []
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@ -88,37 +86,88 @@ export default {
|
||||
watch: {
|
||||
isLocked(value) {
|
||||
if (value === true) {
|
||||
let index = this.popupMenuItems.findIndex((item) => item.id === 'removeEmbed');
|
||||
let index = this.menuActions.findIndex((item) => item.id === 'removeEmbed');
|
||||
|
||||
this.$delete(this.popupMenuItems, index);
|
||||
this.$delete(this.menuActions, index);
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.addPopupMenuItems();
|
||||
async mounted() {
|
||||
this.objectPath = [];
|
||||
await this.setEmbedObjectPath();
|
||||
this.addMenuActions();
|
||||
this.imageExporter = new ImageExporter(this.openmct);
|
||||
},
|
||||
methods: {
|
||||
addPopupMenuItems() {
|
||||
const removeEmbed = {
|
||||
id: 'removeEmbed',
|
||||
cssClass: 'icon-trash',
|
||||
name: this.removeActionString,
|
||||
callback: this.getRemoveDialog.bind(this)
|
||||
};
|
||||
const preview = {
|
||||
id: 'preview',
|
||||
cssClass: 'icon-eye-open',
|
||||
name: 'Preview',
|
||||
callback: this.previewEmbed.bind(this)
|
||||
showMenuItems(event) {
|
||||
const x = event.x;
|
||||
const y = event.y;
|
||||
|
||||
const menuOptions = {
|
||||
menuClass: 'c-ne__embed__actions-menu',
|
||||
placement: this.openmct.menus.menuPlacement.TOP_RIGHT
|
||||
};
|
||||
|
||||
this.popupMenuItems = [preview];
|
||||
this.openmct.menus.showSuperMenu(x, y, this.menuActions, menuOptions);
|
||||
},
|
||||
addMenuActions() {
|
||||
if (this.embed.snapshot) {
|
||||
const viewSnapshot = {
|
||||
id: 'viewSnapshot',
|
||||
cssClass: 'icon-camera',
|
||||
name: 'View Snapshot',
|
||||
description: 'View the snapshot image taken in the form of a jpeg.',
|
||||
onItemClicked: () => this.openSnapshot()
|
||||
};
|
||||
|
||||
if (!this.isLocked) {
|
||||
this.popupMenuItems.unshift(removeEmbed);
|
||||
this.menuActions = [viewSnapshot];
|
||||
}
|
||||
|
||||
const navigateToItem = {
|
||||
id: 'navigateToItem',
|
||||
cssClass: this.embed.cssClass,
|
||||
name: 'Navigate to Item',
|
||||
description: 'Navigate to the item with the current time settings.',
|
||||
onItemClicked: () => this.navigateToItem()
|
||||
};
|
||||
|
||||
const navigateToItemInTime = {
|
||||
id: 'navigateToItemInTime',
|
||||
cssClass: this.embed.cssClass,
|
||||
name: 'Navigate to Item in Time',
|
||||
description: 'Navigate to the item in its time frame when captured.',
|
||||
onItemClicked: () => this.navigateToItemInTime()
|
||||
};
|
||||
|
||||
const quickView = {
|
||||
id: 'quickView',
|
||||
cssClass: 'icon-eye-open',
|
||||
name: 'Quick View',
|
||||
description: 'Full screen overlay view of the item.',
|
||||
onItemClicked: () => this.previewEmbed()
|
||||
};
|
||||
|
||||
this.menuActions = this.menuActions.concat([quickView, navigateToItem, navigateToItemInTime]);
|
||||
|
||||
if (!this.isLocked) {
|
||||
const removeEmbed = {
|
||||
id: 'removeEmbed',
|
||||
cssClass: 'icon-trash',
|
||||
name: this.removeActionString,
|
||||
description: 'Permanently delete this embed from this Notebook entry.',
|
||||
onItemClicked: this.getRemoveDialog.bind(this)
|
||||
};
|
||||
|
||||
this.menuActions.push(removeEmbed);
|
||||
}
|
||||
|
||||
},
|
||||
async setEmbedObjectPath() {
|
||||
this.objectPath = await this.openmct.objects.getOriginalPath(this.embed.domainObject.identifier);
|
||||
|
||||
if (this.objectPath.length > 0 && this.objectPath[this.objectPath.length - 1].type === 'root') {
|
||||
this.objectPath.pop();
|
||||
}
|
||||
},
|
||||
annotateSnapshot() {
|
||||
const annotateVue = new Vue({
|
||||
@ -179,7 +228,11 @@ export default {
|
||||
painterroInstance.show(object.configuration.fullSizeImageURL);
|
||||
});
|
||||
},
|
||||
changeLocation() {
|
||||
navigateToItem() {
|
||||
const url = objectPathToUrl(this.openmct, this.objectPath);
|
||||
this.openmct.router.navigate(url);
|
||||
},
|
||||
navigateToItemInTime() {
|
||||
const hash = this.embed.historicLink;
|
||||
|
||||
const bounds = this.openmct.time.bounds();
|
||||
|
@ -1,3 +1,4 @@
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
/*****************************************************************************
|
||||
* Open MCT, Copyright (c) 2014-2022, United States Government
|
||||
* as represented by the Administrator of the National Aeronautics and Space
|
||||
@ -22,23 +23,37 @@
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="c-notebook__entry c-ne has-local-controls has-tag-applier"
|
||||
class="c-notebook__entry c-ne has-local-controls"
|
||||
aria-label="Notebook Entry"
|
||||
:class="{ 'locked': isLocked }"
|
||||
:class="{ 'locked': isLocked, 'is-selected': isSelectedEntry }"
|
||||
@dragover="changeCursor"
|
||||
@drop.capture="cancelEditMode"
|
||||
@drop.prevent="dropOnEntry"
|
||||
@click="selectEntry($event, entry)"
|
||||
>
|
||||
<div class="c-ne__time-and-content">
|
||||
<div class="c-ne__time-and-creator">
|
||||
<span class="c-ne__created-date">{{ createdOnDate }}</span>
|
||||
<span class="c-ne__created-time">{{ createdOnTime }}</span>
|
||||
|
||||
<div class="c-ne__time-and-creator-and-delete">
|
||||
<div class="c-ne__time-and-creator">
|
||||
<span class="c-ne__created-date">{{ createdOnDate }}</span>
|
||||
<span class="c-ne__created-time">{{ createdOnTime }}</span>
|
||||
<span
|
||||
v-if="entry.createdBy"
|
||||
class="c-ne__creator"
|
||||
>
|
||||
<span class="icon-person"></span> {{ entry.createdBy }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="entry.createdBy"
|
||||
class="c-ne__creator"
|
||||
v-if="!readOnly && !isLocked"
|
||||
class="c-ne__local-controls--hidden"
|
||||
>
|
||||
<span class="icon-person"></span> {{ entry.createdBy }}
|
||||
<button
|
||||
class="c-ne__remove c-icon-button c-icon-button--major icon-trash"
|
||||
title="Delete this entry"
|
||||
tabindex="-1"
|
||||
@click="deleteEntry"
|
||||
>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="c-ne__content">
|
||||
@ -49,7 +64,7 @@
|
||||
tabindex="0"
|
||||
>
|
||||
<TextHighlight
|
||||
:text="entryText"
|
||||
:text="formatValidUrls(entry.text)"
|
||||
:highlight="highlightText"
|
||||
:highlight-class="'search-highlight'"
|
||||
/>
|
||||
@ -61,12 +76,14 @@
|
||||
class="c-ne__text c-ne__input"
|
||||
aria-label="Notebook Entry Input"
|
||||
tabindex="0"
|
||||
contenteditable="true"
|
||||
:contenteditable="canEdit"
|
||||
v-bind.prop="formattedText"
|
||||
@mouseover="checkEditability($event)"
|
||||
@mouseleave="canEdit = true"
|
||||
@focus="editingEntry()"
|
||||
@blur="updateEntryValue($event)"
|
||||
@keydown.enter.exact.prevent
|
||||
@keyup.enter.exact.prevent="forceBlur($event)"
|
||||
v-text="entry.text"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
@ -77,43 +94,42 @@
|
||||
class="c-ne__text"
|
||||
contenteditable="false"
|
||||
tabindex="0"
|
||||
v-text="entry.text"
|
||||
v-bind.prop="formattedText"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<TagEditor
|
||||
:domain-object="domainObject"
|
||||
:annotations="notebookAnnotations"
|
||||
:annotation-type="openmct.annotation.ANNOTATION_TYPES.NOTEBOOK"
|
||||
:target-specific-details="{entryId: entry.id}"
|
||||
@tags-updated="timestampAndUpdate"
|
||||
/>
|
||||
<div class="c-ne__tags c-tag-holder">
|
||||
<div
|
||||
v-for="(tag, index) in entryTags"
|
||||
:key="index"
|
||||
class="c-tag"
|
||||
:style="{ backgroundColor: tag.backgroundColor, color: tag.foregroundColor }"
|
||||
>
|
||||
{{ tag.label }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="c-snapshots c-ne__embeds">
|
||||
<NotebookEmbed
|
||||
v-for="embed in entry.embeds"
|
||||
:key="embed.id"
|
||||
:embed="embed"
|
||||
:is-locked="isLocked"
|
||||
@removeEmbed="removeEmbed"
|
||||
@updateEmbed="updateEmbed"
|
||||
/>
|
||||
<div
|
||||
:class="{'c-scrollcontainer': enableEmbedsWrapperScroll }"
|
||||
>
|
||||
<div
|
||||
ref="embedsWrapper"
|
||||
class="c-snapshots c-ne__embeds-wrapper"
|
||||
>
|
||||
<NotebookEmbed
|
||||
v-for="embed in entry.embeds"
|
||||
ref="embeds"
|
||||
:key="embed.id"
|
||||
:embed="embed"
|
||||
:is-locked="isLocked"
|
||||
@removeEmbed="removeEmbed"
|
||||
@updateEmbed="updateEmbed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!readOnly && !isLocked"
|
||||
class="c-ne__local-controls--hidden"
|
||||
>
|
||||
<button
|
||||
class="c-icon-button c-icon-button--major icon-trash"
|
||||
title="Delete this entry"
|
||||
tabindex="-1"
|
||||
@click="deleteEntry"
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="readOnly"
|
||||
class="c-ne__section-and-page"
|
||||
@ -139,22 +155,28 @@
|
||||
|
||||
<script>
|
||||
import NotebookEmbed from './NotebookEmbed.vue';
|
||||
import TagEditor from '../../../ui/components/tags/TagEditor.vue';
|
||||
import TextHighlight from '../../../utils/textHighlight/TextHighlight.vue';
|
||||
import { createNewEmbed } from '../utils/notebook-entries';
|
||||
import { saveNotebookImageDomainObject, updateNamespaceOfDomainObject } from '../utils/notebook-image';
|
||||
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import _ from 'lodash';
|
||||
|
||||
import Moment from 'moment';
|
||||
|
||||
const SANITIZATION_SCHEMA = {
|
||||
allowedTags: [],
|
||||
allowedAttributes: {}
|
||||
};
|
||||
const URL_REGEX = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/g;
|
||||
const UNKNOWN_USER = 'Unknown';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NotebookEmbed,
|
||||
TextHighlight,
|
||||
TagEditor
|
||||
TextHighlight
|
||||
},
|
||||
inject: ['openmct', 'snapshotContainer'],
|
||||
inject: ['openmct', 'snapshotContainer', 'entryUrlWhitelist'],
|
||||
props: {
|
||||
domainObject: {
|
||||
type: Object,
|
||||
@ -203,8 +225,22 @@ export default {
|
||||
default() {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
selectedEntryId: {
|
||||
type: String,
|
||||
default() {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
editMode: false,
|
||||
canEdit: true,
|
||||
enableEmbedsWrapperScroll: false,
|
||||
urlWhitelist: []
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
createdOnDate() {
|
||||
return this.formatTime(this.entry.createdOn, 'YYYY-MM-DD');
|
||||
@ -212,6 +248,26 @@ export default {
|
||||
createdOnTime() {
|
||||
return this.formatTime(this.entry.createdOn, 'HH:mm:ss');
|
||||
},
|
||||
formattedText() {
|
||||
// remove ANY tags
|
||||
const text = sanitizeHtml(this.entry.text, SANITIZATION_SCHEMA);
|
||||
|
||||
if (this.editMode || this.urlWhitelist.length === 0) {
|
||||
return { innerText: text };
|
||||
}
|
||||
|
||||
const html = this.formatValidUrls(text);
|
||||
|
||||
return { innerHTML: html };
|
||||
},
|
||||
isSelectedEntry() {
|
||||
return this.selectedEntryId === this.entry.id;
|
||||
},
|
||||
entryTags() {
|
||||
const tagsFromAnnotations = this.openmct.annotation.getTagsFromAnnotations(this.notebookAnnotations);
|
||||
|
||||
return tagsFromAnnotations;
|
||||
},
|
||||
entryText() {
|
||||
let text = this.entry.text;
|
||||
|
||||
@ -232,7 +288,23 @@ export default {
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.manageEmbedLayout = _.debounce(this.manageEmbedLayout, 400);
|
||||
|
||||
if (this.$refs.embedsWrapper) {
|
||||
this.embedsWrapperResizeObserver = new ResizeObserver(this.manageEmbedLayout);
|
||||
this.embedsWrapperResizeObserver.observe(this.$refs.embedsWrapper);
|
||||
}
|
||||
|
||||
this.manageEmbedLayout();
|
||||
this.dropOnEntry = this.dropOnEntry.bind(this);
|
||||
if (this.entryUrlWhitelist?.length > 0) {
|
||||
this.urlWhitelist = this.entryUrlWhitelist;
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.embedsWrapperResizeObserver) {
|
||||
this.embedsWrapperResizeObserver.unobserve(this.$refs.embedsWrapper);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async addNewEmbed(objectPath) {
|
||||
@ -245,6 +317,8 @@ export default {
|
||||
};
|
||||
const newEmbed = await createNewEmbed(snapshotMeta);
|
||||
this.entry.embeds.push(newEmbed);
|
||||
|
||||
this.manageEmbedLayout();
|
||||
},
|
||||
cancelEditMode(event) {
|
||||
const isEditing = this.openmct.editor.isEditing();
|
||||
@ -262,9 +336,41 @@ export default {
|
||||
event.dataTransfer.effectAllowed = 'none';
|
||||
}
|
||||
},
|
||||
checkEditability($event) {
|
||||
if ($event.target.nodeName === 'A') {
|
||||
this.canEdit = false;
|
||||
}
|
||||
},
|
||||
deleteEntry() {
|
||||
this.$emit('deleteEntry', this.entry.id);
|
||||
},
|
||||
formatValidUrls(text) {
|
||||
return text.replace(URL_REGEX, (match) => {
|
||||
const url = new URL(match);
|
||||
const domain = url.hostname;
|
||||
let result = match;
|
||||
let isMatch = this.urlWhitelist.find((partialDomain) => {
|
||||
return domain.endsWith(partialDomain);
|
||||
});
|
||||
|
||||
if (isMatch) {
|
||||
result = `<a class="c-hyperlink" target="_blank" href="${match}">${match}</a>`;
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
},
|
||||
manageEmbedLayout() {
|
||||
if (this.$refs.embeds) {
|
||||
const embedsWrapperLength = this.$refs.embedsWrapper.clientWidth;
|
||||
const embedsTotalWidth = this.$refs.embeds.reduce((total, embed) => {
|
||||
return embed.$el.clientWidth + total;
|
||||
}, 0);
|
||||
|
||||
this.enableEmbedsWrapperScroll = embedsTotalWidth > embedsWrapperLength;
|
||||
}
|
||||
|
||||
},
|
||||
async dropOnEntry($event) {
|
||||
$event.stopImmediatePropagation();
|
||||
|
||||
@ -322,6 +428,8 @@ export default {
|
||||
this.entry.embeds.splice(embedPosition, 1);
|
||||
|
||||
this.timestampAndUpdate();
|
||||
|
||||
this.manageEmbedLayout();
|
||||
},
|
||||
updateEmbed(newEmbed) {
|
||||
this.entry.embeds.some(e => {
|
||||
@ -347,16 +455,45 @@ export default {
|
||||
this.$emit('updateEntry', this.entry);
|
||||
},
|
||||
editingEntry() {
|
||||
this.editMode = true;
|
||||
this.$emit('editingEntry');
|
||||
},
|
||||
updateEntryValue($event) {
|
||||
this.editMode = false;
|
||||
const value = $event.target.innerText;
|
||||
if (value !== this.entry.text && value.match(/\S/)) {
|
||||
this.entry.text = value;
|
||||
this.entry.text = sanitizeHtml(value, SANITIZATION_SCHEMA);
|
||||
this.timestampAndUpdate();
|
||||
} else {
|
||||
this.$emit('cancelEdit');
|
||||
}
|
||||
},
|
||||
selectEntry(event, entry) {
|
||||
const targetDetails = {};
|
||||
const keyString = this.openmct.objects.makeKeyString(this.domainObject.identifier);
|
||||
targetDetails[keyString] = {
|
||||
entryId: entry.id
|
||||
};
|
||||
const targetDomainObjects = {};
|
||||
targetDomainObjects[keyString] = this.domainObject;
|
||||
this.openmct.selection.select(
|
||||
[
|
||||
{
|
||||
element: event.currentTarget,
|
||||
context: {
|
||||
type: 'notebook-entry-selection',
|
||||
item: this.domainObject,
|
||||
targetDetails,
|
||||
targetDomainObjects,
|
||||
annotations: this.notebookAnnotations,
|
||||
annotationType: this.openmct.annotation.ANNOTATION_TYPES.NOTEBOOK,
|
||||
onAnnotationChange: this.timestampAndUpdate
|
||||
}
|
||||
}
|
||||
],
|
||||
false);
|
||||
event.stopPropagation();
|
||||
this.$emit('entry-selection', this.entry);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -6,8 +6,7 @@ export const NOTEBOOK_DEFAULT = 'DEFAULT';
|
||||
export const NOTEBOOK_SNAPSHOT = 'SNAPSHOT';
|
||||
export const NOTEBOOK_VIEW_TYPE = 'notebook-vue';
|
||||
export const RESTRICTED_NOTEBOOK_VIEW_TYPE = 'restricted-notebook-vue';
|
||||
export const NOTEBOOK_INSTALLED_KEY = '_NOTEBOOK_PLUGIN_INSTALLED';
|
||||
export const RESTRICTED_NOTEBOOK_INSTALLED_KEY = '_RESTRICTED_NOTEBOOK_PLUGIN_INSTALLED';
|
||||
export const NOTEBOOK_BASE_INSTALLED = '_NOTEBOOK_BASE_FUNCTIONALITY_INSTALLED';
|
||||
|
||||
// these only deals with constants, figured this could skip going into a utils file
|
||||
export function isNotebookOrAnnotationType(domainObject) {
|
||||
|
@ -33,8 +33,7 @@ import {
|
||||
RESTRICTED_NOTEBOOK_TYPE,
|
||||
NOTEBOOK_VIEW_TYPE,
|
||||
RESTRICTED_NOTEBOOK_VIEW_TYPE,
|
||||
NOTEBOOK_INSTALLED_KEY,
|
||||
RESTRICTED_NOTEBOOK_INSTALLED_KEY
|
||||
NOTEBOOK_BASE_INSTALLED
|
||||
} from './notebook-constants';
|
||||
|
||||
import Vue from 'vue';
|
||||
@ -63,7 +62,7 @@ function addLegacyNotebookGetInterceptor(openmct) {
|
||||
|
||||
function installBaseNotebookFunctionality(openmct) {
|
||||
// only need to do this once
|
||||
if (openmct[NOTEBOOK_INSTALLED_KEY] || openmct[RESTRICTED_NOTEBOOK_INSTALLED_KEY]) {
|
||||
if (openmct[NOTEBOOK_BASE_INSTALLED]) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -101,14 +100,12 @@ function installBaseNotebookFunctionality(openmct) {
|
||||
openmct.indicators.add(indicator);
|
||||
|
||||
monkeyPatchObjectAPIForNotebooks(openmct);
|
||||
|
||||
openmct[NOTEBOOK_BASE_INSTALLED] = true;
|
||||
}
|
||||
|
||||
function NotebookPlugin(name = 'Notebook') {
|
||||
function NotebookPlugin(name = 'Notebook', entryUrlWhitelist = []) {
|
||||
return function install(openmct) {
|
||||
if (openmct[NOTEBOOK_INSTALLED_KEY]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const icon = 'icon-notebook';
|
||||
const description = 'Create and save timestamped notes with embedded object snapshots.';
|
||||
const snapshotContainer = getSnapshotContainer(openmct);
|
||||
@ -118,21 +115,15 @@ function NotebookPlugin(name = 'Notebook') {
|
||||
const notebookType = new NotebookType(name, description, icon);
|
||||
openmct.types.addType(NOTEBOOK_TYPE, notebookType);
|
||||
|
||||
const notebookView = new NotebookViewProvider(openmct, name, NOTEBOOK_VIEW_TYPE, NOTEBOOK_TYPE, icon, snapshotContainer);
|
||||
openmct.objectViews.addProvider(notebookView);
|
||||
const notebookView = new NotebookViewProvider(openmct, name, NOTEBOOK_VIEW_TYPE, NOTEBOOK_TYPE, icon, snapshotContainer, entryUrlWhitelist);
|
||||
openmct.objectViews.addProvider(notebookView, entryUrlWhitelist);
|
||||
|
||||
installBaseNotebookFunctionality(openmct);
|
||||
|
||||
openmct[NOTEBOOK_INSTALLED_KEY] = true;
|
||||
};
|
||||
}
|
||||
|
||||
function RestrictedNotebookPlugin(name = 'Notebook Shift Log') {
|
||||
function RestrictedNotebookPlugin(name = 'Notebook Shift Log', entryUrlWhitelist = []) {
|
||||
return function install(openmct) {
|
||||
if (openmct[RESTRICTED_NOTEBOOK_INSTALLED_KEY]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const icon = 'icon-notebook-shift-log';
|
||||
const description = 'Create and save timestamped notes with embedded object snapshots with the ability to commit and lock pages.';
|
||||
const snapshotContainer = getSnapshotContainer(openmct);
|
||||
@ -140,12 +131,10 @@ function RestrictedNotebookPlugin(name = 'Notebook Shift Log') {
|
||||
const notebookType = new NotebookType(name, description, icon);
|
||||
openmct.types.addType(RESTRICTED_NOTEBOOK_TYPE, notebookType);
|
||||
|
||||
const notebookView = new NotebookViewProvider(openmct, name, RESTRICTED_NOTEBOOK_VIEW_TYPE, RESTRICTED_NOTEBOOK_TYPE, icon, snapshotContainer);
|
||||
openmct.objectViews.addProvider(notebookView);
|
||||
const notebookView = new NotebookViewProvider(openmct, name, RESTRICTED_NOTEBOOK_VIEW_TYPE, RESTRICTED_NOTEBOOK_TYPE, icon, snapshotContainer, entryUrlWhitelist);
|
||||
openmct.objectViews.addProvider(notebookView, entryUrlWhitelist);
|
||||
|
||||
installBaseNotebookFunctionality(openmct);
|
||||
|
||||
openmct[RESTRICTED_NOTEBOOK_INSTALLED_KEY] = true;
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="notifications.length > 0"
|
||||
v-if="notifications.length === 0 ? showNotificationsOverlay : notifications.length > 0"
|
||||
class="c-indicator c-indicator--clickable icon-bell"
|
||||
:class="[severityClass]"
|
||||
>
|
||||
|