Compare commits
42 Commits
image-thum
...
nb-embed-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98836b256f | ||
|
|
4dee94480c | ||
|
|
17fee67e08 | ||
|
|
d1c7d133fc | ||
|
|
edbbebe329 | ||
|
|
f98a2cdd6b | ||
|
|
22621aaaf8 | ||
|
|
e0ca6200bb | ||
|
|
3bd12a1db4 | ||
|
|
70074c52c8 | ||
|
|
d5adaf6e8c | ||
|
|
8125632728 | ||
|
|
14c9dd0a32 | ||
|
|
9ae58f8441 | ||
|
|
4889284335 | ||
|
|
c2183d4de2 | ||
|
|
902d80c214 | ||
|
|
22ce817443 | ||
|
|
3e45b2ccd7 | ||
|
|
4b08aa93e6 | ||
|
|
e48f419db7 | ||
|
|
f6e0224099 | ||
|
|
df3b8b55d9 | ||
|
|
fcf950cf43 | ||
|
|
54f06d36a5 | ||
|
|
a742c35ff9 | ||
|
|
332540598b | ||
|
|
06d1efc008 | ||
|
|
d196cafb9c | ||
|
|
081eeb8a1f | ||
|
|
97245781e5 | ||
|
|
ede591d768 | ||
|
|
945f220727 | ||
|
|
bd9ed3de87 | ||
|
|
eb50e93cd9 | ||
|
|
b72bad16d9 | ||
|
|
a4d2290274 | ||
|
|
9a3806b117 | ||
|
|
ba26e38837 | ||
|
|
29a747405e | ||
|
|
305d566ee7 | ||
|
|
95e6a5b3ad |
@@ -31,9 +31,11 @@ try {
|
||||
console.warn(err);
|
||||
}
|
||||
|
||||
const projectRootDir = path.resolve(__dirname, "..");
|
||||
|
||||
/** @type {import('webpack').Configuration} */
|
||||
const config = {
|
||||
context: path.join(__dirname, ".."),
|
||||
context: projectRootDir,
|
||||
entry: {
|
||||
openmct: "./openmct.js",
|
||||
generatorWorker: "./example/generator/generatorWorker.js",
|
||||
@@ -46,7 +48,7 @@ const config = {
|
||||
output: {
|
||||
globalObject: "this",
|
||||
filename: "[name].js",
|
||||
path: path.resolve(__dirname, "..", "dist"),
|
||||
path: path.resolve(projectRootDir, "dist"),
|
||||
library: "openmct",
|
||||
libraryTarget: "umd",
|
||||
publicPath: "",
|
||||
@@ -55,8 +57,8 @@ const config = {
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.join(__dirname, "..", "src"),
|
||||
legacyRegistry: path.join(__dirname, "..", "src/legacyRegistry"),
|
||||
"@": path.join(projectRootDir, "src"),
|
||||
legacyRegistry: path.join(projectRootDir, "src/legacyRegistry"),
|
||||
saveAs: "file-saver/src/FileSaver.js",
|
||||
csv: "comma-separated-values",
|
||||
EventEmitter: "eventemitter3",
|
||||
@@ -64,24 +66,22 @@ const config = {
|
||||
"plotly-basic": "plotly.js-basic-dist",
|
||||
"plotly-gl2d": "plotly.js-gl2d-dist",
|
||||
"d3-scale": path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
projectRootDir,
|
||||
"node_modules/d3-scale/dist/d3-scale.min.js"
|
||||
),
|
||||
printj: path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
projectRootDir,
|
||||
"node_modules/printj/dist/printj.min.js"
|
||||
),
|
||||
styles: path.join(__dirname, "..", "src/styles"),
|
||||
MCT: path.join(__dirname, "..", "src/MCT"),
|
||||
testUtils: path.join(__dirname, "..", "src/utils/testUtils.js"),
|
||||
styles: path.join(projectRootDir, "src/styles"),
|
||||
MCT: path.join(projectRootDir, "src/MCT"),
|
||||
testUtils: path.join(projectRootDir, "src/utils/testUtils.js"),
|
||||
objectUtils: path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
projectRootDir,
|
||||
"src/api/objects/object-utils.js"
|
||||
),
|
||||
utils: path.join(__dirname, "..", "src/utils")
|
||||
"kdbush": path.join(projectRootDir, "node_modules/kdbush/kdbush.min.js"),
|
||||
utils: path.join(projectRootDir, "src/utils")
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
@@ -168,8 +168,8 @@ const config = {
|
||||
performance: {
|
||||
// We should eventually consider chunking to decrease
|
||||
// these values
|
||||
maxEntrypointSize: 25000000,
|
||||
maxAssetSize: 25000000
|
||||
maxEntrypointSize: 27000000,
|
||||
maxAssetSize: 27000000
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -5,11 +5,12 @@ This configuration should be used for development purposes. It contains full sou
|
||||
devServer (which be invoked using by `npm start`), and a non-minified Vue.js distribution.
|
||||
If OpenMCT is to be used for a production server, use webpack.prod.js instead.
|
||||
*/
|
||||
const { merge } = require("webpack-merge");
|
||||
const common = require("./webpack.common");
|
||||
|
||||
const path = require("path");
|
||||
const webpack = require("webpack");
|
||||
const { merge } = require("webpack-merge");
|
||||
|
||||
const common = require("./webpack.common");
|
||||
const projectRootDir = path.resolve(__dirname, "..");
|
||||
|
||||
module.exports = merge(common, {
|
||||
mode: "development",
|
||||
@@ -26,7 +27,7 @@ module.exports = merge(common, {
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
vue: path.join(__dirname, "..", "node_modules/vue/dist/vue.js")
|
||||
vue: path.join(projectRootDir, "node_modules/vue/dist/vue.js")
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
|
||||
@@ -4,17 +4,18 @@
|
||||
This configuration should be used for production installs.
|
||||
It is the default webpack configuration.
|
||||
*/
|
||||
const { merge } = require("webpack-merge");
|
||||
const common = require("./webpack.common");
|
||||
|
||||
const path = require("path");
|
||||
const webpack = require("webpack");
|
||||
const { merge } = require("webpack-merge");
|
||||
|
||||
const common = require("./webpack.common");
|
||||
const projectRootDir = path.resolve(__dirname, "..");
|
||||
|
||||
module.exports = merge(common, {
|
||||
mode: "production",
|
||||
resolve: {
|
||||
alias: {
|
||||
vue: path.join(__dirname, "..", "node_modules/vue/dist/vue.min.js")
|
||||
vue: path.join(projectRootDir, "node_modules/vue/dist/vue.min.js")
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
|
||||
@@ -45,6 +45,14 @@
|
||||
* @property {string} url the relative url to the object (for use with `page.goto()`)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Defines parameters to be used in the creation of a notification.
|
||||
* @typedef {Object} CreateNotificationOptions
|
||||
* @property {string} message the message
|
||||
* @property {'info' | 'alert' | 'error'} severity the severity
|
||||
* @property {import('../src/api/notifications/NotificationAPI').NotificationOptions} [notificationOptions] additional options
|
||||
*/
|
||||
|
||||
const Buffer = require('buffer').Buffer;
|
||||
const genUuid = require('uuid').v4;
|
||||
|
||||
@@ -112,6 +120,25 @@ async function createDomainObjectWithDefaults(page, { type, name, parent = 'mine
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a notification with the given options.
|
||||
* @param {import('@playwright/test').Page} page
|
||||
* @param {CreateNotificationOptions} createNotificationOptions
|
||||
*/
|
||||
async function createNotification(page, createNotificationOptions) {
|
||||
await page.evaluate((_createNotificationOptions) => {
|
||||
const { message, severity, options } = _createNotificationOptions;
|
||||
const notificationApi = window.openmct.notifications;
|
||||
if (severity === 'info') {
|
||||
notificationApi.info(message, options);
|
||||
} else if (severity === 'alert') {
|
||||
notificationApi.alert(message, options);
|
||||
} else {
|
||||
notificationApi.error(message, options);
|
||||
}
|
||||
}, createNotificationOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('@playwright/test').Page} page
|
||||
* @param {string} name
|
||||
@@ -333,6 +360,7 @@ async function setEndOffset(page, offset) {
|
||||
// eslint-disable-next-line no-undef
|
||||
module.exports = {
|
||||
createDomainObjectWithDefaults,
|
||||
createNotification,
|
||||
expandTreePaneItemByName,
|
||||
createPlanFromJSON,
|
||||
openObjectTreeContextMenu,
|
||||
|
||||
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));
|
||||
});
|
||||
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 } = require('../../appActions.js');
|
||||
const { createDomainObjectWithDefaults, createNotification } = 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}`);
|
||||
@@ -85,4 +85,28 @@ test.describe('AppActions', () => {
|
||||
expect(folder3.url).toBe(`${e2eFolder.url}/${folder1.uuid}/${folder2.uuid}/${folder3.uuid}`);
|
||||
});
|
||||
});
|
||||
test("createNotification", async ({ page }) => {
|
||||
await page.goto('./', { waitUntil: 'networkidle' });
|
||||
await createNotification(page, {
|
||||
message: 'Test info notification',
|
||||
severity: 'info'
|
||||
});
|
||||
await expect(page.locator('.c-message-banner__message')).toHaveText('Test info notification');
|
||||
await expect(page.locator('.c-message-banner')).toHaveClass(/info/);
|
||||
await page.locator('[aria-label="Dismiss"]').click();
|
||||
await createNotification(page, {
|
||||
message: 'Test alert notification',
|
||||
severity: 'alert'
|
||||
});
|
||||
await expect(page.locator('.c-message-banner__message')).toHaveText('Test alert notification');
|
||||
await expect(page.locator('.c-message-banner')).toHaveClass(/alert/);
|
||||
await page.locator('[aria-label="Dismiss"]').click();
|
||||
await createNotification(page, {
|
||||
message: 'Test error notification',
|
||||
severity: 'error'
|
||||
});
|
||||
await expect(page.locator('.c-message-banner__message')).toHaveText('Test error notification');
|
||||
await expect(page.locator('.c-message-banner')).toHaveClass(/error/);
|
||||
await page.locator('[aria-label="Dismiss"]').click();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,76 @@ 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.locator('#tree-pane');
|
||||
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 locatorTree = page.locator('#locator-tree');
|
||||
const myItemsLocatorTreeItem = locatorTree.getByRole('treeitem', {
|
||||
name: myItemsFolderName
|
||||
});
|
||||
await myItemsLocatorTreeItem.locator('.c-disclosure-triangle').click();
|
||||
await myItemsLocatorTreeItem.click();
|
||||
|
||||
const parentFolderLocatorTreeItem = locatorTree.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 = locatorTree.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 = locatorTree.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 +142,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 +166,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 +186,76 @@ 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.locator('#tree-pane');
|
||||
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 locatorTree = page.locator('#locator-tree');
|
||||
const myItemsLocatorTreeItem = locatorTree.getByRole('treeitem', {
|
||||
name: myItemsFolderName
|
||||
});
|
||||
await myItemsLocatorTreeItem.locator('.c-disclosure-triangle').click();
|
||||
await myItemsLocatorTreeItem.click();
|
||||
|
||||
const parentFolderLocatorTreeItem = locatorTree.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 = locatorTree.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 = locatorTree.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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
79
e2e/tests/functional/notification.e2e.spec.js
Normal file
@@ -0,0 +1,79 @@
|
||||
/*****************************************************************************
|
||||
* 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 tests which verify Open MCT's Notification functionality
|
||||
*/
|
||||
|
||||
// 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', () => {
|
||||
test.fixme('Notifications can be dismissed individually', async ({ page }) => {
|
||||
// Create some persistent notifications
|
||||
// Verify that they are present in the notifications list
|
||||
// Dismiss one of the notifications
|
||||
// Verify that it is no longer present in the 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' });
|
||||
@@ -47,7 +48,12 @@ 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.locator('#tree-pane');
|
||||
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 +80,12 @@ 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.locator('#tree-pane');
|
||||
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 +116,12 @@ 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.locator('#tree-pane');
|
||||
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();
|
||||
|
||||
@@ -139,7 +155,12 @@ 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.locator('#tree-pane');
|
||||
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();
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -24,8 +24,6 @@
|
||||
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 nbUtils = require('../../../../helper/notebookUtils');
|
||||
@@ -266,70 +264,3 @@ 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.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.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?
|
||||
});
|
||||
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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
});
|
||||
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 18 KiB |
85
e2e/tests/functional/recentObjects.e2e.spec.js
Normal file
@@ -0,0 +1,85 @@
|
||||
/*****************************************************************************
|
||||
* 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', () => {
|
||||
test('Recent Objects CRUD operations', async ({ page }) => {
|
||||
await page.goto('./', { waitUntil: 'networkidle' });
|
||||
|
||||
// Create a folder and nest a Clock within it
|
||||
const recentObjectsList = page.locator('[aria-label="Recent Objects"]');
|
||||
const folderA = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Folder'
|
||||
});
|
||||
const 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();
|
||||
|
||||
// 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 recentObjectsList.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(page.getByRole('listitem', { name: clock.name }).locator('a').getByText(folderA.name)).toBeTruthy();
|
||||
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.fixme("Clicking on the 'target button' scrolls the object into view in the tree and highlights it");
|
||||
test.fixme("Clicking on an object in the path of a recent object navigates to the object");
|
||||
test.fixme("Tests for context menu actions from recent objects");
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
58
e2e/tests/visual/notification.visual.spec.js
Normal file
@@ -0,0 +1,58 @@
|
||||
/*****************************************************************************
|
||||
* 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 is dedicated to test notification banner functionality and its accessibility attributes.
|
||||
*/
|
||||
|
||||
const { test, expect } = require('../../pluginFixtures');
|
||||
const percySnapshot = require('@percy/playwright');
|
||||
const { createDomainObjectWithDefaults } = require('../../appActions');
|
||||
|
||||
test.describe('Visual - Check Notification Info Banner of \'Save successful\'', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Go to baseURL and Hide Tree
|
||||
await page.goto('./', { waitUntil: 'networkidle' });
|
||||
});
|
||||
|
||||
test('Create a clock, click on \'Save successful\' banner and dismiss it', async ({ page }) => {
|
||||
// Create a clock domain object
|
||||
await createDomainObjectWithDefaults(page, { type: 'Clock' });
|
||||
// Verify there is a button with aria-label="Review 1 Notification"
|
||||
expect(await page.locator('button[aria-label="Review 1 Notification"]').isVisible()).toBe(true);
|
||||
// Verify there is a button with aria-label="Clear all notifications"
|
||||
expect(await page.locator('button[aria-label="Clear all notifications"]').isVisible()).toBe(true);
|
||||
// Click on the div with role="alert" that has "Save successful" text
|
||||
await page.locator('div[role="alert"]:has-text("Save successful")').click();
|
||||
// Verify there is a div with role="dialog"
|
||||
expect(await page.locator('div[role="dialog"]').isVisible()).toBe(true);
|
||||
// Verify the div with role="dialog" contains text "Save successful"
|
||||
expect(await page.locator('div[role="dialog"]').innerText()).toContain('Save successful');
|
||||
await percySnapshot(page, 'Notification banner');
|
||||
// Verify there is a button with text "Dismiss"
|
||||
expect(await page.locator('button:has-text("Dismiss")').isVisible()).toBe(true);
|
||||
// Click on button with text "Dismiss"
|
||||
await page.locator('button:has-text("Dismiss")').click();
|
||||
// Verify there is no div with role="dialog"
|
||||
expect(await page.locator('div[role="dialog"]').isVisible()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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()
|
||||
|
||||
12
package.json
@@ -5,9 +5,10 @@
|
||||
"devDependencies": {
|
||||
"@babel/eslint-parser": "7.18.9",
|
||||
"@braintree/sanitize-url": "6.0.2",
|
||||
"@percy/cli": "1.16.0",
|
||||
"@percy/cli": "1.17.0",
|
||||
"@percy/playwright": "1.0.4",
|
||||
"@playwright/test": "1.29.0",
|
||||
"@types/eventemitter3": "1.2.0",
|
||||
"@types/jasmine": "4.3.1",
|
||||
"@types/lodash": "4.14.191",
|
||||
"babel-loader": "9.1.0",
|
||||
@@ -19,10 +20,10 @@
|
||||
"d3-axis": "3.0.0",
|
||||
"d3-scale": "3.3.0",
|
||||
"d3-selection": "3.0.0",
|
||||
"eslint": "8.31.0",
|
||||
"eslint": "8.32.0",
|
||||
"eslint-plugin-compat": "4.0.2",
|
||||
"eslint-plugin-playwright": "0.11.2",
|
||||
"eslint-plugin-vue": "9.8.0",
|
||||
"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",
|
||||
"file-saver": "2.0.5",
|
||||
@@ -40,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",
|
||||
@@ -50,7 +52,7 @@
|
||||
"painterro": "1.2.78",
|
||||
"playwright-core": "1.29.0",
|
||||
"plotly.js-basic-dist": "2.17.0",
|
||||
"plotly.js-gl2d-dist": "2.14.0",
|
||||
"plotly.js-gl2d-dist": "2.17.1",
|
||||
"printj": "1.3.1",
|
||||
"resolve-url-loader": "5.0.0",
|
||||
"sass": "1.57.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');
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
<template>
|
||||
<mct-tree
|
||||
id="locator-tree"
|
||||
:is-selector-tree="true"
|
||||
:initial-selection="model.parent"
|
||||
@tree-item-selection="handleItemSelection"
|
||||
|
||||
@@ -31,7 +31,31 @@
|
||||
* @namespace platform/api/notifications
|
||||
*/
|
||||
import moment from 'moment';
|
||||
import EventEmitter from 'EventEmitter';
|
||||
import EventEmitter from 'eventemitter3';
|
||||
|
||||
/**
|
||||
* @typedef {object} NotificationProperties
|
||||
* @property {function} dismiss Dismiss the notification
|
||||
* @property {NotificationModel} model The Notification model
|
||||
* @property {(progressPerc: number, progressText: string) => void} [progress] Update the progress of the notification
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {EventEmitter & NotificationProperties} Notification
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} NotificationLink
|
||||
* @property {function} onClick The function to be called when the link is clicked
|
||||
* @property {string} cssClass A CSS class name to style the link
|
||||
* @property {string} text The text to be displayed for the link
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} NotificationOptions
|
||||
* @property {number} [autoDismissTimeout] Milliseconds to wait before automatically dismissing the notification
|
||||
* @property {NotificationLink} [link] A link for the notification
|
||||
*/
|
||||
|
||||
/**
|
||||
* A representation of a banner notification. Banner notifications
|
||||
@@ -40,13 +64,17 @@ import EventEmitter from 'EventEmitter';
|
||||
* dialogs so that the same information can be provided in a dialog
|
||||
* and then minimized to a banner notification if needed, or vice-versa.
|
||||
*
|
||||
* @see DialogModel
|
||||
* @typedef {object} NotificationModel
|
||||
* @property {string} message The message to be displayed by the notification
|
||||
* @property {number | 'unknown'} [progress] The progress of some ongoing task. Should be a number between 0 and 100, or
|
||||
* with the string literal 'unknown'.
|
||||
* @property {string} [progressText] A message conveying progress of some ongoing task.
|
||||
|
||||
* @see DialogModel
|
||||
* @property {string} [severity] The severity of the notification. Should be one of 'info', 'alert', or 'error'.
|
||||
* @property {string} [timestamp] The time at which the notification was created. Should be a string in ISO 8601 format.
|
||||
* @property {boolean} [minimized] Whether or not the notification has been minimized
|
||||
* @property {boolean} [autoDismiss] Whether the notification should be automatically dismissed after a short period of time.
|
||||
* @property {NotificationOptions} options The notification options
|
||||
*/
|
||||
|
||||
const DEFAULT_AUTO_DISMISS_TIMEOUT = 3000;
|
||||
@@ -55,18 +83,19 @@ const MINIMIZE_ANIMATION_TIMEOUT = 300;
|
||||
/**
|
||||
* The notification service is responsible for informing the user of
|
||||
* events via the use of banner notifications.
|
||||
* @memberof ui/notification
|
||||
* @constructor */
|
||||
|
||||
*/
|
||||
export default class NotificationAPI extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
/** @type {Notification[]} */
|
||||
this.notifications = [];
|
||||
/** @type {{severity: "info" | "alert" | "error"}} */
|
||||
this.highest = { severity: "info" };
|
||||
|
||||
/*
|
||||
/**
|
||||
* A context in which to hold the active notification and a
|
||||
* handle to its timeout.
|
||||
* @type {Notification | undefined}
|
||||
*/
|
||||
this.activeNotification = undefined;
|
||||
}
|
||||
@@ -75,16 +104,12 @@ export default class NotificationAPI extends EventEmitter {
|
||||
* Info notifications are low priority informational messages for the user. They will be auto-destroy after a brief
|
||||
* period of time.
|
||||
* @param {string} message The message to display to the user
|
||||
* @param {Object} [options] object with following properties
|
||||
* autoDismissTimeout: {number} in miliseconds to automatically dismisses notification
|
||||
* link: {Object} Add a link to notifications for navigation
|
||||
* onClick: callback function
|
||||
* cssClass: css class name to add style on link
|
||||
* text: text to display for link
|
||||
* @returns {InfoNotification}
|
||||
* @param {NotificationOptions} [options] The notification options
|
||||
* @returns {Notification}
|
||||
*/
|
||||
info(message, options = {}) {
|
||||
let notificationModel = {
|
||||
/** @type {NotificationModel} */
|
||||
const notificationModel = {
|
||||
message: message,
|
||||
autoDismiss: true,
|
||||
severity: "info",
|
||||
@@ -97,7 +122,7 @@ export default class NotificationAPI extends EventEmitter {
|
||||
/**
|
||||
* Present an alert to the user.
|
||||
* @param {string} message The message to display to the user.
|
||||
* @param {Object} [options] object with following properties
|
||||
* @param {NotificationOptions} [options] object with following properties
|
||||
* autoDismissTimeout: {number} in milliseconds to automatically dismisses notification
|
||||
* link: {Object} Add a link to notifications for navigation
|
||||
* onClick: callback function
|
||||
@@ -106,7 +131,7 @@ export default class NotificationAPI extends EventEmitter {
|
||||
* @returns {Notification}
|
||||
*/
|
||||
alert(message, options = {}) {
|
||||
let notificationModel = {
|
||||
const notificationModel = {
|
||||
message: message,
|
||||
severity: "alert",
|
||||
options
|
||||
@@ -147,7 +172,8 @@ export default class NotificationAPI extends EventEmitter {
|
||||
message: message,
|
||||
progressPerc: progressPerc,
|
||||
progressText: progressText,
|
||||
severity: "info"
|
||||
severity: "info",
|
||||
options: {}
|
||||
};
|
||||
|
||||
return this._notify(notificationModel);
|
||||
@@ -165,8 +191,13 @@ export default class NotificationAPI extends EventEmitter {
|
||||
* dismissed.
|
||||
*
|
||||
* @private
|
||||
* @param {Notification | undefined} notification
|
||||
*/
|
||||
_minimize(notification) {
|
||||
if (!notification) {
|
||||
return;
|
||||
}
|
||||
|
||||
//Check this is a known notification
|
||||
let index = this.notifications.indexOf(notification);
|
||||
|
||||
@@ -204,8 +235,13 @@ export default class NotificationAPI extends EventEmitter {
|
||||
* dismiss
|
||||
*
|
||||
* @private
|
||||
* @param {Notification | undefined} notification
|
||||
*/
|
||||
_dismiss(notification) {
|
||||
if (!notification) {
|
||||
return;
|
||||
}
|
||||
|
||||
//Check this is a known notification
|
||||
let index = this.notifications.indexOf(notification);
|
||||
|
||||
@@ -236,10 +272,11 @@ export default class NotificationAPI extends EventEmitter {
|
||||
* dismiss or minimize where appropriate.
|
||||
*
|
||||
* @private
|
||||
* @param {Notification | undefined} notification
|
||||
*/
|
||||
_dismissOrMinimize(notification) {
|
||||
let model = notification.model;
|
||||
if (model.severity === "info") {
|
||||
let model = notification?.model;
|
||||
if (model?.severity === "info") {
|
||||
this._dismiss(notification);
|
||||
} else {
|
||||
this._minimize(notification);
|
||||
@@ -251,10 +288,11 @@ export default class NotificationAPI extends EventEmitter {
|
||||
*/
|
||||
_setHighestSeverity() {
|
||||
let severity = {
|
||||
"info": 1,
|
||||
"alert": 2,
|
||||
"error": 3
|
||||
info: 1,
|
||||
alert: 2,
|
||||
error: 3
|
||||
};
|
||||
|
||||
this.highest.severity = this.notifications.reduce((previous, notification) => {
|
||||
if (severity[notification.model.severity] > severity[previous]) {
|
||||
return notification.model.severity;
|
||||
@@ -312,8 +350,11 @@ export default class NotificationAPI extends EventEmitter {
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {NotificationModel} notificationModel
|
||||
* @returns {Notification}
|
||||
*/
|
||||
_createNotification(notificationModel) {
|
||||
/** @type {Notification} */
|
||||
let notification = new EventEmitter();
|
||||
notification.model = notificationModel;
|
||||
notification.dismiss = () => {
|
||||
@@ -333,6 +374,7 @@ export default class NotificationAPI extends EventEmitter {
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Notification | undefined} notification
|
||||
*/
|
||||
_setActiveNotification(notification) {
|
||||
this.activeNotification = notification;
|
||||
|
||||
@@ -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,7 +218,7 @@ 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) {
|
||||
@@ -740,6 +738,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
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
ref="element"
|
||||
class="c-overlay__contents js-notebook-snapshot-item-wrapper"
|
||||
tabindex="0"
|
||||
aria-modal="true"
|
||||
role="dialog"
|
||||
></div>
|
||||
<div
|
||||
v-if="buttons"
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -721,7 +721,7 @@ 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() {
|
||||
|
||||
@@ -35,11 +35,9 @@ export default {
|
||||
// set
|
||||
this.keyString = this.openmct.objects.makeKeyString(this.domainObject.identifier);
|
||||
this.metadata = this.openmct.telemetry.getMetadata(this.domainObject);
|
||||
this.imageMetadataValue = { ...this.metadata.valuesForHints(['image'])[0] };
|
||||
this.imageThumbnailMetadataValue = { ...this.metadata.valuesForHints(['thumbnail'])[0] };
|
||||
this.imageHints = { ...this.metadata.valuesForHints(['image'])[0] };
|
||||
this.durationFormatter = this.getFormatter(this.timeSystem.durationFormat || DEFAULT_DURATION_FORMATTER);
|
||||
this.imageFormatter = this.openmct.telemetry.getValueFormatter(this.imageMetadataValue);
|
||||
this.imageThumbnailFormatter = this.openmct.telemetry.getValueFormatter(this.imageThumbnailMetadataValue);
|
||||
this.imageFormatter = this.openmct.telemetry.getValueFormatter(this.imageHints);
|
||||
this.imageDownloadNameHints = { ...this.metadata.valuesForHints(['imageDownloadName'])[0]};
|
||||
|
||||
// initialize
|
||||
|
||||
@@ -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();
|
||||
@@ -99,6 +102,8 @@ export default class MoveAction {
|
||||
}
|
||||
}
|
||||
|
||||
await this.saveTransaction();
|
||||
|
||||
this.navigateTo(newObjectPath);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]?.[1]?.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();
|
||||
|
||||
@@ -22,23 +22,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">
|
||||
@@ -82,38 +96,37 @@
|
||||
</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>
|
||||
<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,11 +152,12 @@
|
||||
|
||||
<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 _ from 'lodash';
|
||||
|
||||
import Moment from 'moment';
|
||||
|
||||
const UNKNOWN_USER = 'Unknown';
|
||||
@@ -151,8 +165,7 @@ const UNKNOWN_USER = 'Unknown';
|
||||
export default {
|
||||
components: {
|
||||
NotebookEmbed,
|
||||
TextHighlight,
|
||||
TagEditor
|
||||
TextHighlight
|
||||
},
|
||||
inject: ['openmct', 'snapshotContainer'],
|
||||
props: {
|
||||
@@ -203,8 +216,17 @@ export default {
|
||||
default() {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
selectedEntryId: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
enableEmbedsWrapperScroll: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
createdOnDate() {
|
||||
return this.formatTime(this.entry.createdOn, 'YYYY-MM-DD');
|
||||
@@ -212,6 +234,14 @@ export default {
|
||||
createdOnTime() {
|
||||
return this.formatTime(this.entry.createdOn, 'HH:mm:ss');
|
||||
},
|
||||
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,8 +262,21 @@ 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);
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.embedsWrapperResizeObserver) {
|
||||
this.embedsWrapperResizeObserver.unobserve(this.$refs.embedsWrapper);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async addNewEmbed(objectPath) {
|
||||
const bounds = this.openmct.time.bounds();
|
||||
@@ -245,6 +288,8 @@ export default {
|
||||
};
|
||||
const newEmbed = await createNewEmbed(snapshotMeta);
|
||||
this.entry.embeds.push(newEmbed);
|
||||
|
||||
this.manageEmbedLayout();
|
||||
},
|
||||
cancelEditMode(event) {
|
||||
const isEditing = this.openmct.editor.isEditing();
|
||||
@@ -265,6 +310,17 @@ export default {
|
||||
deleteEntry() {
|
||||
this.$emit('deleteEntry', this.entry.id);
|
||||
},
|
||||
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 +378,8 @@ export default {
|
||||
this.entry.embeds.splice(embedPosition, 1);
|
||||
|
||||
this.timestampAndUpdate();
|
||||
|
||||
this.manageEmbedLayout();
|
||||
},
|
||||
updateEmbed(newEmbed) {
|
||||
this.entry.embeds.some(e => {
|
||||
@@ -357,6 +415,38 @@ export default {
|
||||
} 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: this.openmct.layout.$refs.browseObject.$el,
|
||||
context: {
|
||||
item: this.domainObject
|
||||
}
|
||||
},
|
||||
{
|
||||
element: event.currentTarget,
|
||||
context: {
|
||||
type: 'notebook-entry-selection',
|
||||
targetDetails,
|
||||
targetDomainObjects,
|
||||
annotations: this.notebookAnnotations,
|
||||
annotationType: this.openmct.annotation.ANNOTATION_TYPES.NOTEBOOK,
|
||||
onAnnotationChange: this.timestampAndUpdate
|
||||
}
|
||||
}
|
||||
],
|
||||
false);
|
||||
event.stopPropagation();
|
||||
this.$emit('entry-selection', this.entry);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
<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]"
|
||||
>
|
||||
<span class="c-indicator__label">
|
||||
<button @click="toggleNotificationsList(true)">
|
||||
<button
|
||||
:aria-label="'Review ' + notificationsCountMessage(notifications.length)"
|
||||
@click="toggleNotificationsList(true)"
|
||||
>
|
||||
{{ notificationsCountMessage(notifications.length) }}
|
||||
</button>
|
||||
<button @click="dismissAllNotifications()">
|
||||
<button
|
||||
aria-label="Clear all notifications"
|
||||
@click="dismissAllNotifications()"
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
</span>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
class="c-message"
|
||||
role="listitem"
|
||||
:class="'message-severity-' + notification.model.severity"
|
||||
>
|
||||
<div class="c-ne__time-and-content">
|
||||
@@ -20,6 +21,11 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
:aria-label="'Dismiss notification of ' + notification.model.message"
|
||||
class="c-click-icon c-overlay__close-button icon-x"
|
||||
@click="dismiss()"
|
||||
></button>
|
||||
<div class="c-overlay__button-bar">
|
||||
<button
|
||||
v-for="(dialogOption, index) in notification.model.options"
|
||||
@@ -52,6 +58,14 @@ export default {
|
||||
notification: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
closeOverlay: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
notificationsCount: {
|
||||
type: Number,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -79,6 +93,12 @@ export default {
|
||||
updateProgressBar(progressPerc, progressText) {
|
||||
this.progressPerc = progressPerc;
|
||||
this.progressText = progressText;
|
||||
},
|
||||
dismiss() {
|
||||
this.notification.dismiss();
|
||||
if (this.notificationsCount === 1) {
|
||||
this.closeOverlay();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,11 +6,16 @@
|
||||
{{ notificationsCountDisplayMessage(notifications.length) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-messages c-overlay__messages">
|
||||
<div
|
||||
role="list"
|
||||
class="w-messages c-overlay__messages"
|
||||
>
|
||||
<notification-message
|
||||
v-for="notification in notifications"
|
||||
:key="notification.model.timestamp"
|
||||
:close-overlay="closeOverlay"
|
||||
:notification="notification"
|
||||
:notifications-count="notifications.length"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,6 +62,9 @@ export default {
|
||||
}
|
||||
});
|
||||
},
|
||||
closeOverlay() {
|
||||
this.overlay.dismiss();
|
||||
},
|
||||
notificationsCountDisplayMessage(count) {
|
||||
if (count > 1 || count === 0) {
|
||||
return `Displaying ${count} notifications`;
|
||||
|
||||
@@ -88,6 +88,14 @@
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
[class*='__label'] {
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
[class*='__poll-table'] {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
[class*='new-question'] {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
@@ -123,6 +131,12 @@
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
&__actions {
|
||||
display:flex;
|
||||
flex: auto;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.c-indicator {
|
||||
|
||||
@@ -58,6 +58,13 @@
|
||||
{{ entry.roleCount }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="c-status-poll-report__actions">
|
||||
<button
|
||||
class="c-button"
|
||||
title="Clear the previous poll question"
|
||||
@click="clearPollQuestion"
|
||||
>Clear Poll</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -74,6 +81,41 @@
|
||||
@click="updatePollQuestion"
|
||||
>Update</button>
|
||||
</div>
|
||||
<div class="c-table c-spq__poll-table">
|
||||
<table class="c-table__body">
|
||||
<thead class="c-table__header">
|
||||
<tr>
|
||||
<th>
|
||||
Position
|
||||
</th>
|
||||
<th>
|
||||
Status
|
||||
</th>
|
||||
<th>
|
||||
Age
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="statusForRole in statusesForRolesViewModel"
|
||||
:key="statusForRole.key"
|
||||
>
|
||||
<td>
|
||||
{{ statusForRole.role }}
|
||||
</td>
|
||||
<td
|
||||
:style="{ background: statusForRole.status.statusBgColor, color: statusForRole.status.statusFgColor }"
|
||||
>
|
||||
{{ statusForRole.status.label }}
|
||||
</td>
|
||||
<td>
|
||||
{{ statusForRole.age }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -97,9 +139,11 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
pollQuestionUpdated: '--',
|
||||
pollQuestionTimestamp: undefined,
|
||||
currentPollQuestion: '--',
|
||||
newPollQuestion: undefined,
|
||||
statusCountViewModel: []
|
||||
statusCountViewModel: [],
|
||||
statusesForRolesViewModel: []
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -135,9 +179,17 @@ export default {
|
||||
this.openmct.user.status.on('pollQuestionChange', this.setPollQuestion);
|
||||
},
|
||||
setPollQuestion(pollQuestion) {
|
||||
this.currentPollQuestion = pollQuestion.question;
|
||||
let pollQuestionText = pollQuestion.question;
|
||||
if (!pollQuestionText || pollQuestionText === '') {
|
||||
pollQuestionText = '--';
|
||||
this.indicator.text('No Poll Question');
|
||||
} else {
|
||||
this.indicator.text(pollQuestionText);
|
||||
}
|
||||
|
||||
this.currentPollQuestion = pollQuestionText;
|
||||
this.pollQuestionTimestamp = pollQuestion.timestamp;
|
||||
this.pollQuestionUpdated = new Date(pollQuestion.timestamp).toISOString();
|
||||
this.indicator.text(pollQuestion.question);
|
||||
},
|
||||
async updatePollQuestion() {
|
||||
const result = await this.openmct.user.status.setPollQuestion(this.newPollQuestion);
|
||||
@@ -149,6 +201,13 @@ export default {
|
||||
|
||||
this.newPollQuestion = undefined;
|
||||
},
|
||||
async clearPollQuestion() {
|
||||
this.currentPollQuestion = undefined;
|
||||
await Promise.all([
|
||||
this.openmct.user.status.resetAllStatuses(),
|
||||
this.openmct.user.status.setPollQuestion()
|
||||
]);
|
||||
},
|
||||
async fetchStatusSummary() {
|
||||
const allStatuses = await this.openmct.user.status.getPossibleStatuses();
|
||||
const statusCountMap = allStatuses.reduce((statusToCountMap, status) => {
|
||||
@@ -158,7 +217,6 @@ export default {
|
||||
}, {});
|
||||
const allStatusRoles = await this.openmct.user.status.getAllStatusRoles();
|
||||
const statusesForRoles = await Promise.all(allStatusRoles.map(role => this.openmct.user.status.getStatusForRole(role)));
|
||||
|
||||
statusesForRoles.forEach((status, i) => {
|
||||
const currentCount = statusCountMap[status.key];
|
||||
statusCountMap[status.key] = currentCount + 1;
|
||||
@@ -170,6 +228,51 @@ export default {
|
||||
roleCount: statusCountMap[status.key]
|
||||
};
|
||||
});
|
||||
const defaultStatuses = await Promise.all(allStatusRoles.map(role => this.openmct.user.status.getDefaultStatusForRole(role)));
|
||||
this.statusesForRolesViewModel = [];
|
||||
statusesForRoles.forEach((status, index) => {
|
||||
const isDefaultStatus = defaultStatuses[index].key === status.key;
|
||||
let statusTimestamp = status.timestamp;
|
||||
if (isDefaultStatus) {
|
||||
// if the default is selected, set timestamp to undefined
|
||||
statusTimestamp = undefined;
|
||||
}
|
||||
|
||||
this.statusesForRolesViewModel.push({
|
||||
status: this.applyStyling(status),
|
||||
role: allStatusRoles[index],
|
||||
age: this.formatStatusAge(statusTimestamp, this.pollQuestionTimestamp)
|
||||
});
|
||||
});
|
||||
},
|
||||
formatStatusAge(statusTimestamp, pollQuestionTimestamp) {
|
||||
if (statusTimestamp === undefined || pollQuestionTimestamp === undefined) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
const statusAgeInMs = statusTimestamp - pollQuestionTimestamp;
|
||||
const absoluteTotalSeconds = Math.floor(Math.abs(statusAgeInMs) / 1000);
|
||||
let hours = Math.floor(absoluteTotalSeconds / 3600);
|
||||
let minutes = Math.floor((absoluteTotalSeconds - (hours * 3600)) / 60);
|
||||
let secondsString = absoluteTotalSeconds - (hours * 3600) - (minutes * 60);
|
||||
|
||||
if (statusAgeInMs > 0 || (absoluteTotalSeconds === 0)) {
|
||||
hours = `+ ${hours}`;
|
||||
} else {
|
||||
hours = `- ${hours}`;
|
||||
}
|
||||
|
||||
if (minutes < 10) {
|
||||
minutes = `0${minutes}`;
|
||||
}
|
||||
|
||||
if (secondsString < 10) {
|
||||
secondsString = `0${secondsString}`;
|
||||
}
|
||||
|
||||
const statusAgeString = `${hours}:${minutes}:${secondsString}`;
|
||||
|
||||
return statusAgeString;
|
||||
},
|
||||
applyStyling(status) {
|
||||
const stylesForStatus = this.configuration?.statusStyles?.[status.label];
|
||||
|
||||
@@ -51,7 +51,7 @@ export default class PollQuestionIndicator extends AbstractStatusIndicator {
|
||||
createIndicator() {
|
||||
const pollQuestionIndicator = this.openmct.indicators.simpleIndicator();
|
||||
|
||||
pollQuestionIndicator.text("Poll Question");
|
||||
pollQuestionIndicator.text("No Poll Question");
|
||||
pollQuestionIndicator.description("Set the current poll question");
|
||||
pollQuestionIndicator.iconClass('icon-status-poll-edit');
|
||||
pollQuestionIndicator.element.classList.add("c-indicator--operator-status");
|
||||
|
||||
@@ -85,7 +85,10 @@
|
||||
<mct-chart
|
||||
:rectangles="rectangles"
|
||||
:highlights="highlights"
|
||||
:annotated-points="annotatedPoints"
|
||||
:annotation-selections="annotationSelections"
|
||||
:show-limit-line-labels="showLimitLineLabels"
|
||||
:annotation-viewing-and-editing-allowed="annotationViewingAndEditingAllowed"
|
||||
@plotReinitializeCanvas="initCanvas"
|
||||
@chartLoaded="initialize"
|
||||
/>
|
||||
@@ -211,6 +214,7 @@ import MctTicks from "./MctTicks.vue";
|
||||
import MctChart from "./chart/MctChart.vue";
|
||||
import XAxis from "./axis/XAxis.vue";
|
||||
import YAxis from "./axis/YAxis.vue";
|
||||
import KDBush from 'kdbush';
|
||||
import _ from "lodash";
|
||||
|
||||
const OFFSET_THRESHOLD = 10;
|
||||
@@ -268,6 +272,8 @@ export default {
|
||||
return {
|
||||
altPressed: false,
|
||||
highlights: [],
|
||||
annotatedPoints: [],
|
||||
annotationSelections: [],
|
||||
lockHighlightPoint: false,
|
||||
tickWidth: 0,
|
||||
yKeyOptions: [],
|
||||
@@ -298,6 +304,10 @@ export default {
|
||||
isFrozen() {
|
||||
return this.config.xAxis.get('frozen') === true && this.config.yAxis.get('frozen') === true;
|
||||
},
|
||||
annotationViewingAndEditingAllowed() {
|
||||
// only allow annotations viewing/editing if plot is paused or in fixed time mode
|
||||
return this.isFrozen || !this.isRealTime;
|
||||
},
|
||||
plotLegendPositionClass() {
|
||||
return !this.isNestedWithinAStackedPlot ? `plot-legend-${this.config.legend.get('position')}` : '';
|
||||
},
|
||||
@@ -361,16 +371,81 @@ export default {
|
||||
this.removeStatusListener = this.openmct.status.observe(this.domainObject.identifier, this.updateStatus);
|
||||
|
||||
this.openmct.objectViews.on('clearData', this.clearData);
|
||||
this.$on('loadingUpdated', this.loadAnnotations);
|
||||
this.openmct.selection.on('change', this.updateSelection);
|
||||
this.setTimeContext();
|
||||
|
||||
this.loaded = true;
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.openmct.selection.off('change', this.updateSelection);
|
||||
document.removeEventListener('keydown', this.handleKeyDown);
|
||||
document.removeEventListener('keyup', this.handleKeyUp);
|
||||
this.destroy();
|
||||
},
|
||||
methods: {
|
||||
updateSelection(selection) {
|
||||
const selectionContext = selection?.[0]?.[0]?.context?.item;
|
||||
if (!selectionContext
|
||||
|| this.openmct.objects.areIdsEqual(selectionContext.identifier, this.domainObject.identifier)) {
|
||||
// Selection changed, but it's us, so ignoring it
|
||||
return;
|
||||
}
|
||||
|
||||
const selectionType = selection?.[0]?.[1]?.context?.type;
|
||||
if (selectionType !== 'plot-points-selection') {
|
||||
// wrong type of selection
|
||||
return;
|
||||
}
|
||||
|
||||
const currentXaxis = this.config.xAxis.get('displayRange');
|
||||
const currentYaxis = this.config.yAxis.get('displayRange');
|
||||
|
||||
// when there is no plot data, the ranges can be undefined
|
||||
// in which case we should not perform selection
|
||||
if (!currentXaxis || !currentYaxis) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedAnnotations = selection?.[0]?.[1]?.context?.annotations;
|
||||
if (selectedAnnotations?.length) {
|
||||
// just use first annotation
|
||||
const boundingBoxes = Object.values(selectedAnnotations[0].targets);
|
||||
let minX = Number.MAX_SAFE_INTEGER;
|
||||
let minY = Number.MAX_SAFE_INTEGER;
|
||||
let maxX = Number.MIN_SAFE_INTEGER;
|
||||
let maxY = Number.MIN_SAFE_INTEGER;
|
||||
boundingBoxes.forEach(boundingBox => {
|
||||
if (boundingBox.minX < minX) {
|
||||
minX = boundingBox.minX;
|
||||
}
|
||||
|
||||
if (boundingBox.maxX > maxX) {
|
||||
maxX = boundingBox.maxX;
|
||||
}
|
||||
|
||||
if (boundingBox.maxY > maxY) {
|
||||
maxY = boundingBox.maxY;
|
||||
}
|
||||
|
||||
if (boundingBox.minY < minY) {
|
||||
minY = boundingBox.minY;
|
||||
}
|
||||
});
|
||||
|
||||
this.config.xAxis.set('displayRange', {
|
||||
min: minX,
|
||||
max: maxX
|
||||
});
|
||||
this.config.yAxis.set('displayRange', {
|
||||
min: minY,
|
||||
max: maxY
|
||||
});
|
||||
this.zoom('out', 0.2);
|
||||
}
|
||||
|
||||
this.prepareExistingAnnotationSelection(selectedAnnotations);
|
||||
},
|
||||
handleKeyDown(event) {
|
||||
if (event.key === 'Alt') {
|
||||
this.altPressed = true;
|
||||
@@ -445,7 +520,21 @@ export default {
|
||||
this.checkSameRangeValue();
|
||||
this.stopListening(plotSeries);
|
||||
},
|
||||
async loadAnnotations() {
|
||||
if (!this.openmct.annotation.getAvailableTags().length) {
|
||||
// don't bother loading annotations if there are no tags
|
||||
return;
|
||||
}
|
||||
|
||||
const rawAnnotationsForPlot = [];
|
||||
await Promise.all(this.seriesModels.map(async (seriesModel) => {
|
||||
const seriesAnnotations = await this.openmct.annotation.getAnnotations(seriesModel.model.identifier);
|
||||
rawAnnotationsForPlot.push(...seriesAnnotations);
|
||||
}));
|
||||
if (rawAnnotationsForPlot) {
|
||||
this.annotatedPoints = this.findAnnotationPoints(rawAnnotationsForPlot);
|
||||
}
|
||||
},
|
||||
loadSeriesData(series) {
|
||||
//this check ensures that duplicate requests don't happen on load
|
||||
if (!this.timeContext) {
|
||||
@@ -469,8 +558,7 @@ export default {
|
||||
end: bounds.end
|
||||
};
|
||||
|
||||
series.load(options)
|
||||
.then(this.stopLoading.bind(this));
|
||||
series.load(options).then(this.stopLoading.bind(this));
|
||||
},
|
||||
|
||||
loadMoreData(range, purge) {
|
||||
@@ -662,10 +750,83 @@ export default {
|
||||
this.listenTo(this.canvas, 'mousemove', this.trackMousePosition, this);
|
||||
this.listenTo(this.canvas, 'mouseleave', this.untrackMousePosition, this);
|
||||
this.listenTo(this.canvas, 'mousedown', this.onMouseDown, this);
|
||||
this.listenTo(this.canvas, 'click', this.selectNearbyAnnotations, this);
|
||||
this.listenTo(this.canvas, 'wheel', this.wheelZoom, this);
|
||||
}
|
||||
},
|
||||
|
||||
marqueeAnnotations(annotationsToSelect) {
|
||||
annotationsToSelect.forEach(annotationToSelect => {
|
||||
const firstTargetKeyString = Object.keys(annotationToSelect.targets)[0];
|
||||
const firstTarget = annotationToSelect.targets[firstTargetKeyString];
|
||||
const rectangle = {
|
||||
start: {
|
||||
x: firstTarget.minX,
|
||||
y: firstTarget.minY
|
||||
},
|
||||
end: {
|
||||
x: firstTarget.maxX,
|
||||
y: firstTarget.maxY
|
||||
},
|
||||
color: [1, 1, 1, 0.10]
|
||||
};
|
||||
this.rectangles.push(rectangle);
|
||||
|
||||
});
|
||||
},
|
||||
gatherNearbyAnnotations() {
|
||||
const nearbyAnnotations = [];
|
||||
this.config.series.models.forEach(series => {
|
||||
if (series.closest.annotationsById) {
|
||||
Object.values(series.closest.annotationsById).forEach(closeAnnotation => {
|
||||
const addedAnnotationAlready = nearbyAnnotations.some(annotation => {
|
||||
return _.isEqual(annotation.targets, closeAnnotation.targets)
|
||||
&& _.isEqual(annotation.tags, closeAnnotation.tags);
|
||||
});
|
||||
if (!addedAnnotationAlready) {
|
||||
nearbyAnnotations.push(closeAnnotation);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return nearbyAnnotations;
|
||||
},
|
||||
|
||||
prepareExistingAnnotationSelection(annotations) {
|
||||
const targetDomainObjects = {};
|
||||
this.config.series.models.forEach(series => {
|
||||
targetDomainObjects[series.keyString] = series.domainObject;
|
||||
});
|
||||
|
||||
const targetDetails = {};
|
||||
const uniqueBoundsAnnotations = [];
|
||||
annotations.forEach(annotation => {
|
||||
Object.entries(annotation.targets).forEach(([key, value]) => {
|
||||
targetDetails[key] = value;
|
||||
});
|
||||
|
||||
const boundingBoxAlreadyAdded = uniqueBoundsAnnotations.some(existingAnnotation => {
|
||||
const existingBoundingBox = Object.values(existingAnnotation.targets)[0];
|
||||
const newBoundingBox = Object.values(annotation.targets)[0];
|
||||
|
||||
return (existingBoundingBox.minX === newBoundingBox.minX
|
||||
&& existingBoundingBox.minY === newBoundingBox.minY
|
||||
&& existingBoundingBox.maxX === newBoundingBox.maxX
|
||||
&& existingBoundingBox.maxY === newBoundingBox.maxY);
|
||||
|
||||
});
|
||||
if (!boundingBoxAlreadyAdded) {
|
||||
uniqueBoundsAnnotations.push(annotation);
|
||||
}
|
||||
});
|
||||
this.marqueeAnnotations(uniqueBoundsAnnotations);
|
||||
|
||||
return {
|
||||
targetDomainObjects,
|
||||
targetDetails
|
||||
};
|
||||
},
|
||||
initialize() {
|
||||
this.handleWindowResize = _.debounce(this.handleWindowResize, 500);
|
||||
this.plotContainerResizeObserver = new ResizeObserver(this.handleWindowResize);
|
||||
@@ -805,7 +966,7 @@ export default {
|
||||
},
|
||||
|
||||
onMouseDown(event) {
|
||||
// do not monitor drag events on browser context click
|
||||
// do not monitor drag events on browser context click
|
||||
if (event.ctrlKey) {
|
||||
return;
|
||||
}
|
||||
@@ -817,10 +978,12 @@ export default {
|
||||
const isFrozen = this.config.xAxis.get('frozen') === true && this.config.yAxis.get('frozen') === true;
|
||||
this.isFrozenOnMouseDown = isFrozen;
|
||||
|
||||
if (event.altKey) {
|
||||
if (event.altKey && !event.shiftKey) {
|
||||
return this.startPan(event);
|
||||
} else if (this.annotationViewingAndEditingAllowed && event.altKey && event.shiftKey) {
|
||||
return this.startMarquee(event, true);
|
||||
} else {
|
||||
return this.startMarquee(event);
|
||||
return this.startMarquee(event, false);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -828,7 +991,7 @@ export default {
|
||||
this.stopListening(window, 'mouseup', this.onMouseUp, this);
|
||||
this.stopListening(window, 'mousemove', this.trackMousePosition, this);
|
||||
|
||||
if (this.isMouseClick()) {
|
||||
if (this.isMouseClick() && event.shiftKey) {
|
||||
this.lockHighlightPoint = !this.lockHighlightPoint;
|
||||
this.$emit('lockHighlightPoint', this.lockHighlightPoint);
|
||||
}
|
||||
@@ -869,7 +1032,9 @@ export default {
|
||||
this.marquee.endPixels = this.positionOverElement;
|
||||
},
|
||||
|
||||
startMarquee(event) {
|
||||
startMarquee(event, annotationEvent) {
|
||||
this.rectangles = [];
|
||||
this.annotationSelections = [];
|
||||
this.canvas.classList.remove('plot-drag');
|
||||
this.canvas.classList.add('plot-marquee');
|
||||
|
||||
@@ -883,12 +1048,153 @@ export default {
|
||||
end: this.positionOverPlot,
|
||||
color: [1, 1, 1, 0.5]
|
||||
};
|
||||
if (annotationEvent) {
|
||||
this.marquee.annotationEvent = true;
|
||||
}
|
||||
|
||||
this.rectangles.push(this.marquee);
|
||||
this.trackHistory();
|
||||
}
|
||||
},
|
||||
selectNearbyAnnotations(event) {
|
||||
event.stopPropagation();
|
||||
|
||||
endMarquee() {
|
||||
if (!this.annotationViewingAndEditingAllowed || this.annotationSelections.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nearbyAnnotations = this.gatherNearbyAnnotations();
|
||||
const { targetDomainObjects, targetDetails } = this.prepareExistingAnnotationSelection(nearbyAnnotations);
|
||||
this.selectPlotAnnotations({
|
||||
targetDetails,
|
||||
targetDomainObjects,
|
||||
annotations: nearbyAnnotations
|
||||
});
|
||||
},
|
||||
selectPlotAnnotations({targetDetails, targetDomainObjects, annotations}) {
|
||||
const selection =
|
||||
[
|
||||
{
|
||||
element: this.openmct.layout.$refs.browseObject.$el,
|
||||
context: {
|
||||
item: this.domainObject
|
||||
}
|
||||
},
|
||||
{
|
||||
element: this.$el,
|
||||
context: {
|
||||
type: 'plot-points-selection',
|
||||
targetDetails,
|
||||
targetDomainObjects,
|
||||
annotations,
|
||||
annotationType: this.openmct.annotation.ANNOTATION_TYPES.PLOT_SPATIAL,
|
||||
onAnnotationChange: this.onAnnotationChange
|
||||
}
|
||||
}
|
||||
];
|
||||
this.openmct.selection.select(selection, true);
|
||||
},
|
||||
selectNewPlotAnnotations(minX, minY, maxX, maxY, pointsInBox, event) {
|
||||
const boundingBox = {
|
||||
minX,
|
||||
minY,
|
||||
maxX,
|
||||
maxY
|
||||
};
|
||||
let targetDomainObjects = {};
|
||||
let targetDetails = {};
|
||||
let annotations = {};
|
||||
pointsInBox.forEach(pointInBox => {
|
||||
if (pointInBox.length) {
|
||||
const seriesID = pointInBox[0].series.keyString;
|
||||
targetDetails[seriesID] = boundingBox;
|
||||
targetDomainObjects[seriesID] = pointInBox[0].series.domainObject;
|
||||
}
|
||||
});
|
||||
this.selectPlotAnnotations({
|
||||
targetDetails,
|
||||
targetDomainObjects,
|
||||
annotations
|
||||
});
|
||||
},
|
||||
findAnnotationPoints(rawAnnotations) {
|
||||
const annotationsByPoints = [];
|
||||
rawAnnotations.forEach(rawAnnotation => {
|
||||
if (rawAnnotation.targets) {
|
||||
const targetValues = Object.values(rawAnnotation.targets);
|
||||
if (targetValues && targetValues.length) {
|
||||
// just get the first one
|
||||
const boundingBox = Object.values(targetValues)?.[0];
|
||||
const pointsInBox = this.getPointsInBox(boundingBox, rawAnnotation);
|
||||
if (pointsInBox && pointsInBox.length) {
|
||||
annotationsByPoints.push(pointsInBox.flat());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return annotationsByPoints.flat();
|
||||
},
|
||||
getPointsInBox(boundingBox, rawAnnotation) {
|
||||
// load series models in KD-Trees
|
||||
const seriesKDTrees = [];
|
||||
this.seriesModels.forEach(seriesModel => {
|
||||
const seriesData = seriesModel.getSeriesData();
|
||||
if (seriesData && seriesData.length) {
|
||||
const kdTree = new KDBush(seriesData,
|
||||
(point) => {
|
||||
return seriesModel.getXVal(point);
|
||||
},
|
||||
(point) => {
|
||||
return seriesModel.getYVal(point);
|
||||
}
|
||||
);
|
||||
const searchResults = [];
|
||||
const rangeResults = kdTree.range(boundingBox.minX, boundingBox.minY, boundingBox.maxX, boundingBox.maxY);
|
||||
rangeResults.forEach(id => {
|
||||
const seriesDatum = seriesData[id];
|
||||
if (seriesDatum) {
|
||||
const result = {
|
||||
series: seriesModel,
|
||||
point: seriesDatum
|
||||
};
|
||||
searchResults.push(result);
|
||||
}
|
||||
|
||||
if (rawAnnotation) {
|
||||
if (!seriesDatum.annotationsById) {
|
||||
seriesDatum.annotationsById = {};
|
||||
}
|
||||
|
||||
const annotationKeyString = this.openmct.objects.makeKeyString(rawAnnotation.identifier);
|
||||
seriesDatum.annotationsById[annotationKeyString] = rawAnnotation;
|
||||
}
|
||||
|
||||
});
|
||||
if (searchResults.length) {
|
||||
seriesKDTrees.push(searchResults);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return seriesKDTrees;
|
||||
},
|
||||
endAnnotationMarquee(event) {
|
||||
const minX = Math.min(this.marquee.start.x, this.marquee.end.x);
|
||||
const minY = Math.min(this.marquee.start.y, this.marquee.end.y);
|
||||
const maxX = Math.max(this.marquee.start.x, this.marquee.end.x);
|
||||
const maxY = Math.max(this.marquee.start.y, this.marquee.end.y);
|
||||
const boundingBox = {
|
||||
minX,
|
||||
minY,
|
||||
maxX,
|
||||
maxY
|
||||
};
|
||||
const pointsInBox = this.getPointsInBox(boundingBox);
|
||||
this.annotationSelections = pointsInBox.flat();
|
||||
this.selectNewPlotAnnotations(minX, minY, maxX, maxY, pointsInBox, event);
|
||||
},
|
||||
endZoomMarquee() {
|
||||
const startPixels = this.marquee.startPixels;
|
||||
const endPixels = this.marquee.endPixels;
|
||||
const marqueeDistance = Math.sqrt(
|
||||
@@ -911,9 +1217,25 @@ export default {
|
||||
// if marquee zoom doesn't occur.
|
||||
this.plotHistory.pop();
|
||||
}
|
||||
},
|
||||
endMarquee(event) {
|
||||
if (this.marquee.annotationEvent) {
|
||||
this.endAnnotationMarquee(event);
|
||||
} else {
|
||||
this.endZoomMarquee();
|
||||
this.rectangles = [];
|
||||
}
|
||||
|
||||
this.rectangles = [];
|
||||
this.marquee = undefined;
|
||||
this.marquee = null;
|
||||
},
|
||||
|
||||
onAnnotationChange(annotations) {
|
||||
if (this.marquee) {
|
||||
this.marquee.annotationEvent = false;
|
||||
this.endMarquee();
|
||||
}
|
||||
|
||||
this.loadAnnotations();
|
||||
},
|
||||
|
||||
zoom(zoomDirection, zoomFactor) {
|
||||
|
||||
@@ -50,10 +50,11 @@ import Vue from 'vue';
|
||||
|
||||
const MARKER_SIZE = 6.0;
|
||||
const HIGHLIGHT_SIZE = MARKER_SIZE * 2.0;
|
||||
const ANNOTATION_SIZE = MARKER_SIZE * 3.0;
|
||||
const CLEARANCE = 15;
|
||||
|
||||
export default {
|
||||
inject: ['openmct', 'domainObject'],
|
||||
inject: ['openmct', 'domainObject', 'path'],
|
||||
props: {
|
||||
rectangles: {
|
||||
type: Array,
|
||||
@@ -67,11 +68,27 @@ export default {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
annotatedPoints: {
|
||||
type: Array,
|
||||
default() {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
annotationSelections: {
|
||||
type: Array,
|
||||
default() {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
showLimitLineLabels: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
annotationViewingAndEditingAllowed: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -83,6 +100,12 @@ export default {
|
||||
highlights() {
|
||||
this.scheduleDraw();
|
||||
},
|
||||
annotatedPoints() {
|
||||
this.scheduleDraw();
|
||||
},
|
||||
annotationSelections() {
|
||||
this.scheduleDraw();
|
||||
},
|
||||
rectangles() {
|
||||
this.scheduleDraw();
|
||||
},
|
||||
@@ -148,10 +171,22 @@ export default {
|
||||
this.listenTo(series, 'change:alarmMarkers', this.changeAlarmMarkers, this);
|
||||
this.listenTo(series, 'change:limitLines', this.changeLimitLines, this);
|
||||
this.listenTo(series, 'change', this.scheduleDraw);
|
||||
this.listenTo(series, 'add', this.scheduleDraw);
|
||||
this.listenTo(series, 'add', this.onAddPoint);
|
||||
this.makeChartElement(series);
|
||||
this.makeLimitLines(series);
|
||||
},
|
||||
onAddPoint(point, insertIndex, series) {
|
||||
const xRange = this.config.xAxis.get('displayRange');
|
||||
const yRange = this.config.yAxis.get('displayRange');
|
||||
const xValue = series.getXVal(point);
|
||||
const yValue = series.getYVal(point);
|
||||
|
||||
// if user is not looking at data within the current bounds, don't draw the point
|
||||
if ((xValue > xRange.min) && (xValue < xRange.max)
|
||||
&& (yValue > yRange.min) && (yValue < yRange.max)) {
|
||||
this.scheduleDraw();
|
||||
}
|
||||
},
|
||||
changeInterpolate(mode, o, series) {
|
||||
if (mode === o) {
|
||||
return;
|
||||
@@ -439,6 +474,12 @@ export default {
|
||||
this.drawSeries();
|
||||
this.drawRectangles();
|
||||
this.drawHighlights();
|
||||
|
||||
// only draw these in fixed time mode or plot is paused
|
||||
if (this.annotationViewingAndEditingAllowed) {
|
||||
this.drawAnnotatedPoints();
|
||||
this.drawAnnotationSelections();
|
||||
}
|
||||
}
|
||||
},
|
||||
updateViewport() {
|
||||
@@ -584,6 +625,65 @@ export default {
|
||||
disconnected
|
||||
);
|
||||
},
|
||||
annotatedPointWithinRange(annotatedPoint, xRange, yRange) {
|
||||
const xValue = annotatedPoint.series.getXVal(annotatedPoint.point);
|
||||
const yValue = annotatedPoint.series.getYVal(annotatedPoint.point);
|
||||
|
||||
return ((xValue > xRange.min) && (xValue < xRange.max)
|
||||
&& (yValue > yRange.min) && (yValue < yRange.max));
|
||||
},
|
||||
drawAnnotatedPoints() {
|
||||
// we should do this by series, and then plot all the points at once instead
|
||||
// of doing it one by one
|
||||
if (this.annotatedPoints && this.annotatedPoints.length) {
|
||||
const uniquePointsToDraw = [];
|
||||
const xRange = this.config.xAxis.get('displayRange');
|
||||
const yRange = this.config.yAxis.get('displayRange');
|
||||
this.annotatedPoints.forEach((annotatedPoint) => {
|
||||
// if the annotation is outside the range, don't draw it
|
||||
if (this.annotatedPointWithinRange(annotatedPoint, xRange, yRange)) {
|
||||
const canvasXValue = this.offset.xVal(annotatedPoint.point, annotatedPoint.series);
|
||||
const canvasYValue = this.offset.yVal(annotatedPoint.point, annotatedPoint.series);
|
||||
const pointToDraw = new Float32Array([canvasXValue, canvasYValue]);
|
||||
const drawnPoint = uniquePointsToDraw.some((rawPoint) => {
|
||||
return rawPoint[0] === pointToDraw[0] && rawPoint[1] === pointToDraw[1];
|
||||
});
|
||||
if (!drawnPoint) {
|
||||
uniquePointsToDraw.push(pointToDraw);
|
||||
this.drawAnnotatedPoint(annotatedPoint, pointToDraw);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
drawAnnotatedPoint(annotatedPoint, pointToDraw) {
|
||||
if (annotatedPoint.point && annotatedPoint.series) {
|
||||
const color = annotatedPoint.series.get('color').asRGBAArray();
|
||||
// set transparency
|
||||
color[3] = 0.15;
|
||||
const pointCount = 1;
|
||||
const shape = annotatedPoint.series.get('markerShape');
|
||||
|
||||
this.drawAPI.drawPoints(pointToDraw, color, pointCount, ANNOTATION_SIZE, shape);
|
||||
}
|
||||
},
|
||||
drawAnnotationSelections() {
|
||||
if (this.annotationSelections && this.annotationSelections.length) {
|
||||
this.annotationSelections.forEach(this.drawAnnotationSelection, this);
|
||||
}
|
||||
},
|
||||
drawAnnotationSelection(annotationSelection) {
|
||||
const points = new Float32Array([
|
||||
this.offset.xVal(annotationSelection.point, annotationSelection.series),
|
||||
this.offset.yVal(annotationSelection.point, annotationSelection.series)
|
||||
]);
|
||||
|
||||
const color = [255, 255, 255, 1]; // white
|
||||
const pointCount = 1;
|
||||
const shape = annotationSelection.series.get('markerShape');
|
||||
|
||||
this.drawAPI.drawPoints(points, color, pointCount, ANNOTATION_SIZE, shape);
|
||||
},
|
||||
drawHighlights() {
|
||||
if (this.highlights && this.highlights.length) {
|
||||
this.highlights.forEach(this.drawHighlight, this);
|
||||
|
||||
@@ -29,7 +29,7 @@ import LegendModel from "./LegendModel";
|
||||
|
||||
/**
|
||||
* PlotConfiguration model stores the configuration of a plot and some
|
||||
* limited state. The indiidual parts of the plot configuration model
|
||||
* limited state. The individual parts of the plot configuration model
|
||||
* handle setting defaults and updating in response to various changes.
|
||||
*
|
||||
* @extends {Model<PlotConfigModelType, PlotConfigModelOptions>}
|
||||
|
||||
@@ -83,6 +83,10 @@ export default class PlotSeries extends Model {
|
||||
// Model.apply(this, arguments);
|
||||
this.onXKeyChange(this.get('xKey'));
|
||||
this.onYKeyChange(this.get('yKey'));
|
||||
this.xRangeMin = Number.MIN_SAFE_INTEGER;
|
||||
this.yRangeMin = Number.MIN_SAFE_INTEGER;
|
||||
this.xRangeMax = Number.MAX_SAFE_INTEGER;
|
||||
this.yRangeMax = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
this.unPlottableValues = [undefined, Infinity, -Infinity];
|
||||
}
|
||||
@@ -378,6 +382,7 @@ export default class PlotSeries extends Model {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a point to the data array while maintaining the sort order of
|
||||
* the array and preventing insertion of points with a duplicate x
|
||||
|
||||
@@ -178,10 +178,10 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
isNestedWithinAStackedPlot() {
|
||||
return this.path.find((pathObject, pathObjIndex) => pathObjIndex > 0 && pathObject.type === 'telemetry.plot.stacked');
|
||||
return this.path.find((pathObject, pathObjIndex) => pathObjIndex > 0 && pathObject?.type === 'telemetry.plot.stacked');
|
||||
},
|
||||
isStackedPlotObject() {
|
||||
return this.path.find((pathObject, pathObjIndex) => pathObjIndex === 0 && pathObject.type === 'telemetry.plot.stacked');
|
||||
return this.path.find((pathObject, pathObjIndex) => pathObjIndex === 0 && pathObject?.type === 'telemetry.plot.stacked');
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
@@ -58,6 +58,7 @@ export default class ViewLargeAction {
|
||||
|
||||
_expand(objectPath, view) {
|
||||
const element = this._getPreview(objectPath, view);
|
||||
view.onPreviewModeChange?.({ isPreviewing: true });
|
||||
|
||||
this.overlay = this.openmct.overlays.overlay({
|
||||
element,
|
||||
@@ -67,6 +68,7 @@ export default class ViewLargeAction {
|
||||
this.preview.$destroy();
|
||||
this.preview = undefined;
|
||||
delete this.preview;
|
||||
view.onPreviewModeChange?.();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -463,6 +463,8 @@ $transInBounceBig: all 300ms cubic-bezier(.2,1.6,.6,3);
|
||||
$createBtnTextTransform: uppercase;
|
||||
$colorDiscreteItemBg: rgba($colorBodyFg,0.1);
|
||||
$colorDiscreteItemCurrentBg: rgba($colorOk,0.3);
|
||||
$scrollContainer: $colorBodyBg;
|
||||
;
|
||||
|
||||
@mixin discreteItem() {
|
||||
background: $colorDiscreteItemBg;
|
||||
|
||||
@@ -467,6 +467,7 @@ $transInBounceBig: all 300ms cubic-bezier(.2,1.6,.6,3);
|
||||
$createBtnTextTransform: uppercase;
|
||||
$colorDiscreteItemBg: rgba($colorBodyFg,0.1);
|
||||
$colorDiscreteItemCurrentBg: rgba($colorOk,0.3);
|
||||
$scrollContainer: $colorBodyBg;
|
||||
|
||||
@mixin discreteItem() {
|
||||
background: rgba($colorBodyFg,0.1);
|
||||
|
||||
@@ -463,6 +463,7 @@ $transInBounceBig: all 300ms cubic-bezier(.2,1.6,.6,3);
|
||||
$createBtnTextTransform: uppercase;
|
||||
$colorDiscreteItemBg: rgba($colorBodyFg,0.1);
|
||||
$colorDiscreteItemCurrentBg: rgba($colorOk,0.3);
|
||||
$scrollContainer: rgba(102, 102, 102, 0.1);
|
||||
|
||||
@mixin discreteItem() {
|
||||
background: $colorDiscreteItemBg;
|
||||
|
||||
@@ -327,3 +327,6 @@ $bg-icon-timelist: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://ww
|
||||
$bg-icon-plot-scatter: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3e%3cg data-name='Layer 2'%3e%3cpath d='M96 0C43.2 0 0 43.2 0 96v320c0 52.8 43.2 96 96 96h320c52.8 0 96-43.2 96-96V96c0-52.8-43.2-96-96-96ZM64 176a48 48 0 1 1 48 48 48 48 0 0 1-48-48Zm80 240a48 48 0 1 1 48-48 48 48 0 0 1-48 48Zm128-96a48 48 0 1 1 48-48 48 48 0 0 1-48 48Zm0-160a48 48 0 1 1 48-48 48 48 0 0 1-48 48Zm128 256a48 48 0 1 1 48-48 48 48 0 0 1-48 48Z' data-name='Layer 1'/%3e%3c/g%3e%3c/svg%3e");
|
||||
$bg-icon-notebook-shift-log: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3e%3cpath d='M448 55.36c0-39.95-27.69-63.66-61.54-52.68L0 128h448V55.36ZM448 160H0v288c0 35.2 28.8 64 64 64h384c35.2 0 64-28.8 64-64V224c0-35.2-28.8-64-64-64ZM128 416H64v-64h64v64Zm0-96H64v-64h64v64Zm320 96H192v-64h256v64Zm0-96H192v-64h256v64Z'/%3e%3c/svg%3e");
|
||||
$bg-icon-telemetry-aggregate: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3e%3cg data-name='Layer 2'%3e%3cg data-name='Layer 3'%3e%3cpath d='M39 197.72c7-20.72 18.74-50.4 34.6-74.18C92.91 94.65 114.79 80 138.67 80s45.75 14.65 65 43.54c15.86 23.78 27.57 53.46 34.6 74.18 15.44 45.48 31.56 67.49 39 73.27 7.47-5.78 23.6-27.79 39-73.27 7-20.72 18.74-50.4 34.61-74.18q13.9-20.85 29.56-31.75A207.78 207.78 0 0 0 208 0C93.12 0 0 93.12 0 208a208.14 208.14 0 0 0 7.39 55.09c8.39-10.87 20.2-31.67 31.61-65.37Z'/%3e%3cpath d='M377 218.28c-7 20.72-18.74 50.4-34.6 74.18-19.28 28.89-41.16 43.54-65 43.54s-45.75-14.65-65-43.54c-15.86-23.78-27.57-53.46-34.6-74.18-15.44-45.48-31.57-67.49-39-73.27-7.47 5.78-23.6 27.79-39 73.27-7.19 20.72-18.9 50.4-34.8 74.18q-13.9 20.85-29.56 31.75A207.78 207.78 0 0 0 208 416c114.88 0 208-93.12 208-208a208.14 208.14 0 0 0-7.39-55.09c-8.39 10.87-20.2 31.67-31.61 65.37Z'/%3e%3cpath d='M460.78 167.31A258.4 258.4 0 0 1 464 208a255.84 255.84 0 0 1-256 256 258.4 258.4 0 0 1-40.69-3.22A207.23 207.23 0 0 0 304 512c114.88 0 208-93.12 208-208a207.23 207.23 0 0 0-51.22-136.69Z'/%3e%3c/g%3e%3c/g%3e%3c/svg%3e");
|
||||
$bg-icon-trash: url("data:image/svg+xml;charset=UTF-8,%3csvg version='1.1' id='Layer_1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' width='512px' height='512px' viewBox='0 0 512 512' enable-background='new 0 0 512 512' xml:space='preserve'%3e%3cpath d='M416,64h-96.18V32c0-17.6-14.4-32-32-32h-64c-17.6,0-32,14.4-32,32v32H96c-52.8,0-96,36-96,80s0,80,0,80h32v192 c0,52.8,43.2,96,96,96h256c52.8,0,96-43.2,96-96V224h32c0,0,0-36,0-80S468.8,64,416,64z M160,416H96V224h64V416z M288,416h-64V224 h64V416z M416,416h-64V224h64V416z'/%3e%3c/svg%3e");
|
||||
$bg-icon-eye-open: url("data:image/svg+xml;charset=UTF-8,%3csvg version='1.1' id='Layer_1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' viewBox='0 0 512 512' style='enable-background:new 0 0 512 512;' xml:space='preserve'%3e%3cstyle type='text/css'%3e .st0%7bfill:%2300A14B;%7d %3c/style%3e%3ctitle%3eicon-eye-open-v2%3c/title%3e%3cg%3e%3cpath class='st0' d='M256,58.2c-122.9,0-226.1,84-255.4,197.8C29.9,369.7,133.1,453.8,256,453.8s226.1-84,255.4-197.8 C482.1,142.3,378.9,58.2,256,58.2z M414.6,294.2c-11.3,17.2-25.3,32.4-41.5,45.2c-16.4,12.9-34.5,22.8-54,29.7 c-20.2,7.1-41.4,10.7-63,10.7s-42.9-3.6-63-10.7c-19.5-6.9-37.7-16.9-54-29.7c-16.2-12.8-30.2-27.9-41.5-45.2 c-7.9-12-14.4-24.8-19.3-38.2c5-13.4,11.5-26.2,19.3-38.2c11.3-17.2,25.3-32.4,41.5-45.2c16.4-12.9,34.5-22.8,54-29.7 c20.2-7.1,41.4-10.7,63-10.7s42.9,3.6,63,10.7c19.5,6.9,37.7,16.9,54,29.7c16.2,12.8,30.2,27.9,41.5,45.2 c7.9,12,14.4,24.8,19.3,38.2C429,269.4,422.5,282.2,414.6,294.2z'/%3e%3ccircle class='st0' cx='256' cy='256' r='96'/%3e%3c/g%3e%3c/svg%3e");
|
||||
$bg-icon-camera: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3e%3ctitle%3eicon-camera-v2%3c/title%3e%3cpath d='M448,128H384L320,0H192L128,128H64A64.2,64.2,0,0,0,0,192V448a64.2,64.2,0,0,0,64,64H448a64.2,64.2,0,0,0,64-64V192A64.2,64.2,0,0,0,448,128ZM256,432A128,128,0,1,1,384,304,128,128,0,0,1,256,432Z'/%3e%3c/svg%3e");
|
||||
|
||||
@@ -194,6 +194,7 @@ button {
|
||||
.c-icon-button {
|
||||
[class*='label'] {
|
||||
opacity: 0.8;
|
||||
padding: 1px 0;
|
||||
}
|
||||
|
||||
&--mixed {
|
||||
@@ -461,6 +462,15 @@ input[type=number].c-input-number--no-spinners {
|
||||
}
|
||||
}
|
||||
|
||||
.c-scrollcontainer{
|
||||
@include nice-input();
|
||||
margin-top: $interiorMargin;
|
||||
background: $scrollContainer;
|
||||
border-radius: $controlCr;
|
||||
overflow: auto;
|
||||
padding: $interiorMarginSm;
|
||||
}
|
||||
|
||||
// SELECTS
|
||||
select {
|
||||
@include appearanceNone();
|
||||
|
||||
@@ -265,3 +265,6 @@
|
||||
.bg-icon-plot-scatter { @include glyphBg($bg-icon-plot-scatter); }
|
||||
.bg-icon-notebook-shift-log { @include glyphBg($bg-icon-notebook-shift-log); }
|
||||
.bg-icon-telemetry-aggregate { @include glyphBg($bg-icon-telemetry-aggregate); }
|
||||
.bg-icon-trash { @include glyphBg($bg-icon-trash); }
|
||||
.bg-icon-eye-open { @include glyphBg($bg-icon-eye-open); }
|
||||
.bg-icon-camera { @include glyphBg($bg-icon-camera); }
|
||||
|
||||
@@ -137,7 +137,7 @@
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba($colorKey, 0.2);
|
||||
background: rgba($colorBodyFg, 0.1); //$colorInteriorBorder;
|
||||
//color: $colorBodyFg;
|
||||
}
|
||||
|
||||
@@ -283,6 +283,14 @@
|
||||
@include discreteItem();
|
||||
display: flex;
|
||||
padding: $interiorMarginSm $interiorMarginSm $interiorMarginSm $interiorMargin;
|
||||
|
||||
&:hover {
|
||||
background: rgba($colorBodyFg, 0.2);
|
||||
}
|
||||
|
||||
&.is-selected {
|
||||
background: rgba($colorKey, 0.3);
|
||||
}
|
||||
|
||||
&__text,
|
||||
&__local-controls {
|
||||
@@ -295,11 +303,25 @@
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&__time-and-creator,
|
||||
&__time-and-creator-and-delete,
|
||||
&__input {
|
||||
padding: $p;
|
||||
}
|
||||
|
||||
&__input{
|
||||
word-break: break-word;
|
||||
}
|
||||
&__time-and-creator-and-delete{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
> * + *{
|
||||
float: right;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
&__creator [class*='icon'] {
|
||||
font-size: 0.95em;
|
||||
}
|
||||
@@ -307,6 +329,7 @@
|
||||
&__time-and-content {
|
||||
display: block;
|
||||
flex: 1 1 auto;
|
||||
overflow: visible;
|
||||
|
||||
> * + * {
|
||||
margin-top: $interiorMarginSm;
|
||||
@@ -324,9 +347,10 @@
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
margin-right: $interiorMarginSm;
|
||||
margin-top: $interiorMargin;
|
||||
|
||||
> [class*="__"] + [class*="__"] {
|
||||
margin-top: $interiorMarginSm;
|
||||
@@ -350,7 +374,8 @@
|
||||
@include inlineInput;
|
||||
padding-left: $p;
|
||||
padding-right: $p;
|
||||
|
||||
overflow: unset;
|
||||
margin-bottom: $interiorMargin;
|
||||
@include hover {
|
||||
&:not(:focus, .locked) {
|
||||
background: rgba($colorBodyFg, 0.1);
|
||||
@@ -386,12 +411,17 @@
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
&__remove{
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/****************************** EMBEDS */
|
||||
@mixin snapThumb() {
|
||||
// LEGACY: TODO: refactor when .snap-thumb in New Entry dialog is refactored
|
||||
$d: 30px;
|
||||
$d: 40px;
|
||||
border: 1px solid $colorInteriorBorder;
|
||||
cursor: pointer;
|
||||
width: $d;
|
||||
@@ -409,21 +439,24 @@
|
||||
// LEGACY,
|
||||
@include snapThumb();
|
||||
}
|
||||
.c-ne__embeds-wrapper{
|
||||
max-height: 75px;
|
||||
padding-left: $interiorMargin;
|
||||
padding-top: $interiorMargin;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.c-ne__embed {
|
||||
@include discreteItemInnerElem();
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
padding: $interiorMargin;
|
||||
|
||||
[class*="__"] + [class*="__"] {
|
||||
margin-left: $interiorMargin;
|
||||
}
|
||||
border: 1px solid $colorInteriorBorder;
|
||||
|
||||
&__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
margin-left: $interiorMargin;
|
||||
a {
|
||||
color: $colorKey;
|
||||
}
|
||||
@@ -437,9 +470,10 @@
|
||||
}
|
||||
|
||||
&__link {
|
||||
flex: 1 1 auto;
|
||||
&:before {
|
||||
display: block;
|
||||
font-size: 0.85em;
|
||||
font-size: 1em;
|
||||
margin-right: $interiorMarginSm;
|
||||
}
|
||||
}
|
||||
@@ -452,6 +486,24 @@
|
||||
&__snap-thumb {
|
||||
@include snapThumb();
|
||||
}
|
||||
&__actions{
|
||||
margin: $interiorMarginSm;
|
||||
}
|
||||
&__actions-menu {
|
||||
width: 55vh;
|
||||
max-width: 500px;
|
||||
height: 130px;
|
||||
z-index: 70;
|
||||
[class*="__icon"] {
|
||||
filter: $colorKeyFilter;
|
||||
margin: 0%;
|
||||
height: 4vh;
|
||||
}
|
||||
[class*="__item-description"] {
|
||||
min-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/****************************** SNAPSHOTTING */
|
||||
@@ -692,6 +744,7 @@ body.mobile {
|
||||
.c-notebook__entry {
|
||||
[class*="local-controls"] {
|
||||
display: none;
|
||||
height: fit-content;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,12 +42,14 @@
|
||||
@import "../ui/inspector/elements.scss";
|
||||
@import "../ui/inspector/inspector.scss";
|
||||
@import "../ui/inspector/location.scss";
|
||||
@import "../ui/inspector/annotations/annotation-inspector.scss";
|
||||
@import "../ui/layout/app-logo.scss";
|
||||
@import "../ui/layout/create-button.scss";
|
||||
@import "../ui/layout/layout.scss";
|
||||
@import "../ui/layout/mct-tree.scss";
|
||||
@import "../ui/layout/search/search.scss";
|
||||
@import "../ui/layout/pane.scss";
|
||||
@import "../ui/layout/recent-objects.scss";
|
||||
@import "../ui/layout/status-bar/indicators.scss";
|
||||
@import "../ui/layout/status-bar/notification-banner.scss";
|
||||
@import "../ui/preview/preview.scss";
|
||||
|
||||
@@ -60,8 +60,15 @@ export function identifierToString(openmct, objectPath) {
|
||||
return '#/browse/' + openmct.objects.getRelativePath(objectPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('../../openmct').OpenMCT} openmct
|
||||
* @param {Array<import('../api/objects/ObjectAPI').DomainObject>} objectPath
|
||||
* @param {any} customUrlParams
|
||||
* @returns {string} url
|
||||
*/
|
||||
export default function objectPathToUrl(openmct, objectPath, customUrlParams = {}) {
|
||||
let url = identifierToString(openmct, objectPath);
|
||||
|
||||
let urlParams = paramsToArray(openmct, customUrlParams);
|
||||
if (urlParams.length) {
|
||||
url += '?' + urlParams.join('&');
|
||||
|
||||
@@ -26,7 +26,7 @@ describe('the url tool', function () {
|
||||
];
|
||||
openmct = createOpenMct();
|
||||
openmct.on('start', () => {
|
||||
openmct.router.setPath(' http://localhost:8020/foobar?testParam1=testValue1');
|
||||
openmct.router.setPath('/browse/mine?testParam1=testValue1');
|
||||
done();
|
||||
});
|
||||
openmct.startHeadless();
|
||||
|
||||
@@ -22,11 +22,11 @@
|
||||
|
||||
<template>
|
||||
<ul
|
||||
v-if="orderedOriginalPath.length"
|
||||
v-if="orderedPath.length"
|
||||
class="c-location"
|
||||
>
|
||||
<li
|
||||
v-for="pathObject in orderedOriginalPath"
|
||||
v-for="pathObject in orderedPath"
|
||||
:key="pathObject.key"
|
||||
class="c-location__item"
|
||||
>
|
||||
@@ -65,11 +65,17 @@ export default {
|
||||
default() {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
objectPath: {
|
||||
type: Array,
|
||||
default() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
orderedOriginalPath: []
|
||||
orderedPath: []
|
||||
};
|
||||
},
|
||||
async mounted() {
|
||||
@@ -79,9 +85,14 @@ export default {
|
||||
this.keyString = keyString;
|
||||
this.originalPath = [];
|
||||
|
||||
const rawOriginalPath = await this.openmct.objects.getOriginalPath(keyString);
|
||||
let rawPath = null;
|
||||
if (this.objectPath === null) {
|
||||
rawPath = await this.openmct.objects.getOriginalPath(keyString);
|
||||
} else {
|
||||
rawPath = this.objectPath;
|
||||
}
|
||||
|
||||
const pathWithDomainObject = rawOriginalPath.map((domainObject, index, pathArray) => {
|
||||
const pathWithDomainObject = rawPath.map((domainObject, index, pathArray) => {
|
||||
let key = this.openmct.objects.makeKeyString(domainObject.identifier);
|
||||
const objectPath = pathArray.slice(index);
|
||||
|
||||
@@ -93,10 +104,10 @@ export default {
|
||||
});
|
||||
if (this.showObjectItself) {
|
||||
// remove ROOT only
|
||||
this.orderedOriginalPath = pathWithDomainObject.slice(0, pathWithDomainObject.length - 1).reverse();
|
||||
this.orderedPath = pathWithDomainObject.slice(0, pathWithDomainObject.length - 1).reverse();
|
||||
} else {
|
||||
// remove ROOT and object itself from path
|
||||
this.orderedOriginalPath = pathWithDomainObject.slice(1, pathWithDomainObject.length - 1).reverse();
|
||||
this.orderedPath = pathWithDomainObject.slice(1, pathWithDomainObject.length - 1).reverse();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
&__name {
|
||||
@include ellipsize();
|
||||
display: inline;
|
||||
padding: 1px 0;
|
||||
}
|
||||
|
||||
&__type-icon {
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
title="Add new tag"
|
||||
@click="addTag"
|
||||
>
|
||||
<div class="c-icon-button__label">Add Tag</div>
|
||||
<div class="c-icon-button__label c-tag-btn__label">Add Tag</div>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -57,17 +57,28 @@ export default {
|
||||
},
|
||||
annotationType: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
targetSpecificDetails: {
|
||||
type: Object,
|
||||
required: true
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
domainObject: {
|
||||
type: Object,
|
||||
default() {
|
||||
return null;
|
||||
}
|
||||
required: true,
|
||||
default: null
|
||||
},
|
||||
targets: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default: null
|
||||
},
|
||||
targetDomainObjects: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default: null
|
||||
},
|
||||
onTagChange: {
|
||||
type: Function,
|
||||
required: false,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -99,7 +110,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
annotationsChanged() {
|
||||
if (this.annotations && this.annotations.length) {
|
||||
if (this.annotations) {
|
||||
this.tagsChanged();
|
||||
}
|
||||
},
|
||||
@@ -141,27 +152,47 @@ export default {
|
||||
this.userAddingTag = true;
|
||||
},
|
||||
async tagRemoved(tagToRemove) {
|
||||
// Soft delete annotations that match tag instead
|
||||
// Soft delete annotations that match tag instead (that aren't already deleted)
|
||||
const annotationsToDelete = this.annotations.filter((annotation) => {
|
||||
return annotation.tags.includes(tagToRemove);
|
||||
return annotation.tags.includes(tagToRemove) && !annotation._deleted;
|
||||
});
|
||||
if (annotationsToDelete) {
|
||||
await this.openmct.annotation.deleteAnnotations(annotationsToDelete);
|
||||
this.$emit('tags-updated', annotationsToDelete);
|
||||
if (this.onTagChange) {
|
||||
this.onTagChange(this.annotations);
|
||||
}
|
||||
}
|
||||
},
|
||||
async tagAdded(newTag) {
|
||||
// Either undelete an annotation, or create one (1) new annotation
|
||||
const existingAnnotation = this.annotations.find((annotation) => {
|
||||
let existingAnnotation = this.annotations.find((annotation) => {
|
||||
return annotation.tags.includes(newTag);
|
||||
});
|
||||
|
||||
const createdAnnotation = await this.openmct.annotation.addSingleAnnotationTag(existingAnnotation,
|
||||
this.domainObject, this.targetSpecificDetails, this.annotationType, newTag);
|
||||
if (!existingAnnotation) {
|
||||
const contentText = `${this.annotationType} tag`;
|
||||
const annotationCreationArguments = {
|
||||
name: contentText,
|
||||
existingAnnotation,
|
||||
contentText: contentText,
|
||||
targets: this.targets,
|
||||
targetDomainObjects: this.targetDomainObjects,
|
||||
domainObject: this.domainObject,
|
||||
annotationType: this.annotationType,
|
||||
tags: [newTag]
|
||||
};
|
||||
existingAnnotation = await this.openmct.annotation.create(annotationCreationArguments);
|
||||
} else if (existingAnnotation._deleted) {
|
||||
this.openmct.annotation.unDeleteAnnotation(existingAnnotation);
|
||||
}
|
||||
|
||||
this.userAddingTag = false;
|
||||
|
||||
this.$emit('tags-updated', createdAnnotation);
|
||||
this.$emit('tags-updated', existingAnnotation);
|
||||
if (this.onTagChange) {
|
||||
this.onTagChange([existingAnnotation]);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
<div
|
||||
v-else
|
||||
class="c-tag"
|
||||
:class="{'c-tag-edit': !readOnly}"
|
||||
:style="{ background: selectedBackgroundColor, color: selectedForegroundColor }"
|
||||
>
|
||||
<div
|
||||
@@ -42,6 +43,7 @@
|
||||
aria-label="Tag"
|
||||
>{{ selectedTagLabel }} </div>
|
||||
<button
|
||||
v-show="!readOnly"
|
||||
class="c-completed-tag-deletion c-tag__remove-btn icon-x-in-circle"
|
||||
@click="removeTag"
|
||||
></button>
|
||||
@@ -77,6 +79,12 @@ export default {
|
||||
default() {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
readOnly: {
|
||||
type: Boolean,
|
||||
default() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
|
||||
@@ -54,6 +54,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.c-tag-btn__label {
|
||||
overflow: visible!important;
|
||||
}
|
||||
|
||||
/******************************* HOVERS */
|
||||
.has-tag-applier {
|
||||
// Apply this class to all components that should trigger tag removal btn on hover
|
||||
|
||||
@@ -36,7 +36,6 @@
|
||||
>
|
||||
{{ tabbedView.name }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="c-inspector__content">
|
||||
<multipane
|
||||
@@ -44,10 +43,12 @@
|
||||
type="vertical"
|
||||
>
|
||||
<pane class="c-inspector__properties">
|
||||
<Properties
|
||||
v-if="!activity"
|
||||
/>
|
||||
<location />
|
||||
<Properties v-if="!activity" />
|
||||
<div
|
||||
v-if="!multiSelect"
|
||||
class="c-inspect-properties c-inspect-properties--location"
|
||||
>
|
||||
</div>
|
||||
<inspector-views />
|
||||
</pane>
|
||||
<pane
|
||||
@@ -75,39 +76,49 @@
|
||||
<SavedStylesInspectorView :is-editing="isEditing" />
|
||||
</pane>
|
||||
</multipane>
|
||||
<multipane
|
||||
v-show="currentTabbedView.key === '__annotations'"
|
||||
type="vertical"
|
||||
>
|
||||
<pane class="c-inspector__annotations">
|
||||
<AnnotationsInspectorView
|
||||
@annotationCreated="updateCurrentTab(tabbedViews[2])"
|
||||
/>
|
||||
</pane>
|
||||
</multipane>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import multipane from '../layout/multipane.vue';
|
||||
import pane from '../layout/pane.vue';
|
||||
import ElementsPool from './ElementsPool.vue';
|
||||
import Location from './Location.vue';
|
||||
import Properties from './details/Properties.vue';
|
||||
import ObjectName from './ObjectName.vue';
|
||||
import InspectorViews from './InspectorViews.vue';
|
||||
import multipane from "../layout/multipane.vue";
|
||||
import pane from "../layout/pane.vue";
|
||||
import ElementsPool from "./ElementsPool.vue";
|
||||
import Properties from "./details/Properties.vue";
|
||||
import ObjectName from "./ObjectName.vue";
|
||||
import InspectorViews from "./InspectorViews.vue";
|
||||
import _ from "lodash";
|
||||
import stylesManager from '@/ui/inspector/styles/StylesManager';
|
||||
import StylesInspectorView from '@/ui/inspector/styles/StylesInspectorView.vue';
|
||||
import SavedStylesInspectorView from '@/ui/inspector/styles/SavedStylesInspectorView.vue';
|
||||
import stylesManager from "@/ui/inspector/styles/StylesManager";
|
||||
import StylesInspectorView from "@/ui/inspector/styles/StylesInspectorView.vue";
|
||||
import SavedStylesInspectorView from "@/ui/inspector/styles/SavedStylesInspectorView.vue";
|
||||
import AnnotationsInspectorView from "./annotations/AnnotationsInspectorView.vue";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
StylesInspectorView,
|
||||
SavedStylesInspectorView,
|
||||
AnnotationsInspectorView,
|
||||
multipane,
|
||||
pane,
|
||||
ElementsPool,
|
||||
Properties,
|
||||
ObjectName,
|
||||
Location,
|
||||
InspectorViews
|
||||
},
|
||||
provide: {
|
||||
stylesManager: stylesManager
|
||||
},
|
||||
inject: ['openmct'],
|
||||
inject: ["openmct"],
|
||||
props: {
|
||||
isEditing: {
|
||||
type: Boolean,
|
||||
@@ -117,40 +128,64 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
hasComposition: false,
|
||||
multiSelect: false,
|
||||
showStyles: false,
|
||||
tabbedViews: [{
|
||||
key: '__properties',
|
||||
name: 'Properties'
|
||||
}, {
|
||||
key: '__styles',
|
||||
name: 'Styles'
|
||||
}],
|
||||
tabbedViews: [
|
||||
{
|
||||
key: "__properties",
|
||||
name: "Properties"
|
||||
},
|
||||
{
|
||||
key: "__styles",
|
||||
name: "Styles"
|
||||
},
|
||||
{
|
||||
key: "__annotations",
|
||||
name: "Annotations"
|
||||
}
|
||||
],
|
||||
currentTabbedView: {},
|
||||
activity: undefined
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.excludeObjectTypes = ['folder', 'webPage', 'conditionSet', 'summary-widget', 'hyperlink'];
|
||||
this.openmct.selection.on('change', this.updateInspectorViews);
|
||||
this.excludeObjectTypes = [
|
||||
"folder",
|
||||
"webPage",
|
||||
"conditionSet",
|
||||
"summary-widget",
|
||||
"hyperlink"
|
||||
];
|
||||
this.openmct.selection.on("change", this.updateInspectorViews);
|
||||
},
|
||||
destroyed() {
|
||||
this.openmct.selection.off('change', this.updateInspectorViews);
|
||||
this.openmct.selection.off("change", this.updateInspectorViews);
|
||||
},
|
||||
methods: {
|
||||
updateInspectorViews(selection) {
|
||||
this.refreshComposition(selection);
|
||||
|
||||
if (this.openmct.types.get('conditionSet')) {
|
||||
if (this.openmct.types.get("conditionSet")) {
|
||||
this.refreshTabs(selection);
|
||||
}
|
||||
|
||||
if (selection.length > 1) {
|
||||
this.multiSelect = true;
|
||||
|
||||
// return;
|
||||
} else {
|
||||
this.multiSelect = false;
|
||||
}
|
||||
|
||||
this.setActivity(selection);
|
||||
},
|
||||
refreshComposition(selection) {
|
||||
if (selection.length > 0 && selection[0].length > 0) {
|
||||
let parentObject = selection[0][0].context.item;
|
||||
|
||||
this.hasComposition = Boolean(parentObject && this.openmct.composition.get(parentObject));
|
||||
this.hasComposition = Boolean(
|
||||
parentObject && this.openmct.composition.get(parentObject)
|
||||
);
|
||||
}
|
||||
},
|
||||
refreshTabs(selection) {
|
||||
@@ -160,21 +195,33 @@ export default {
|
||||
let object = selection[0][0].context.item;
|
||||
if (object) {
|
||||
let type = this.openmct.types.get(object.type);
|
||||
this.showStyles = this.isLayoutObject(selection[0], object.type) || this.isCreatableObject(object, type);
|
||||
this.showStyles =
|
||||
this.isLayoutObject(selection[0], object.type)
|
||||
|| this.isCreatableObject(object, type);
|
||||
}
|
||||
|
||||
if (!this.currentTabbedView.key || (!this.showStyles && this.currentTabbedView.key === this.tabbedViews[1].key)) {
|
||||
if (
|
||||
!this.currentTabbedView.key
|
||||
|| (!this.showStyles
|
||||
&& this.currentTabbedView.key === this.tabbedViews[1].key)
|
||||
) {
|
||||
this.updateCurrentTab(this.tabbedViews[0]);
|
||||
}
|
||||
}
|
||||
},
|
||||
isLayoutObject(selection, objectType) {
|
||||
//we allow conditionSets to be styled if they're part of a layout
|
||||
return selection.length > 1
|
||||
&& ((objectType === 'conditionSet') || (this.excludeObjectTypes.indexOf(objectType) < 0));
|
||||
return (
|
||||
selection.length > 1
|
||||
&& (objectType === "conditionSet"
|
||||
|| this.excludeObjectTypes.indexOf(objectType) < 0)
|
||||
);
|
||||
},
|
||||
isCreatableObject(object, type) {
|
||||
return (this.excludeObjectTypes.indexOf(object.type) < 0) && type.definition.creatable;
|
||||
return (
|
||||
this.excludeObjectTypes.indexOf(object.type) < 0
|
||||
&& type.definition.creatable
|
||||
);
|
||||
},
|
||||
updateCurrentTab(view) {
|
||||
this.currentTabbedView = view;
|
||||
@@ -183,10 +230,11 @@ export default {
|
||||
return _.isEqual(this.currentTabbedView, view);
|
||||
},
|
||||
setActivity(selection) {
|
||||
this.activity = selection
|
||||
&& selection.length
|
||||
&& selection[0].length
|
||||
&& selection[0][0].activity;
|
||||
this.activity =
|
||||
selection
|
||||
&& selection.length
|
||||
&& selection[0].length
|
||||
&& selection[0][0].activity;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
83
src/ui/inspector/annotations/AnnotationEditor.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
/*****************************************************************************
|
||||
* 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.
|
||||
*****************************************************************************/
|
||||
|
||||
<template>
|
||||
<div class="c-annotation__row">
|
||||
<textarea
|
||||
v-model="contentModel"
|
||||
class="c-annotation__text_area"
|
||||
type="text"
|
||||
></textarea>
|
||||
<div>
|
||||
<span>{{ modifiedOnDate }}</span>
|
||||
<span>{{ modifiedOnTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Moment from 'moment';
|
||||
|
||||
export default {
|
||||
inject: ['openmct'],
|
||||
props: {
|
||||
annotation: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
contentModel: {
|
||||
get() {
|
||||
return this.annotation.contentText;
|
||||
},
|
||||
set(contentText) {
|
||||
console.debug(`Set tag called with ${contentText}`);
|
||||
}
|
||||
},
|
||||
modifiedOnDate() {
|
||||
return this.formatTime(this.annotation.modified, 'YYYY-MM-DD');
|
||||
},
|
||||
modifiedOnTime() {
|
||||
return this.formatTime(this.annotation.modified, 'HH:mm:ss');
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
},
|
||||
methods: {
|
||||
getAvailableTagByID(tagID) {
|
||||
return this.openmct.annotation.getAvailableTags().find(tag => {
|
||||
return tag.id === tagID;
|
||||
});
|
||||
},
|
||||
formatTime(unixTime, timeFormat) {
|
||||
return Moment.utc(unixTime).format(timeFormat);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
213
src/ui/inspector/annotations/AnnotationsInspectorView.vue
Normal file
@@ -0,0 +1,213 @@
|
||||
/*****************************************************************************
|
||||
* 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.
|
||||
*****************************************************************************/
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="c-inspector__properties c-inspect-properties has-tag-applier"
|
||||
aria-label="Tags Inspector"
|
||||
>
|
||||
<div
|
||||
class="c-inspect-properties__header"
|
||||
>
|
||||
Tags
|
||||
</div>
|
||||
<div
|
||||
v-if="shouldShowTagsEditor"
|
||||
class="c-inspect-properties__section"
|
||||
>
|
||||
<TagEditor
|
||||
:targets="targetDetails"
|
||||
:target-domain-objects="targetDomainObjects"
|
||||
:domain-object="domainObject"
|
||||
:annotations="loadedAnnotations"
|
||||
:annotation-type="annotationType"
|
||||
:on-tag-change="onAnnotationChange"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="c-inspect-properties__row--span-all"
|
||||
>
|
||||
{{ noTagsMessage }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TagEditor from '../../components/tags/TagEditor.vue';
|
||||
import _ from 'lodash';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
TagEditor
|
||||
},
|
||||
inject: ['openmct'],
|
||||
data() {
|
||||
return {
|
||||
selection: null,
|
||||
lastLocalAnnotationCreations: {},
|
||||
unobserveEntries: {},
|
||||
loadedAnnotations: []
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
hasAnnotations() {
|
||||
return Boolean(
|
||||
this.loadedAnnotations
|
||||
&& this.loadedAnnotations.length
|
||||
);
|
||||
},
|
||||
nonTagAnnotations() {
|
||||
if (!this.loadedAnnotations) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.loadedAnnotations.filter(annotation => {
|
||||
return !annotation.tags && !annotation._deleted;
|
||||
});
|
||||
},
|
||||
tagAnnotations() {
|
||||
if (!this.loadedAnnotations) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.loadedAnnotations.filter(annotation => {
|
||||
return !annotation.tags && !annotation._deleted;
|
||||
});
|
||||
},
|
||||
multiSelection() {
|
||||
return this.selection && this.selection.length > 1;
|
||||
},
|
||||
noAnnotationsMessage() {
|
||||
return this.multiSelection
|
||||
? 'No annotations to display for multiple items'
|
||||
: 'No annotations to display for this item';
|
||||
},
|
||||
noTagsMessage() {
|
||||
return this.multiSelection
|
||||
? 'No tags to display for multiple items'
|
||||
: 'No tags to display for this item';
|
||||
},
|
||||
domainObject() {
|
||||
return this?.selection?.[0]?.[0]?.context?.item;
|
||||
},
|
||||
targetDetails() {
|
||||
return this?.selection?.[0]?.[1]?.context?.targetDetails ?? {};
|
||||
},
|
||||
shouldShowTagsEditor() {
|
||||
return Object.keys(this.targetDetails).length > 0;
|
||||
},
|
||||
targetDomainObjects() {
|
||||
return this?.selection?.[0]?.[1]?.context?.targetDomainObjects ?? {};
|
||||
},
|
||||
selectedAnnotations() {
|
||||
return this?.selection?.[0]?.[1]?.context?.annotations;
|
||||
},
|
||||
annotationType() {
|
||||
return this?.selection?.[0]?.[1]?.context?.annotationType;
|
||||
},
|
||||
annotationFilter() {
|
||||
return this?.selection?.[0]?.[1]?.context?.annotationFilter;
|
||||
},
|
||||
onAnnotationChange() {
|
||||
return this?.selection?.[0]?.[1]?.context?.onAnnotationChange;
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
this.openmct.annotation.on('targetDomainObjectAnnotated', this.loadAnnotationForTargetObject);
|
||||
this.openmct.selection.on('change', this.updateSelection);
|
||||
await this.updateSelection(this.openmct.selection.get());
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.openmct.selection.off('change', this.updateSelection);
|
||||
const unobserveEntryFunctions = Object.values(this.unobserveEntries);
|
||||
unobserveEntryFunctions.forEach(unobserveEntry => {
|
||||
unobserveEntry();
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
loadNewAnnotations(annotationsToLoad) {
|
||||
if (!annotationsToLoad || !annotationsToLoad.length) {
|
||||
this.loadedAnnotations.splice(0);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const sortedAnnotations = annotationsToLoad.sort((annotationA, annotationB) => {
|
||||
return annotationB.modified - annotationA.modified;
|
||||
});
|
||||
|
||||
const mutableAnnotations = sortedAnnotations.map((annotation) => {
|
||||
return this.openmct.objects.toMutable(annotation);
|
||||
});
|
||||
|
||||
if (sortedAnnotations.length < this.loadedAnnotations.length) {
|
||||
this.loadedAnnotations = this.loadedAnnotations.slice(0, mutableAnnotations.length);
|
||||
}
|
||||
|
||||
for (let index = 0; index < mutableAnnotations.length; index += 1) {
|
||||
this.$set(this.loadedAnnotations, index, mutableAnnotations[index]);
|
||||
}
|
||||
},
|
||||
updateSelection(selection) {
|
||||
const unobserveEntryFunctions = Object.values(this.unobserveEntries);
|
||||
unobserveEntryFunctions.forEach(unobserveEntry => {
|
||||
unobserveEntry();
|
||||
});
|
||||
this.unobserveEntries = {};
|
||||
|
||||
this.selection = selection;
|
||||
const targetKeys = Object.keys(this.targetDomainObjects);
|
||||
targetKeys.forEach(targetKey => {
|
||||
const targetObject = this.targetDomainObjects[targetKey];
|
||||
this.lastLocalAnnotationCreations[targetKey] = targetObject?.annotationLastCreated ?? 0;
|
||||
if (!this.unobserveEntries[targetKey]) {
|
||||
this.unobserveEntries[targetKey] = this.openmct.objects.observe(targetObject, '*', this.targetObjectChanged);
|
||||
}
|
||||
});
|
||||
this.loadNewAnnotations(this.selectedAnnotations);
|
||||
},
|
||||
async targetObjectChanged(target) {
|
||||
const targetID = this.openmct.objects.makeKeyString(target.identifier);
|
||||
const lastLocalAnnotationCreation = this.lastLocalAnnotationCreations[targetID] ?? 0;
|
||||
if (lastLocalAnnotationCreation < target.annotationLastCreated) {
|
||||
this.lastLocalAnnotationCreations[targetID] = target.annotationLastCreated;
|
||||
await this.loadAnnotationForTargetObject(target);
|
||||
}
|
||||
},
|
||||
async loadAnnotationForTargetObject(target) {
|
||||
const targetID = this.openmct.objects.makeKeyString(target.identifier);
|
||||
const allAnnotationsForTarget = await this.openmct.annotation.getAnnotations(target.identifier);
|
||||
const filteredAnnotationsForSelection = allAnnotationsForTarget.filter(annotation => {
|
||||
const matchingTargetID = Object.keys(annotation.targets).filter(loadedTargetID => {
|
||||
return targetID === loadedTargetID;
|
||||
});
|
||||
const fetchedTargetDetails = annotation.targets[matchingTargetID];
|
||||
const selectedTargetDetails = this.targetDetails[matchingTargetID];
|
||||
|
||||
return _.isEqual(fetchedTargetDetails, selectedTargetDetails);
|
||||
});
|
||||
this.loadNewAnnotations(filteredAnnotationsForSelection);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
18
src/ui/inspector/annotations/annotation-inspector.scss
Normal file
@@ -0,0 +1,18 @@
|
||||
.c-inspect-annotations {
|
||||
> * + * {
|
||||
margin-top: $interiorMargin;
|
||||
}
|
||||
|
||||
&__content{
|
||||
> * + * {
|
||||
margin-top: $interiorMargin;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -106,6 +106,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
.c-inspect-properties,
|
||||
.c-inspect-tags {
|
||||
[class*="header"] {
|
||||
@include propertiesHeader();
|
||||
flex: 0 0 auto;
|
||||
font-size: .85em;
|
||||
}
|
||||
}
|
||||
|
||||
.c-inspect-properties,
|
||||
.c-inspect-styles {
|
||||
[class*="header"] {
|
||||
@@ -187,6 +196,11 @@
|
||||
line-height: 1.8em;
|
||||
}
|
||||
}
|
||||
|
||||
.c-location {
|
||||
// Always make the location element span columns
|
||||
grid-column: 1 / 3;
|
||||
}
|
||||
}
|
||||
|
||||
/********************************************* INSPECTOR PROPERTIES TAB */
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
&:not(:last-child) {
|
||||
&:after {
|
||||
color: $colorInspectorPropName;
|
||||
// color: $colorInspectorPropName;
|
||||
content: $glyph-icon-arrow-right;
|
||||
font-family: symbolsfont;
|
||||
font-size: 0.7em;
|
||||
@@ -36,6 +36,7 @@
|
||||
&__type-icon {
|
||||
width: auto;
|
||||
font-size: 1em;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
|
||||
@@ -53,11 +53,12 @@
|
||||
type="horizontal"
|
||||
>
|
||||
<pane
|
||||
id="tree-pane"
|
||||
class="l-shell__pane-tree"
|
||||
style="width: 300px;"
|
||||
handle="after"
|
||||
label="Browse"
|
||||
hide-param="hideTree"
|
||||
:persist-position="true"
|
||||
@start-resizing="onStartResizing"
|
||||
@end-resizing="onEndResizing"
|
||||
>
|
||||
@@ -75,11 +76,30 @@
|
||||
@click="handleSyncTreeNavigation"
|
||||
>
|
||||
</button>
|
||||
<mct-tree
|
||||
:sync-tree-navigation="triggerSync"
|
||||
:reset-tree-navigation="triggerReset"
|
||||
class="l-shell__tree"
|
||||
/>
|
||||
<multipane
|
||||
type="vertical"
|
||||
>
|
||||
<pane
|
||||
id="tree-pane"
|
||||
>
|
||||
<mct-tree
|
||||
ref="mctTree"
|
||||
:sync-tree-navigation="triggerSync"
|
||||
:reset-tree-navigation="triggerReset"
|
||||
class="l-shell__tree"
|
||||
/>
|
||||
</pane>
|
||||
<pane
|
||||
handle="before"
|
||||
label="Recently Viewed"
|
||||
:persist-position="true"
|
||||
>
|
||||
<RecentObjectsList
|
||||
class="l-shell__tree"
|
||||
@openAndScrollTo="openAndScrollTo($event)"
|
||||
/>
|
||||
</pane>
|
||||
</multipane>
|
||||
</pane>
|
||||
<pane class="l-shell__pane-main">
|
||||
<browse-bar
|
||||
@@ -109,6 +129,7 @@
|
||||
handle="before"
|
||||
label="Inspect"
|
||||
hide-param="hideInspector"
|
||||
:persist-position="true"
|
||||
@start-resizing="onStartResizing"
|
||||
@end-resizing="onEndResizing"
|
||||
>
|
||||
@@ -134,6 +155,7 @@ import Toolbar from '../toolbar/Toolbar.vue';
|
||||
import AppLogo from './AppLogo.vue';
|
||||
import Indicators from './status-bar/Indicators.vue';
|
||||
import NotificationBanner from './status-bar/NotificationBanner.vue';
|
||||
import RecentObjectsList from './RecentObjectsList.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -148,7 +170,8 @@ export default {
|
||||
Toolbar,
|
||||
AppLogo,
|
||||
Indicators,
|
||||
NotificationBanner
|
||||
NotificationBanner,
|
||||
RecentObjectsList
|
||||
},
|
||||
inject: ['openmct'],
|
||||
data: function () {
|
||||
@@ -245,6 +268,10 @@ export default {
|
||||
|
||||
this.hasToolbar = structure.length > 0;
|
||||
},
|
||||
openAndScrollTo(navigationPath) {
|
||||
this.$refs.mctTree.openAndScrollTo(navigationPath);
|
||||
this.$refs.mctTree.targetedPath = navigationPath;
|
||||
},
|
||||
setActionCollection(actionCollection) {
|
||||
this.actionCollection = actionCollection;
|
||||
},
|
||||
|
||||
200
src/ui/layout/RecentObjectsList.vue
Normal file
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<div
|
||||
class="c-tree-and-search l-shell__tree"
|
||||
>
|
||||
<ul
|
||||
class="c-tree-and-search__tree c-tree c-tree__scrollable"
|
||||
>
|
||||
<recent-objects-list-item
|
||||
v-for="(recentObject) in recentObjects"
|
||||
:key="recentObject.navigationPath"
|
||||
:object-path="recentObject.objectPath"
|
||||
:navigation-path="recentObject.navigationPath"
|
||||
:domain-object="recentObject.domainObject"
|
||||
@openAndScrollTo="openAndScrollTo($event)"
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const MAX_RECENT_ITEMS = 20;
|
||||
const LOCAL_STORAGE_KEY__RECENT_OBJECTS = 'mct-recent-objects';
|
||||
import RecentObjectsListItem from './RecentObjectsListItem.vue';
|
||||
export default {
|
||||
name: 'RecentObjectsList',
|
||||
components: {
|
||||
RecentObjectsListItem
|
||||
},
|
||||
inject: ['openmct'],
|
||||
props: {
|
||||
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
recents: []
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
recentObjects() {
|
||||
return this.recents.filter((recentObject) => {
|
||||
return recentObject.location !== null;
|
||||
});
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.compositionCollections = {};
|
||||
this.openmct.router.on('change:path', this.onPathChange);
|
||||
this.getSavedRecentItems();
|
||||
},
|
||||
destroyed() {
|
||||
this.openmct.router.off('change:path', this.onPathChange);
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* Add a composition collection to the map and register its remove handler
|
||||
* @param {string} navigationPath
|
||||
*/
|
||||
addCompositionListenerFor(navigationPath) {
|
||||
this.compositionCollections[navigationPath].removeHandler = this.compositionRemoveHandler(navigationPath);
|
||||
this.compositionCollections[navigationPath].collection.on('remove',
|
||||
this.compositionCollections[navigationPath].removeHandler);
|
||||
},
|
||||
/**
|
||||
* Handler for composition collection remove events.
|
||||
* Removes the object and any of its children from the recents list.
|
||||
* @param {string} navigationPath
|
||||
*/
|
||||
compositionRemoveHandler(navigationPath) {
|
||||
/**
|
||||
* @param {import('../../api/objects/ObjectAPI').Identifier | string} identifier
|
||||
*/
|
||||
return (identifier) => {
|
||||
// Construct the navigationPath of the removed object itself
|
||||
const removedNavigationPath = `${navigationPath}/${this.openmct.objects.makeKeyString(identifier)}`;
|
||||
|
||||
// Remove the object and any of its children from the recents list
|
||||
this.recents = this.recents.filter((recentObject) => {
|
||||
return !recentObject.navigationPath.includes(removedNavigationPath);
|
||||
});
|
||||
|
||||
this.removeCompositionListenerFor(removedNavigationPath);
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Restores the RecentObjects list from localStorage, retrieves composition collections,
|
||||
* and registers composition listeners for composable objects.
|
||||
*/
|
||||
getSavedRecentItems() {
|
||||
const savedRecentsString = localStorage.getItem(LOCAL_STORAGE_KEY__RECENT_OBJECTS);
|
||||
const savedRecents = savedRecentsString ? JSON.parse(savedRecentsString) : [];
|
||||
|
||||
// Get composition collections and add composition listeners for composable objects
|
||||
savedRecents.forEach((recentObject) => {
|
||||
const { domainObject, navigationPath } = recentObject;
|
||||
if (this.shouldTrackCompositionFor(domainObject)) {
|
||||
this.compositionCollections[navigationPath] = {};
|
||||
this.compositionCollections[navigationPath].collection = this.openmct.composition.get(domainObject);
|
||||
this.addCompositionListenerFor(navigationPath);
|
||||
}
|
||||
});
|
||||
|
||||
this.recents = savedRecents;
|
||||
},
|
||||
/**
|
||||
* Handler for 'change:path' router events.
|
||||
* Adds or moves to the top the object at the given path to the recents list.
|
||||
* Registers compositionCollection listeners for composable objects.
|
||||
* Enforces the MAX_RECENT_ITEMS limit.
|
||||
* @param {string} navigationPath
|
||||
*/
|
||||
async onPathChange(navigationPath) {
|
||||
// Short-circuit if the path is not a navigationPath
|
||||
if (!navigationPath.startsWith('/browse/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const objectPath = await this.openmct.objects.getRelativeObjectPath(navigationPath);
|
||||
if (!objectPath.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const domainObject = objectPath[0];
|
||||
|
||||
// Get rid of '/ROOT' if it exists in the navigationPath.
|
||||
// Handles for the case of navigating to "My Items" from a RecentObjectsListItem.
|
||||
// Could lead to dupes of "My Items" in the RecentObjectsList if we don't drop the 'ROOT' here.
|
||||
if (navigationPath.includes('/ROOT')) {
|
||||
navigationPath = navigationPath.replace('/ROOT', '');
|
||||
}
|
||||
|
||||
if (this.shouldTrackCompositionFor(domainObject, navigationPath)) {
|
||||
this.compositionCollections[navigationPath] = {};
|
||||
this.compositionCollections[navigationPath].collection = this.openmct.composition.get(domainObject);
|
||||
this.addCompositionListenerFor(navigationPath);
|
||||
}
|
||||
|
||||
// Don't add deleted objects to the recents list
|
||||
if (domainObject?.location === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Move the object to the top if its already existing in the recents list
|
||||
const existingIndex = this.recents.findIndex((recentObject) => {
|
||||
return navigationPath === recentObject.navigationPath;
|
||||
});
|
||||
if (existingIndex !== -1) {
|
||||
this.recents.splice(existingIndex, 1);
|
||||
}
|
||||
|
||||
this.recents.unshift({
|
||||
objectPath,
|
||||
navigationPath,
|
||||
domainObject
|
||||
});
|
||||
|
||||
// Enforce a max number of recent items
|
||||
while (this.recents.length > MAX_RECENT_ITEMS) {
|
||||
const poppedRecentItem = this.recents.pop();
|
||||
this.removeCompositionListenerFor(poppedRecentItem.navigationPath);
|
||||
}
|
||||
|
||||
this.setSavedRecentItems();
|
||||
},
|
||||
/**
|
||||
* Delete the composition collection and unregister its remove handler
|
||||
* @param {string} navigationPath
|
||||
*/
|
||||
removeCompositionListenerFor(navigationPath) {
|
||||
if (this.compositionCollections[navigationPath]) {
|
||||
this.compositionCollections[navigationPath].collection.off('remove',
|
||||
this.compositionCollections[navigationPath].removeHandler);
|
||||
delete this.compositionCollections[navigationPath];
|
||||
}
|
||||
},
|
||||
openAndScrollTo(navigationPath) {
|
||||
this.$emit("openAndScrollTo", navigationPath);
|
||||
},
|
||||
/**
|
||||
* Saves the Recent Objects list to localStorage.
|
||||
*/
|
||||
setSavedRecentItems() {
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY__RECENT_OBJECTS, JSON.stringify(this.recents));
|
||||
},
|
||||
/**
|
||||
* Returns true if the `domainObject` supports composition and we are not already
|
||||
* tracking its composition.
|
||||
* @param {import('../../api/objects/ObjectAPI').DomainObject} domainObject
|
||||
* @param {string} navigationPath
|
||||
*/
|
||||
shouldTrackCompositionFor(domainObject, navigationPath) {
|
||||
return this.compositionCollections[navigationPath] === undefined
|
||||
&& this.openmct.composition.supportsComposition(domainObject);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
</style>
|
||||
134
src/ui/layout/RecentObjectsListItem.vue
Normal file
@@ -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.
|
||||
*****************************************************************************/
|
||||
|
||||
<template>
|
||||
<li
|
||||
class="c-recentobjects-listitem c-recentobjects-listitem--object"
|
||||
:class="isAlias"
|
||||
:aria-label="`${domainObject.name}`"
|
||||
>
|
||||
<div
|
||||
class="c-recentobjects-listitem__type-icon recent-object-icon"
|
||||
:class="resultTypeIcon"
|
||||
></div>
|
||||
<div
|
||||
class="c-recentobjects-listitem__body"
|
||||
>
|
||||
<span
|
||||
class="c-recentobjects-listitem__title"
|
||||
:name="domainObject.name"
|
||||
draggable="true"
|
||||
@dragstart="dragStart"
|
||||
@click.prevent="clickedRecent"
|
||||
>
|
||||
{{ domainObject.name }}
|
||||
</span>
|
||||
|
||||
<ObjectPath
|
||||
class="c-recentobjects-listitem__object-path"
|
||||
:read-only="false"
|
||||
:domain-object="domainObject"
|
||||
:show-original-path="false"
|
||||
:object-path="objectPath"
|
||||
/>
|
||||
</div>
|
||||
<div class="c-recentobjects-listitem__target-button">
|
||||
<button
|
||||
class="c-icon-button icon-target"
|
||||
@click="openAndScrollTo(navigationPath)"
|
||||
></button>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ObjectPath from '../components/ObjectPath.vue';
|
||||
import PreviewAction from '../preview/PreviewAction';
|
||||
|
||||
export default {
|
||||
name: 'RecentObjectsListItem',
|
||||
components: {
|
||||
ObjectPath
|
||||
},
|
||||
inject: ['openmct'],
|
||||
props: {
|
||||
domainObject: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
navigationPath: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
objectPath: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isAlias() {
|
||||
return this.openmct.objects.isObjectPathToALink(this.domainObject, this.objectPath) ? { 'is-alias': true } : undefined;
|
||||
},
|
||||
resultTypeIcon() {
|
||||
return this.openmct.types.get(this.domainObject.type).definition.cssClass;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.previewAction = new PreviewAction(this.openmct);
|
||||
this.previewAction.on('isVisible', this.togglePreviewState);
|
||||
},
|
||||
destroyed() {
|
||||
this.previewAction.off('isVisible', this.togglePreviewState);
|
||||
},
|
||||
methods: {
|
||||
clickedRecent(_event) {
|
||||
if (this.openmct.editor.isEditing()) {
|
||||
this.preview();
|
||||
} else {
|
||||
this.openmct.router.navigate(`#${this.navigationPath}`);
|
||||
}
|
||||
},
|
||||
togglePreviewState(previewState) {
|
||||
this.$emit('preview-changed', previewState);
|
||||
},
|
||||
preview() {
|
||||
if (this.previewAction.appliesTo(this.objectPath)) {
|
||||
this.previewAction.invoke(this.objectPath);
|
||||
}
|
||||
},
|
||||
dragStart(event) {
|
||||
const navigatedObject = this.openmct.router.path[0];
|
||||
const serializedPath = JSON.stringify(this.objectPath);
|
||||
const keyString = this.openmct.objects.makeKeyString(this.domainObject.identifier);
|
||||
if (this.openmct.composition.checkPolicy(navigatedObject, this.domainObject)) {
|
||||
event.dataTransfer.setData("openmct/composable-domain-object", JSON.stringify(this.domainObject));
|
||||
}
|
||||
|
||||
event.dataTransfer.setData("openmct/domain-object-path", serializedPath);
|
||||
event.dataTransfer.setData(`openmct/domain-object/${keyString}`, this.domainObject);
|
||||
},
|
||||
openAndScrollTo(navigationPath) {
|
||||
this.$emit('openAndScrollTo', navigationPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -289,7 +289,7 @@
|
||||
}
|
||||
|
||||
&__pane-tree {
|
||||
width: 300px;
|
||||
width: 100%;
|
||||
padding-left: nth($shellPanePad, 2);
|
||||
}
|
||||
|
||||
|
||||
@@ -108,6 +108,10 @@
|
||||
color: $colorItemTreeSelectedFg;
|
||||
}
|
||||
}
|
||||
&.is-targeted-item {
|
||||
$c: $colorBodyFg;
|
||||
@include pulseProp($animName: flashTarget, $dur: 500ms, $iter: 8, $prop: background, $valStart: rgba($c, 0.4), $valEnd: rgba($c, 0));
|
||||
}
|
||||
|
||||
&.is-new {
|
||||
animation-name: animTemporaryHighlight;
|
||||
|
||||
@@ -88,10 +88,12 @@
|
||||
:item-height="itemHeight"
|
||||
:open-items="openTreeItems"
|
||||
:loading-items="treeItemLoading"
|
||||
:targeted-path="targetedPath"
|
||||
@tree-item-mounted="scrollToCheck($event)"
|
||||
@tree-item-destroyed="removeCompositionListenerFor($event)"
|
||||
@tree-item-action="treeItemAction(treeItem, $event)"
|
||||
@tree-item-selection="treeItemSelection(treeItem)"
|
||||
@targeted-path-animation-end="targetedPathAnimationEnd()"
|
||||
/>
|
||||
<!-- main loading -->
|
||||
<div
|
||||
@@ -174,19 +176,18 @@ export default {
|
||||
itemOffset: 0,
|
||||
activeSearch: false,
|
||||
mainTreeTopMargin: undefined,
|
||||
selectedItem: {}
|
||||
selectedItem: {},
|
||||
targetedPath: ''
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
childrenHeight() {
|
||||
let childrenCount = this.focusedItems.length || 1;
|
||||
const childrenCount = this.focusedItems.length || 1;
|
||||
|
||||
return (this.itemHeight * childrenCount) - this.mainTreeTopMargin; // 5px margin
|
||||
},
|
||||
childrenHeightStyles() {
|
||||
let height = this.childrenHeight + 'px';
|
||||
|
||||
return { height };
|
||||
return { height: `${this.childrenHeight}px` };
|
||||
},
|
||||
focusedItems() {
|
||||
return this.activeSearch ? this.searchResultItems : this.treeItems;
|
||||
@@ -195,9 +196,7 @@ export default {
|
||||
return Math.ceil(this.mainTreeHeight / this.itemHeight) + ITEM_BUFFER;
|
||||
},
|
||||
scrollableStyles() {
|
||||
let height = this.mainTreeHeight + 'px';
|
||||
|
||||
return { height };
|
||||
return { height: `${this.mainTreeHeight}px` };
|
||||
},
|
||||
showNoItems() {
|
||||
return this.visibleItems.length === 0 && !this.activeSearch && this.searchValue === '' && !this.isLoading;
|
||||
@@ -209,7 +208,7 @@ export default {
|
||||
if (!this.isSelectorTree) {
|
||||
return {};
|
||||
} else {
|
||||
return { 'min-height': this.itemHeight * LOCATOR_ITEM_COUNT_HEIGHT + 'px' };
|
||||
return { minHeight: `${this.itemHeight * LOCATOR_ITEM_COUNT_HEIGHT}px`};
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -311,6 +310,9 @@ export default {
|
||||
this.openTreeItem(parentItem);
|
||||
}
|
||||
},
|
||||
targetedPathAnimationEnd() {
|
||||
this.targetedPath = undefined;
|
||||
},
|
||||
treeItemSelection(item) {
|
||||
this.selectedItem = item;
|
||||
this.$emit('tree-item-selection', item);
|
||||
@@ -457,6 +459,9 @@ export default {
|
||||
|
||||
this.treeItemSelection(item);
|
||||
}
|
||||
|
||||
this.scrollToCheck(navigationPath);
|
||||
this.scrollToPath = null;
|
||||
});
|
||||
},
|
||||
scrollToCheck(navigationPath) {
|
||||
@@ -480,9 +485,9 @@ export default {
|
||||
behavior: 'smooth'
|
||||
});
|
||||
} else if (this.scrollToPath) {
|
||||
this.scrollToPath = undefined;
|
||||
delete this.scrollToPath;
|
||||
this.scrollToPath = null;
|
||||
}
|
||||
|
||||
},
|
||||
scrollEndEvent() {
|
||||
if (!this.$refs.scrollable) {
|
||||
@@ -494,12 +499,14 @@ export default {
|
||||
if (!this.isItemInView(this.scrollToPath)) {
|
||||
this.scrollTo(this.scrollToPath);
|
||||
} else {
|
||||
this.scrollToPath = undefined;
|
||||
delete this.scrollToPath;
|
||||
this.scrollToPath = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
setTargetedItem(navigationPath) {
|
||||
this.targetedItem = navigationPath;
|
||||
},
|
||||
isItemInView(navigationPath) {
|
||||
const indexOfScroll = this.treeItems.findIndex(item => item.navigationPath === navigationPath);
|
||||
const scrollTopAmount = indexOfScroll * this.itemHeight;
|
||||
|
||||
@@ -83,6 +83,8 @@
|
||||
&[class*="--vertical"] {
|
||||
padding-top: $interiorMargin;
|
||||
padding-bottom: $interiorMargin;
|
||||
min-height: 30px; // For Recents holder
|
||||
|
||||
&.l-pane--collapsed {
|
||||
padding-top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
<template>
|
||||
<div
|
||||
class="l-pane"
|
||||
:class="{
|
||||
'l-pane--horizontal-handle-before': type === 'horizontal' && handle === 'before',
|
||||
'l-pane--horizontal-handle-after': type === 'horizontal' && handle === 'after',
|
||||
'l-pane--vertical-handle-before': type === 'vertical' && handle === 'before',
|
||||
'l-pane--vertical-handle-after': type === 'vertical' && handle === 'after',
|
||||
'l-pane--collapsed': collapsed,
|
||||
'l-pane--reacts': !handle,
|
||||
'l-pane--resizing': resizing === true
|
||||
}"
|
||||
:class="paneClasses"
|
||||
>
|
||||
<div
|
||||
v-if="handle"
|
||||
class="l-pane__handle"
|
||||
@mousedown="start"
|
||||
@mousedown.prevent="startResizing"
|
||||
></div>
|
||||
<div class="l-pane__header">
|
||||
<span
|
||||
@@ -42,6 +34,7 @@
|
||||
|
||||
<script>
|
||||
const COLLAPSE_THRESHOLD_PX = 40;
|
||||
const LOCAL_STORAGE_KEY__PANE_POSITIONS = 'mct-pane-positions';
|
||||
|
||||
export default {
|
||||
inject: ['openmct'],
|
||||
@@ -60,6 +53,10 @@ export default {
|
||||
hideParam: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
persistPosition: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -70,7 +67,25 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
isCollapsable() {
|
||||
return this.hideParam && this.hideParam.length > 0;
|
||||
return this.hideParam?.length > 0;
|
||||
},
|
||||
localStorageKey() {
|
||||
if (!this.label) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.label.toLowerCase().replace(/ /g, '-');
|
||||
},
|
||||
paneClasses() {
|
||||
return {
|
||||
'l-pane--horizontal-handle-before': this.type === 'horizontal' && this.handle === 'before',
|
||||
'l-pane--horizontal-handle-after': this.type === 'horizontal' && this.handle === 'after',
|
||||
'l-pane--vertical-handle-before': this.type === 'vertical' && this.handle === 'before',
|
||||
'l-pane--vertical-handle-after': this.type === 'vertical' && this.handle === 'after',
|
||||
'l-pane--collapsed': this.collapsed,
|
||||
'l-pane--reacts': !this.handle,
|
||||
'l-pane--resizing': this.resizing === true
|
||||
};
|
||||
}
|
||||
},
|
||||
beforeMount() {
|
||||
@@ -78,62 +93,33 @@ export default {
|
||||
this.styleProp = (this.type === 'horizontal') ? 'width' : 'height';
|
||||
},
|
||||
async mounted() {
|
||||
if (this.persistPosition) {
|
||||
const savedPosition = this.getSavedPosition();
|
||||
if (savedPosition) {
|
||||
this.$el.style[this.styleProp] = savedPosition;
|
||||
}
|
||||
}
|
||||
|
||||
await this.$nextTick();
|
||||
// Hide tree and/or inspector pane if specified in URL
|
||||
if (this.isCollapsable) {
|
||||
this.handleHideUrl();
|
||||
}
|
||||
|
||||
},
|
||||
methods: {
|
||||
toggleCollapse: function (e) {
|
||||
if (this.collapsed) {
|
||||
this.handleExpand();
|
||||
this.removeHideParam(this.hideParam);
|
||||
} else {
|
||||
this.handleCollapse();
|
||||
this.addHideParam(this.hideParam);
|
||||
}
|
||||
},
|
||||
handleHideUrl: function () {
|
||||
const hideParam = this.openmct.router.getSearchParam(this.hideParam);
|
||||
|
||||
if (hideParam === 'true') {
|
||||
this.handleCollapse();
|
||||
}
|
||||
},
|
||||
addHideParam: function (target) {
|
||||
addHideParam(target) {
|
||||
this.openmct.router.setSearchParam(target, 'true');
|
||||
},
|
||||
removeHideParam: function (target) {
|
||||
this.openmct.router.deleteSearchParam(target);
|
||||
endResizing(_event) {
|
||||
document.body.removeEventListener('mousemove', this.updatePosition);
|
||||
document.body.removeEventListener('mouseup', this.endResizing);
|
||||
this.resizing = false;
|
||||
this.$emit('end-resizing');
|
||||
this.trackSize();
|
||||
},
|
||||
handleCollapse: function () {
|
||||
this.currentSize = (this.dragCollapse === true) ? this.initial : this.$el.style[this.styleProp];
|
||||
this.$el.style[this.styleProp] = '';
|
||||
this.collapsed = true;
|
||||
},
|
||||
handleExpand: function () {
|
||||
this.$el.style[this.styleProp] = this.currentSize;
|
||||
delete this.currentSize;
|
||||
delete this.dragCollapse;
|
||||
this.collapsed = false;
|
||||
},
|
||||
trackSize: function () {
|
||||
if (!this.dragCollapse === true) {
|
||||
if (this.type === 'vertical') {
|
||||
this.initial = this.$el.offsetHeight;
|
||||
} else if (this.type === 'horizontal') {
|
||||
this.initial = this.$el.offsetWidth;
|
||||
}
|
||||
}
|
||||
},
|
||||
getPosition: function (event) {
|
||||
return this.type === 'horizontal'
|
||||
? event.pageX
|
||||
: event.pageY;
|
||||
},
|
||||
getNewSize: function (event) {
|
||||
let delta = this.startPosition - this.getPosition(event);
|
||||
getNewSize(event) {
|
||||
const delta = this.startPosition - this.getPosition(event);
|
||||
if (this.handle === "before") {
|
||||
return `${this.initial + delta}px`;
|
||||
}
|
||||
@@ -142,33 +128,88 @@ export default {
|
||||
return `${this.initial - delta}px`;
|
||||
}
|
||||
},
|
||||
updatePosition: function (event) {
|
||||
let size = this.getNewSize(event);
|
||||
let intSize = parseInt(size.substr(0, size.length - 2), 10);
|
||||
if (intSize < COLLAPSE_THRESHOLD_PX && this.isCollapsable === true) {
|
||||
this.dragCollapse = true;
|
||||
this.end();
|
||||
this.toggleCollapse();
|
||||
} else {
|
||||
this.$el.style[this.styleProp] = size;
|
||||
getSavedPosition() {
|
||||
if (!this.localStorageKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const savedPositionsString = localStorage.getItem(LOCAL_STORAGE_KEY__PANE_POSITIONS);
|
||||
const savedPositions = savedPositionsString ? JSON.parse(savedPositionsString) : {};
|
||||
|
||||
return savedPositions[this.localStorageKey];
|
||||
},
|
||||
getPosition(event) {
|
||||
return this.type === 'horizontal'
|
||||
? event.pageX
|
||||
: event.pageY;
|
||||
},
|
||||
handleCollapse() {
|
||||
this.currentSize = (this.dragCollapse === true) ? this.initial : this.$el.style[this.styleProp];
|
||||
this.$el.style[this.styleProp] = '';
|
||||
this.collapsed = true;
|
||||
},
|
||||
handleExpand() {
|
||||
this.$el.style[this.styleProp] = this.currentSize;
|
||||
delete this.currentSize;
|
||||
delete this.dragCollapse;
|
||||
this.collapsed = false;
|
||||
},
|
||||
handleHideUrl() {
|
||||
const hideParam = this.openmct.router.getSearchParam(this.hideParam);
|
||||
|
||||
if (hideParam === 'true') {
|
||||
this.handleCollapse();
|
||||
}
|
||||
},
|
||||
start: function (event) {
|
||||
event.preventDefault(); // stop from firing drag event
|
||||
|
||||
removeHideParam(target) {
|
||||
this.openmct.router.deleteSearchParam(target);
|
||||
},
|
||||
setSavedPosition(panePosition) {
|
||||
const panePositionsString = localStorage.getItem(LOCAL_STORAGE_KEY__PANE_POSITIONS);
|
||||
const panePositions = panePositionsString ? JSON.parse(panePositionsString) : {};
|
||||
panePositions[this.localStorageKey] = panePosition;
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY__PANE_POSITIONS, JSON.stringify(panePositions));
|
||||
},
|
||||
startResizing(event) {
|
||||
this.startPosition = this.getPosition(event);
|
||||
document.body.addEventListener('mousemove', this.updatePosition);
|
||||
document.body.addEventListener('mouseup', this.end);
|
||||
document.body.addEventListener('mouseup', this.endResizing);
|
||||
this.resizing = true;
|
||||
this.$emit('start-resizing');
|
||||
this.trackSize();
|
||||
},
|
||||
end: function (event) {
|
||||
document.body.removeEventListener('mousemove', this.updatePosition);
|
||||
document.body.removeEventListener('mouseup', this.end);
|
||||
this.resizing = false;
|
||||
this.$emit('end-resizing');
|
||||
this.trackSize();
|
||||
toggleCollapse(_event) {
|
||||
if (this.collapsed) {
|
||||
this.handleExpand();
|
||||
this.removeHideParam(this.hideParam);
|
||||
} else {
|
||||
this.handleCollapse();
|
||||
this.addHideParam(this.hideParam);
|
||||
}
|
||||
},
|
||||
trackSize() {
|
||||
if (!this.dragCollapse) {
|
||||
if (this.type === 'vertical') {
|
||||
this.initial = this.$el.offsetHeight;
|
||||
} else if (this.type === 'horizontal') {
|
||||
this.initial = this.$el.offsetWidth;
|
||||
}
|
||||
|
||||
if (this.persistPosition) {
|
||||
this.setSavedPosition(`${this.initial}px`);
|
||||
}
|
||||
}
|
||||
},
|
||||
updatePosition(event) {
|
||||
const size = this.getNewSize(event);
|
||||
const intSize = parseInt(size.substr(0, size.length - 2), 10);
|
||||
if (intSize < COLLAPSE_THRESHOLD_PX && this.isCollapsable === true) {
|
||||
this.dragCollapse = true;
|
||||
this.endResizing();
|
||||
this.toggleCollapse();
|
||||
} else {
|
||||
this.$el.style[this.styleProp] = size;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
121
src/ui/layout/recent-objects.scss
Normal file
@@ -0,0 +1,121 @@
|
||||
/*****************************************************************************
|
||||
* 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.
|
||||
*****************************************************************************/
|
||||
|
||||
.c-recentobjects-listitem {
|
||||
display: flex;
|
||||
padding: $interiorMargin $interiorMarginSm;
|
||||
align-items: flex-start;
|
||||
|
||||
> * + * {
|
||||
margin-left: $interiorMargin;
|
||||
}
|
||||
|
||||
+ .c-recentobjects-listitem {
|
||||
border-top: 1px solid $colorInteriorBorder;
|
||||
}
|
||||
|
||||
&.is-alias {
|
||||
// Object is an alias to an original.
|
||||
[class~='recent-object-icon'] {
|
||||
@include isAlias();
|
||||
&:after {
|
||||
bottom: 20%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__object-path {
|
||||
padding: 0 $interiorMarginSm;
|
||||
}
|
||||
|
||||
&__target-button{
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&__type-icon,
|
||||
&__more-options-button {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
&__type-icon {
|
||||
color: $colorItemTreeIcon;
|
||||
font-size: 2.2em;
|
||||
|
||||
// TEMP: uses object-label component, hide label part
|
||||
.c-object-label__name {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__more-options-button {
|
||||
display: none; // TEMP until enabled
|
||||
}
|
||||
|
||||
&__body {
|
||||
flex: 1 1 auto;
|
||||
|
||||
> * + * {
|
||||
margin-top: $interiorMarginSm;
|
||||
}
|
||||
|
||||
.c-location {
|
||||
font-size: 0.9em;
|
||||
opacity: 0.8;
|
||||
|
||||
&__item {
|
||||
|
||||
> * + * {
|
||||
background: blue !important;
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
&__tags {
|
||||
display: flex;
|
||||
|
||||
> * + * {
|
||||
margin-left: $interiorMargin;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
border-radius: $basicCr;
|
||||
color: pullForward($colorBodyFg, 30%);
|
||||
cursor: pointer;
|
||||
padding: $interiorMarginSm;
|
||||
|
||||
&:hover {
|
||||
background-color: $colorItemTreeHoverBg;
|
||||
}
|
||||
}
|
||||
|
||||
.c-tag {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
||||
|
||||
.c-recentobjects-listitem:hover .c-recentobjects-listitem__target-button {
|
||||
opacity: 100;
|
||||
}
|
||||
@@ -67,7 +67,7 @@
|
||||
|
||||
<script>
|
||||
import ObjectPath from '../../components/ObjectPath.vue';
|
||||
import objectPathToUrl from '../../../tools/url';
|
||||
import { identifierToString } from '../../../../src/tools/url';
|
||||
|
||||
export default {
|
||||
name: 'AnnotationSearchResult',
|
||||
@@ -128,13 +128,47 @@ export default {
|
||||
methods: {
|
||||
clickedResult() {
|
||||
const objectPath = this.domainObject.originalPath;
|
||||
let resultUrl = objectPathToUrl(this.openmct, objectPath);
|
||||
// get rid of ROOT if extant
|
||||
if (resultUrl.includes('/ROOT')) {
|
||||
resultUrl = resultUrl.split('/ROOT').join('');
|
||||
}
|
||||
let resultUrl = identifierToString(this.openmct, objectPath);
|
||||
|
||||
this.openmct.router.navigate(resultUrl);
|
||||
if (this.result.annotationType === this.openmct.annotation.ANNOTATION_TYPES.PLOT_SPATIAL) {
|
||||
//wait a beat for the navigation
|
||||
setTimeout(() => {
|
||||
this.clickedPlotAnnotation();
|
||||
}, 100);
|
||||
}
|
||||
},
|
||||
clickedPlotAnnotation() {
|
||||
const targetDetails = {};
|
||||
const targetDomainObjects = {};
|
||||
Object.entries(this.result.targets).forEach(([key, value]) => {
|
||||
targetDetails[key] = value;
|
||||
});
|
||||
this.result.targetModels.forEach((targetModel) => {
|
||||
const keyString = this.openmct.objects.makeKeyString(targetModel.identifier);
|
||||
targetDomainObjects[keyString] = targetModel;
|
||||
});
|
||||
const selection =
|
||||
[
|
||||
{
|
||||
element: this.openmct.layout.$refs.browseObject.$el,
|
||||
context: {
|
||||
item: this.result
|
||||
}
|
||||
},
|
||||
{
|
||||
element: this.$el,
|
||||
context: {
|
||||
type: 'plot-points-selection',
|
||||
targetDetails,
|
||||
targetDomainObjects,
|
||||
annotations: [this.result],
|
||||
annotationType: this.openmct.annotation.ANNOTATION_TYPES.PLOT_SPATIAL,
|
||||
onAnnotationChange: () => {}
|
||||
}
|
||||
}
|
||||
];
|
||||
this.openmct.selection.select(selection, true);
|
||||
},
|
||||
isSearchMatched(tag) {
|
||||
if (this.result.matchingTagKeys) {
|
||||
|
||||
@@ -104,7 +104,7 @@ export default {
|
||||
const originalPathObjects = await this.openmct.objects.getOriginalPath(keyStringForObject);
|
||||
|
||||
return {
|
||||
originalPath: originalPathObjects,
|
||||
objectPath: originalPathObjects,
|
||||
...domainObject
|
||||
};
|
||||
}));
|
||||
@@ -126,7 +126,7 @@ export default {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.openmct.objects.isReachable(result?.originalPath);
|
||||
return this.openmct.objects.isReachable(result?.objectPath);
|
||||
});
|
||||
this.objectSearchResults = filterAnnotationsAndValidPaths;
|
||||
this.searchLoading = false;
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
|
||||
<script>
|
||||
import ObjectPath from '../../components/ObjectPath.vue';
|
||||
import objectPathToUrl from '../../../tools/url';
|
||||
import identifierToString from '../../../tools/url';
|
||||
import PreviewAction from '../../preview/PreviewAction';
|
||||
|
||||
export default {
|
||||
@@ -100,9 +100,10 @@ export default {
|
||||
event.preventDefault();
|
||||
this.preview();
|
||||
} else {
|
||||
const objectPath = this.result.originalPath;
|
||||
let resultUrl = objectPathToUrl(this.openmct, objectPath);
|
||||
// get rid of ROOT if extant
|
||||
const { objectPath } = this.result;
|
||||
let resultUrl = identifierToString(this.openmct, objectPath);
|
||||
|
||||
// Remove the vestigial 'ROOT' identifier from url if it exists
|
||||
if (resultUrl.includes('/ROOT')) {
|
||||
resultUrl = resultUrl.split('/ROOT').join('');
|
||||
}
|
||||
@@ -114,14 +115,14 @@ export default {
|
||||
this.$emit('preview-changed', previewState);
|
||||
},
|
||||
preview() {
|
||||
const objectPath = this.result.originalPath;
|
||||
const { objectPath } = this.result;
|
||||
if (this.previewAction.appliesTo(objectPath)) {
|
||||
this.previewAction.invoke(objectPath);
|
||||
}
|
||||
},
|
||||
dragStart(event) {
|
||||
const navigatedObject = this.openmct.router.path[0];
|
||||
const objectPath = this.result.originalPath;
|
||||
const { objectPath } = this.result;
|
||||
const serializedPath = JSON.stringify(objectPath);
|
||||
const keyString = this.openmct.objects.makeKeyString(this.result.identifier);
|
||||
if (this.openmct.composition.checkPolicy(navigatedObject, this.result)) {
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
<div class="c-gsearch__results-section-title">Annotation Results</div>
|
||||
<annotation-search-result
|
||||
v-for="(annotationResult) in annotationResults"
|
||||
:key="openmct.objects.makeKeyString(annotationResult.identifier)"
|
||||
:key="makeKeyForAnnotationResult(annotationResult)"
|
||||
:result="annotationResult"
|
||||
@click.native="selectedResult"
|
||||
/>
|
||||
@@ -102,6 +102,12 @@ export default {
|
||||
this.resultsShown = false;
|
||||
}
|
||||
},
|
||||
makeKeyForAnnotationResult(annotationResult) {
|
||||
const annotationKeyString = this.openmct.objects.makeKeyString(annotationResult.identifier);
|
||||
const firstTargetKeyString = Object.keys(annotationResult.targets)[0];
|
||||
|
||||
return `${annotationKeyString}-${firstTargetKeyString}`;
|
||||
},
|
||||
previewChanged(changedPreviewState) {
|
||||
this.previewVisible = changedPreviewState;
|
||||
},
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
<div
|
||||
v-if="activeModel.message"
|
||||
class="c-message-banner"
|
||||
role="alert"
|
||||
:aria-live="activeModel.severity === 'error' ? 'assertive' : 'polite'"
|
||||
:class="[
|
||||
activeModel.severity,
|
||||
{
|
||||
@@ -42,6 +44,7 @@
|
||||
/>
|
||||
<button
|
||||
class="c-message-banner__close-button c-click-icon icon-x-in-circle"
|
||||
aria-label="Dismiss"
|
||||
@click.stop="dismiss()"
|
||||
></button>
|
||||
</div>
|
||||
|
||||
@@ -9,10 +9,12 @@
|
||||
class="c-tree__item"
|
||||
:class="{
|
||||
'is-alias': isAlias,
|
||||
'is-navigated-object': shouldHightlight,
|
||||
'is-navigated-object': shouldHighlight,
|
||||
'is-targeted-item': isTargetedItem,
|
||||
'is-context-clicked': contextClickActive,
|
||||
'is-new': isNewItem
|
||||
}"
|
||||
@animationend="targetedPathAnimationEnd($event)"
|
||||
@click.capture="itemClick"
|
||||
@contextmenu.capture="handleContextMenu"
|
||||
>
|
||||
@@ -58,6 +60,10 @@ export default {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
targetedPath: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
selectedItem: {
|
||||
type: Object,
|
||||
required: true
|
||||
@@ -122,6 +128,9 @@ export default {
|
||||
isSelectedItem() {
|
||||
return this.selectedItem.objectPath === this.node.objectPath;
|
||||
},
|
||||
isTargetedItem() {
|
||||
return this.targetedPath === this.navigationPath;
|
||||
},
|
||||
isNewItem() {
|
||||
return this.isNew;
|
||||
},
|
||||
@@ -131,7 +140,7 @@ export default {
|
||||
isOpen() {
|
||||
return this.openItems.includes(this.navigationPath);
|
||||
},
|
||||
shouldHightlight() {
|
||||
shouldHighlight() {
|
||||
if (this.isSelectorTree) {
|
||||
return this.isSelectedItem;
|
||||
} else {
|
||||
@@ -164,6 +173,10 @@ export default {
|
||||
this.$emit('tree-item-destoyed', this.navigationPath);
|
||||
},
|
||||
methods: {
|
||||
targetedPathAnimationEnd($event) {
|
||||
$event.target.classList.remove('is-targeted-item');
|
||||
this.$emit('targeted-path-animation-end');
|
||||
},
|
||||
itemAction() {
|
||||
this.$emit('tree-item-action', this.isOpen || this.isLoading ? 'close' : 'open');
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@ import objectPathToUrl from '../../tools/url';
|
||||
export default {
|
||||
inject: ['openmct'],
|
||||
props: {
|
||||
'objectPath': {
|
||||
objectPath: {
|
||||
type: Array,
|
||||
default() {
|
||||
return [];
|
||||
@@ -20,7 +20,7 @@ export default {
|
||||
return '#' + this.navigateToPath;
|
||||
}
|
||||
|
||||
let url = objectPathToUrl(this.openmct, this.objectPath);
|
||||
const url = objectPathToUrl(this.openmct, this.objectPath);
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -130,11 +130,10 @@ class ApplicationRouter extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to given hash and update current location object and notify listeners about location change
|
||||
* Navigate to given hash, update current location object, and notify listeners about location change
|
||||
*
|
||||
* @param {string} paramName name of searchParam to get from current url searchParams
|
||||
*
|
||||
* @returns {string} value of paramName from current url searchParams
|
||||
* @param {string} hash The URL hash to navigate to in the form of "#/browse/mine/{keyString}/{keyString}".
|
||||
* Should not include any params.
|
||||
*/
|
||||
navigate(hash) {
|
||||
this.handleLocationChange(hash.substring(1));
|
||||
@@ -227,7 +226,7 @@ class ApplicationRouter extends EventEmitter {
|
||||
|
||||
this.started = true;
|
||||
|
||||
this.locationBar.onChange(p => this.hashChaged(p));
|
||||
this.locationBar.onChange(p => this.hashChanged(p));
|
||||
this.locationBar.start({
|
||||
root: location.pathname
|
||||
});
|
||||
@@ -390,7 +389,7 @@ class ApplicationRouter extends EventEmitter {
|
||||
*
|
||||
* @param {string} hash new hash for url
|
||||
*/
|
||||
hashChaged(hash) {
|
||||
hashChanged(hash) {
|
||||
this.emit('change:hash', hash);
|
||||
this.handleLocationChange(hash);
|
||||
}
|
||||
|
||||