Compare commits

..

42 Commits

Author SHA1 Message Date
Jamie V
98836b256f lint fixes 2023-01-20 16:46:42 -08:00
Jamie V
4dee94480c Merge branch 'master' into nb-embed-enhance 2023-01-20 16:14:53 -08:00
John Hill
17fee67e08 i've wasted too much time on this 2023-01-20 15:48:46 -08:00
Scott Bell
d1c7d133fc 5853 plot annotations prototype (#6000)
* Implement new search and tagging for notebooks
* Add inspector and plot annotations
* Clean up inspector for plots and other views
* Bump webpack defaults for windows
* Notebook annotations are shown in inspector now
* Only allow annotations if plot is paused or in fixed time. also do not mutate if immutable
* Key off local events instead of remote (for now)
2023-01-20 14:34:12 -08:00
Kierstyn Just
edbbebe329 [CLA on File] style: added padding to object & icon button labels (#6036) 2023-01-20 11:07:56 -08:00
Jesse Mazzella
f98a2cdd6b feat: Recent Objects (#6103)
* clicking recent objects selects that object
* clicking target navigates to but does not select that object
* max 20 recent objects
2023-01-20 10:27:09 -08:00
Scott Bell
22621aaaf8 6098 operator status indicator v11 improvements (#6112)
* Added clear poll button to clear all statuses
* Clear current poll question
* Added table for operator status

Co-authored-by: Michael Rogers <contact@mhrogers.com>
Co-authored-by: Andrew Henry <akhenry@gmail.com>
2023-01-19 18:56:46 -08:00
Michael Rogers
e0ca6200bb Handle pausing of imagery from viewLargeAction - 3647 (#5901)
* get imagery view context and externally set pause and thumbnail index

* Test pause/play state in realtime mode

* Created an onPreviewMode change handler to be invoked from view large

* Add optional chaining to method invocation

* Change onItemClicked to invoke to resolve repeat large view action error
2023-01-19 18:45:40 -08:00
John Hill
3bd12a1db4 first pass 2023-01-19 14:52:25 -08:00
Marcelo Arias
70074c52c8 Fix Notifications Overlay that opens automatically (#6133)
* Show NotificationIndicator also if NotificationsList is shown

* Create Notification Overlay Regression Test

* Move notification regression test under notification.e2e.spec.js

* Update selector of Notification Banner

* Rename test to "Notification Overlay"
2023-01-18 22:20:47 +00:00
dependabot[bot]
d5adaf6e8c Bump eslint-plugin-playwright from 0.11.2 to 0.12.0 (#6125)
Bumps [eslint-plugin-playwright](https://github.com/playwright-community/eslint-plugin-playwright) from 0.11.2 to 0.12.0.
- [Release notes](https://github.com/playwright-community/eslint-plugin-playwright/releases)
- [Commits](https://github.com/playwright-community/eslint-plugin-playwright/compare/v0.11.2...v0.12.0)

---
updated-dependencies:
- dependency-name: eslint-plugin-playwright
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-17 22:00:58 +00:00
David Tsay
8125632728 Allow form file input to accept other MIME types (#6089)
* allow non json raw files upload

* add e2e test

* compress image
2023-01-17 14:25:18 -06:00
dependabot[bot]
14c9dd0a32 Bump plotly.js-gl2d-dist from 2.14.0 to 2.17.1 (#6104)
Bumps [plotly.js-gl2d-dist](https://github.com/plotly/plotly.js) from 2.14.0 to 2.17.1.
- [Release notes](https://github.com/plotly/plotly.js/releases)
- [Changelog](https://github.com/plotly/plotly.js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/plotly/plotly.js/compare/v2.14.0...v2.17.1)

---
updated-dependencies:
- dependency-name: plotly.js-gl2d-dist
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Shefali Joshi <simplyrender@gmail.com>
2023-01-17 10:32:14 -08:00
Even Stensberg
9ae58f8441 tooling(webpack): base paths of rootfolder (#6123) 2023-01-17 08:05:34 -08:00
dependabot[bot]
4889284335 Bump eslint from 8.31.0 to 8.32.0 (#6124)
Bumps [eslint](https://github.com/eslint/eslint) from 8.31.0 to 8.32.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v8.31.0...v8.32.0)

---
updated-dependencies:
- dependency-name: eslint
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-16 15:25:19 -08:00
dependabot[bot]
c2183d4de2 Bump @percy/cli from 1.16.0 to 1.17.0 (#6110)
Bumps [@percy/cli](https://github.com/percy/cli/tree/HEAD/packages/cli) from 1.16.0 to 1.17.0.
- [Release notes](https://github.com/percy/cli/releases)
- [Commits](https://github.com/percy/cli/commits/v1.17.0/packages/cli)

---
updated-dependencies:
- dependency-name: "@percy/cli"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-13 22:39:46 -08:00
Marcelo Arias
902d80c214 [CLA Approved] Remove notification independently (#6079)
* Add closeOverlay and notifications-count attributes to notification-message

* Add "Dismiss notification" button to NotificationMessage

* Add aria-labels to Alert Banner

* Add aria-modal and role dialog to OverlayComponent

* Add ARIA roles to NotificationMessage and NotificationsList

* Add ARIA role alert to NotificationBanner

* Create Notification E2E Test for dismissing the 'Save successful' dialog

* refactor: fix up types for NotificationAPI

* test: Add `createNotification` appAction

* test: add basic test for `createNotification`

* test: add stub for notification functional test

* Create clock using createDomainObjectWithDefaults

* Replace text-selection with button-selection

* Uninstall @types/eventemitter3

* Revert "Uninstall @types/eventemitter3"

This reverts commit 37e4df9a75.

* fix: remove duplicate dependency

Co-authored-by: Jesse Mazzella <jesse.d.mazzella@nasa.gov>
Co-authored-by: Jesse Mazzella <ozyx@users.noreply.github.com>
2023-01-14 02:12:08 +00:00
dependabot[bot]
22ce817443 Bump eslint-plugin-vue from 9.8.0 to 9.9.0 (#6117)
Bumps [eslint-plugin-vue](https://github.com/vuejs/eslint-plugin-vue) from 9.8.0 to 9.9.0.
- [Release notes](https://github.com/vuejs/eslint-plugin-vue/releases)
- [Commits](https://github.com/vuejs/eslint-plugin-vue/compare/v9.8.0...v9.9.0)

---
updated-dependencies:
- dependency-name: eslint-plugin-vue
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-13 16:36:47 -08:00
Jamie V
3e45b2ccd7 Merge branch 'master' into nb-embed-enhance 2023-01-12 11:18:01 -08:00
Jamie V
4b08aa93e6 fixing tests 2023-01-05 12:39:49 -08:00
Jamie V
e48f419db7 Merge branch 'master' into nb-embed-enhance 2023-01-05 10:49:21 -08:00
Rukmini Bose
f6e0224099 Merge remote-tracking branch 'origin' into nb-embed-enhance 2022-11-10 14:20:34 -08:00
Rukmini Bose
df3b8b55d9 address pr review comments 2022-11-10 14:19:50 -08:00
Rukmini Bose
fcf950cf43 Address PR changes. Fix text overflow for long words. 2022-10-20 10:26:47 -07:00
Rukmini Bose
54f06d36a5 Addressed PR review comments. 2022-10-08 17:21:50 -07:00
Rukmini Bose
a742c35ff9 Fix input container to extend full width. Fix margin between notebook elements 2022-10-06 14:50:34 -07:00
Rukmini Bose
332540598b Fix so embed container goes full width 2022-10-05 16:04:23 -07:00
Rukmini Bose
06d1efc008 Fix lint error 2022-10-05 10:22:43 -07:00
Rukmini Bose
d196cafb9c Fix overflow bug in entries and embed container. Refactor code so that containers optimize space of each entry 2022-10-05 10:18:26 -07:00
Rukmini Bose
081eeb8a1f Fix scroll and snow theme colors 2022-10-04 11:08:58 -07:00
Rukmini Bose
97245781e5 Fix inner shadows. Revert tag code change. Create new theme constants. Make embed container constant 2022-10-04 11:08:58 -07:00
rukmini-bose
ede591d768 Merge branch 'master' into nb-embed-enhance 2022-10-04 10:59:23 -07:00
Jamie V
945f220727 Merge branch 'master' into nb-embed-enhance 2022-10-03 13:48:10 -07:00
Rukmini Bose
bd9ed3de87 Change action menu size 2022-10-03 10:53:27 -07:00
Rukmini Bose
eb50e93cd9 Change tag margin for better spacing between rows. Class rename. Minor styling changes to embed container. Change supermenu icon size 2022-10-03 10:51:36 -07:00
Rukmini Bose
b72bad16d9 Add styling to embed scrolling container 2022-09-30 16:55:51 -07:00
Jamie V
a4d2290274 adding dynamce class for scrolling the embeds wrapper based on need 2022-09-29 20:13:33 -07:00
Rukmini Bose
9a3806b117 Rename embed wrapper 2022-09-29 15:45:50 -07:00
Rukmini Bose
ba26e38837 Added bg icons. Change sizing of icons and thumbnails. Add scrolling to overflow embeds 2022-09-29 15:22:48 -07:00
Rukmini Bose
29a747405e Add action messages. Fix margins 2022-09-28 14:30:41 -07:00
Jamie V
305d566ee7 fix method name case 2022-09-27 13:16:33 -07:00
Jamie V
95e6a5b3ad added new menu and actions to notebook embed as well as new information on embed 2022-09-27 13:12:44 -07:00
97 changed files with 3506 additions and 576 deletions

View File

@@ -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
}
};

View File

@@ -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: [

View File

@@ -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: [

View File

@@ -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,

View 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());
});

View 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));
});

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -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();
});
});

View File

@@ -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 }) => {

View File

@@ -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();
});
});

View 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);
});
});

View File

@@ -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

View File

@@ -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();

View File

@@ -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',

View File

@@ -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
});
});

View 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.
*****************************************************************************/
/*
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
});
});

View File

@@ -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');

View File

@@ -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");

View File

@@ -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
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View 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");
});

View File

@@ -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();

View 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);
});
});

View File

@@ -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()

View File

@@ -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",

View File

@@ -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());

View File

@@ -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;
}
}

View File

@@ -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');

View File

@@ -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();
},

View File

@@ -22,6 +22,7 @@
<template>
<mct-tree
id="locator-tree"
:is-selector-tree="true"
:initial-selection="model.parent"
@tree-item-selection="handleItemSelection"

View File

@@ -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;

View File

@@ -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

View File

@@ -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"

View File

@@ -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.
*/

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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() {

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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();

View File

@@ -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);
}
}
};

View File

@@ -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>

View File

@@ -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();
}
}
}
};

View File

@@ -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`;

View File

@@ -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 {

View File

@@ -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];

View File

@@ -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");

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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>}

View File

@@ -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

View File

@@ -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() {

View File

@@ -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?.();
}
});
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -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;

View File

@@ -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");

View File

@@ -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();

View File

@@ -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); }

View File

@@ -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;
}
}

View File

@@ -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";

View File

@@ -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('&');

View File

@@ -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();

View File

@@ -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();
}
}
}

View File

@@ -23,6 +23,7 @@
&__name {
@include ellipsize();
display: inline;
padding: 1px 0;
}
&__type-icon {

View File

@@ -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]);
}
}
}
};

View File

@@ -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() {

View File

@@ -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

View File

@@ -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;
}
}
};

View 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>

View 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>

View File

@@ -0,0 +1,18 @@
.c-inspect-annotations {
> * + * {
margin-top: $interiorMargin;
}
&__content{
> * + * {
margin-top: $interiorMargin;
}
}
&__content {
display: flex;
flex-direction: column;
}
}

View File

@@ -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 */

View File

@@ -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 {

View File

@@ -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;
},

View 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>

View 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>

View File

@@ -289,7 +289,7 @@
}
&__pane-tree {
width: 300px;
width: 100%;
padding-left: nth($shellPanePad, 2);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}
}
}
};

View 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;
}

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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)) {

View File

@@ -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;
},

View File

@@ -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>

View File

@@ -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');
},

View File

@@ -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;
}

View File

@@ -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);
}