Compare commits
	
		
			37 Commits
		
	
	
		
			omm-r5.1.0
			...
			fix-itc-re
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 19e42974f5 | ||
|   | 8bcd2ee756 | ||
|   | e7ce44d6fa | ||
|   | dc5dd03eae | ||
|   | 4c1a5878c3 | ||
|   | f1e873dc14 | ||
|   | d49cd2510a | ||
|   | 31ff6f228a | ||
|   | 242fa3cd25 | ||
|   | 02fc1690a9 | ||
|   | 25eccbed2c | ||
|   | ce31893797 | ||
|   | ccdaa7d2cc | ||
|   | 492289ad82 | ||
|   | c2957acea5 | ||
|   | 2d502b7ac2 | ||
|   | 981b1afb71 | ||
|   | f37d3aadb6 | ||
|   | 66cbc32dd8 | ||
|   | 4bec2c459c | ||
|   | 97781c216e | ||
|   | 9656783fbd | ||
|   | 4f37daafb5 | ||
|   | 80e16ae254 | ||
|   | cbba210ee7 | ||
|   | 060ee35dbe | ||
|   | dda6800858 | ||
|   | 7b2ad060ac | ||
|   | 7917f0977d | ||
|   | 2e5f8e7a47 | ||
|   | 1c79d2b5cf | ||
|   | 2b7129fe0b | ||
|   | decec8deef | ||
|   | 50592fdc0e | ||
|   | 8bc698cfed | ||
|   | 430428f689 | ||
|   | c6987cd866 | 
| @@ -89,7 +89,7 @@ Read more about [Playwright Snapshots](https://playwright.dev/docs/test-snapshot | ||||
| #### Open MCT's implementation | ||||
|  | ||||
| - Our Snapshot tests receive a `@snapshot` tag. | ||||
| - Snapshots need to be executed within the official Playwright container to ensure we're using the exact rendering platform in CI and locally. | ||||
| - Snapshots need to be executed within the official Playwright container to ensure we're using the exact rendering platform in CI and locally. To do a valid comparison locally: | ||||
|  | ||||
| ```sh | ||||
| docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:[GET THIS VERSION FROM OUR CIRCLECI CONFIG FILE]-focal /bin/bash | ||||
| @@ -97,9 +97,24 @@ npm install | ||||
| npx playwright test --config=e2e/playwright-ci.config.js --project=chrome --grep @snapshot | ||||
| ``` | ||||
|  | ||||
| ### (WIP) Updating Snapshots | ||||
| ### Updating Snapshots | ||||
|  | ||||
| When the `@snapshot` tests fail, they will need to be evaluated to see if the failure is an acceptable change or | ||||
| When the `@snapshot` tests fail, they will need to be evaluated to determine if the failure is an acceptable and desireable or an unintended regression. | ||||
|  | ||||
| To compare a snapshot, run a test and open the html report with the 'Expected' vs 'Actual' screenshot. If the actual screenshot is preferred, then the source-controlled 'Expected' snapshots will need to be updated with the following scripts. | ||||
|  | ||||
| MacOS | ||||
| ``` | ||||
| npm run test:e2e:updatesnapshots | ||||
| ``` | ||||
|  | ||||
| Linux/CI | ||||
| ```sh | ||||
| // Replace {X.X.X} with the current Playwright version  | ||||
| // from our package.json or circleCI configuration file | ||||
| docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v{X.X.X}-focal /bin/bash | ||||
| npm install | ||||
| npm run test:e2e:updatesnapshots | ||||
|  | ||||
| ## Performance Testing | ||||
|  | ||||
|   | ||||
| @@ -144,7 +144,9 @@ async function createNotification(page, createNotificationOptions) { | ||||
|  * @param {string} name | ||||
|  */ | ||||
| async function expandTreePaneItemByName(page, name) { | ||||
|     const treePane = page.locator('#tree-pane'); | ||||
|     const treePane = page.getByRole('tree', { | ||||
|         name: 'Main Tree' | ||||
|     }); | ||||
|     const treeItem = treePane.locator(`role=treeitem[expanded=false][name=/${name}/]`); | ||||
|     const expandTriangle = treeItem.locator('.c-disclosure-triangle'); | ||||
|     await expandTriangle.click(); | ||||
| @@ -218,6 +220,30 @@ async function openObjectTreeContextMenu(page, url) { | ||||
|     }); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Expands the entire object tree (every expandable tree item). | ||||
|  * @param {import('@playwright/test').Page} page | ||||
|  * @param {"Main Tree" | "Create Modal Tree"} [treeName="Main Tree"] | ||||
|  */ | ||||
| async function expandEntireTree(page, treeName = "Main Tree") { | ||||
|     const treeLocator = page.getByRole('tree', { | ||||
|         name: treeName | ||||
|     }); | ||||
|     const collapsedTreeItems = treeLocator.getByRole('treeitem', { | ||||
|         expanded: false | ||||
|     }).locator('span.c-disclosure-triangle.is-enabled'); | ||||
|  | ||||
|     while (await collapsedTreeItems.count() > 0) { | ||||
|         await collapsedTreeItems.nth(0).click(); | ||||
|  | ||||
|         // FIXME: Replace hard wait with something event-driven. | ||||
|         // Without the wait, this fails periodically due to a race condition | ||||
|         // with Vue rendering (loop exits prematurely). | ||||
|         // eslint-disable-next-line playwright/no-wait-for-timeout | ||||
|         await page.waitForTimeout(200); | ||||
|     } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Gets the UUID of the currently focused object by parsing the current URL | ||||
|  * and returning the last UUID in the path. | ||||
| @@ -362,6 +388,7 @@ module.exports = { | ||||
|     createDomainObjectWithDefaults, | ||||
|     createNotification, | ||||
|     expandTreePaneItemByName, | ||||
|     expandEntireTree, | ||||
|     createPlanFromJSON, | ||||
|     openObjectTreeContextMenu, | ||||
|     getHashUrlToDomainObject, | ||||
|   | ||||
							
								
								
									
										32
									
								
								e2e/helper/addInitNotebookWithUrls.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,32 @@ | ||||
| /***************************************************************************** | ||||
|  * Open MCT, Copyright (c) 2014-2022, United States Government | ||||
|  * as represented by the Administrator of the National Aeronautics and Space | ||||
|  * Administration. All rights reserved. | ||||
|  * | ||||
|  * Open MCT is licensed under the Apache License, Version 2.0 (the | ||||
|  * "License"); you may not use this file except in compliance with the License. | ||||
|  * You may obtain a copy of the License at | ||||
|  * http://www.apache.org/licenses/LICENSE-2.0. | ||||
|  * | ||||
|  * Unless required by applicable law or agreed to in writing, software | ||||
|  * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT | ||||
|  * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the | ||||
|  * License for the specific language governing permissions and limitations | ||||
|  * under the License. | ||||
|  * | ||||
|  * Open MCT includes source code licensed under additional open source | ||||
|  * licenses. See the Open Source Licenses file (LICENSES.md) included with | ||||
|  * this source code distribution or the Licensing information page available | ||||
|  * at runtime from the About dialog for additional information. | ||||
|  *****************************************************************************/ | ||||
|  | ||||
| // This should be used to install the re-instal default Notebook plugin with a simple url whitelist. | ||||
| // e.g. | ||||
| // await page.addInitScript({ path: path.join(__dirname, 'addInitNotebookWithUrls.js') }); | ||||
| const NOTEBOOK_NAME = 'Notebook'; | ||||
| const URL_WHITELIST = ['google.com']; | ||||
|  | ||||
| document.addEventListener('DOMContentLoaded', () => { | ||||
|     const openmct = window.openmct; | ||||
|     openmct.install(openmct.plugins.Notebook(NOTEBOOK_NAME, URL_WHITELIST)); | ||||
| }); | ||||
| @@ -21,7 +21,7 @@ | ||||
|  *****************************************************************************/ | ||||
|  | ||||
| const { test, expect } = require('../../pluginFixtures.js'); | ||||
| const { createDomainObjectWithDefaults, createNotification } = require('../../appActions.js'); | ||||
| const { createDomainObjectWithDefaults, createNotification, expandEntireTree } = require('../../appActions.js'); | ||||
|  | ||||
| test.describe('AppActions', () => { | ||||
|     test('createDomainObjectsWithDefaults', async ({ page }) => { | ||||
| @@ -109,4 +109,57 @@ test.describe('AppActions', () => { | ||||
|         await expect(page.locator('.c-message-banner')).toHaveClass(/error/); | ||||
|         await page.locator('[aria-label="Dismiss"]').click(); | ||||
|     }); | ||||
|     test('expandEntireTree', async ({ page }) => { | ||||
|         await page.goto('./', { waitUntil: 'networkidle' }); | ||||
|  | ||||
|         const rootFolder = await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Folder' | ||||
|         }); | ||||
|         const folder1 = await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Folder', | ||||
|             parent: rootFolder.uuid | ||||
|         }); | ||||
|  | ||||
|         await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Clock', | ||||
|             parent: folder1.uuid | ||||
|         }); | ||||
|         const folder2 = await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Folder', | ||||
|             parent: folder1.uuid | ||||
|         }); | ||||
|         await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Folder', | ||||
|             parent: folder1.uuid | ||||
|         }); | ||||
|         await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Display Layout', | ||||
|             parent: folder2.uuid | ||||
|         }); | ||||
|         await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Folder', | ||||
|             parent: folder2.uuid | ||||
|         }); | ||||
|  | ||||
|         await page.goto('./#/browse/mine'); | ||||
|         await expandEntireTree(page); | ||||
|         const treePane = page.getByRole('tree', { | ||||
|             name: "Main Tree" | ||||
|         }); | ||||
|         const treePaneCollapsedItems = treePane.getByRole('treeitem', { expanded: false }); | ||||
|         expect(await treePaneCollapsedItems.count()).toBe(0); | ||||
|  | ||||
|         await page.goto('./#/browse/mine'); | ||||
|         //Click the Create button | ||||
|         await page.click('button:has-text("Create")'); | ||||
|  | ||||
|         // Click the object specified by 'type' | ||||
|         await page.click(`li[role='menuitem']:text("Clock")`); | ||||
|         await expandEntireTree(page, "Create Modal Tree"); | ||||
|         const locatorTree = page.getByRole("tree", { | ||||
|             name: "Create Modal Tree" | ||||
|         }); | ||||
|         const locatorTreeCollapsedItems = locatorTree.locator('role=treeitem[expanded=false]'); | ||||
|         expect(await locatorTreeCollapsedItems.count()).toBe(0); | ||||
|     }); | ||||
| }); | ||||
|   | ||||
| @@ -52,7 +52,9 @@ test.describe('Move & link item tests', () => { | ||||
|         // Attempt to move parent to its own grandparent | ||||
|         await page.locator('button[title="Show selected item in tree"]').click(); | ||||
|  | ||||
|         const treePane = page.locator('#tree-pane'); | ||||
|         const treePane = page.getByRole('tree', { | ||||
|             name: 'Main Tree' | ||||
|         }); | ||||
|         await treePane.getByRole('treeitem', { | ||||
|             name: 'Parent Folder' | ||||
|         }).click({ | ||||
| @@ -63,28 +65,30 @@ test.describe('Move & link item tests', () => { | ||||
|             name: /Move/ | ||||
|         }).click(); | ||||
|  | ||||
|         const locatorTree = page.locator('#locator-tree'); | ||||
|         const myItemsLocatorTreeItem = locatorTree.getByRole('treeitem', { | ||||
|         const createModalTree = page.getByRole('tree', { | ||||
|             name: "Create Modal Tree" | ||||
|         }); | ||||
|         const myItemsLocatorTreeItem = createModalTree.getByRole('treeitem', { | ||||
|             name: myItemsFolderName | ||||
|         }); | ||||
|         await myItemsLocatorTreeItem.locator('.c-disclosure-triangle').click(); | ||||
|         await myItemsLocatorTreeItem.click(); | ||||
|  | ||||
|         const parentFolderLocatorTreeItem = locatorTree.getByRole('treeitem', { | ||||
|         const parentFolderLocatorTreeItem = createModalTree.getByRole('treeitem', { | ||||
|             name: parentFolder.name | ||||
|         }); | ||||
|         await parentFolderLocatorTreeItem.locator('.c-disclosure-triangle').click(); | ||||
|         await parentFolderLocatorTreeItem.click(); | ||||
|         await expect(page.locator('[aria-label="Save"]')).toBeDisabled(); | ||||
|  | ||||
|         const childFolderLocatorTreeItem = locatorTree.getByRole('treeitem', { | ||||
|         const childFolderLocatorTreeItem = createModalTree.getByRole('treeitem', { | ||||
|             name: new RegExp(childFolder.name) | ||||
|         }); | ||||
|         await childFolderLocatorTreeItem.locator('.c-disclosure-triangle').click(); | ||||
|         await childFolderLocatorTreeItem.click(); | ||||
|         await expect(page.locator('[aria-label="Save"]')).toBeDisabled(); | ||||
|  | ||||
|         const grandchildFolderLocatorTreeItem = locatorTree.getByRole('treeitem', { | ||||
|         const grandchildFolderLocatorTreeItem = createModalTree.getByRole('treeitem', { | ||||
|             name: grandchildFolder.name | ||||
|         }); | ||||
|         await grandchildFolderLocatorTreeItem.locator('.c-disclosure-triangle').click(); | ||||
| @@ -195,7 +199,9 @@ test.describe('Move & link item tests', () => { | ||||
|         // Attempt to move parent to its own grandparent | ||||
|         await page.locator('button[title="Show selected item in tree"]').click(); | ||||
|  | ||||
|         const treePane = page.locator('#tree-pane'); | ||||
|         const treePane = page.getByRole('tree', { | ||||
|             name: 'Main Tree' | ||||
|         }); | ||||
|         await treePane.getByRole('treeitem', { | ||||
|             name: 'Parent Folder' | ||||
|         }).click({ | ||||
| @@ -206,28 +212,30 @@ test.describe('Move & link item tests', () => { | ||||
|             name: /Move/ | ||||
|         }).click(); | ||||
|  | ||||
|         const locatorTree = page.locator('#locator-tree'); | ||||
|         const myItemsLocatorTreeItem = locatorTree.getByRole('treeitem', { | ||||
|         const createModalTree = page.getByRole('tree', { | ||||
|             name: "Create Modal Tree" | ||||
|         }); | ||||
|         const myItemsLocatorTreeItem = createModalTree.getByRole('treeitem', { | ||||
|             name: myItemsFolderName | ||||
|         }); | ||||
|         await myItemsLocatorTreeItem.locator('.c-disclosure-triangle').click(); | ||||
|         await myItemsLocatorTreeItem.click(); | ||||
|  | ||||
|         const parentFolderLocatorTreeItem = locatorTree.getByRole('treeitem', { | ||||
|         const parentFolderLocatorTreeItem = createModalTree.getByRole('treeitem', { | ||||
|             name: parentFolder.name | ||||
|         }); | ||||
|         await parentFolderLocatorTreeItem.locator('.c-disclosure-triangle').click(); | ||||
|         await parentFolderLocatorTreeItem.click(); | ||||
|         await expect(page.locator('[aria-label="Save"]')).toBeDisabled(); | ||||
|  | ||||
|         const childFolderLocatorTreeItem = locatorTree.getByRole('treeitem', { | ||||
|         const childFolderLocatorTreeItem = createModalTree.getByRole('treeitem', { | ||||
|             name: new RegExp(childFolder.name) | ||||
|         }); | ||||
|         await childFolderLocatorTreeItem.locator('.c-disclosure-triangle').click(); | ||||
|         await childFolderLocatorTreeItem.click(); | ||||
|         await expect(page.locator('[aria-label="Save"]')).toBeDisabled(); | ||||
|  | ||||
|         const grandchildFolderLocatorTreeItem = locatorTree.getByRole('treeitem', { | ||||
|         const grandchildFolderLocatorTreeItem = createModalTree.getByRole('treeitem', { | ||||
|             name: grandchildFolder.name | ||||
|         }); | ||||
|         await grandchildFolderLocatorTreeItem.locator('.c-disclosure-triangle').click(); | ||||
|   | ||||
| @@ -32,8 +32,7 @@ test.describe('Display Layout', () => { | ||||
|  | ||||
|         // Create Sine Wave Generator | ||||
|         sineWaveObject = await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Sine Wave Generator', | ||||
|             name: "Test Sine Wave Generator" | ||||
|             type: 'Sine Wave Generator' | ||||
|         }); | ||||
|     }); | ||||
|     test('alpha-numeric widget telemetry value exactly matches latest telemetry value received in real time', async ({ page }) => { | ||||
| @@ -48,7 +47,9 @@ 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 | ||||
|         const treePane = page.locator('#tree-pane'); | ||||
|         const treePane = page.getByRole('tree', { | ||||
|             name: 'Main Tree' | ||||
|         }); | ||||
|         const sineWaveGeneratorTreeItem = treePane.getByRole('treeitem', { | ||||
|             name: new RegExp(sineWaveObject.name) | ||||
|         }); | ||||
| @@ -80,7 +81,9 @@ 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 | ||||
|         const treePane = page.locator('#tree-pane'); | ||||
|         const treePane = page.getByRole('tree', { | ||||
|             name: 'Main Tree' | ||||
|         }); | ||||
|         const sineWaveGeneratorTreeItem = treePane.getByRole('treeitem', { | ||||
|             name: new RegExp(sineWaveObject.name) | ||||
|         }); | ||||
| @@ -116,7 +119,9 @@ 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 | ||||
|         const treePane = page.locator('#tree-pane'); | ||||
|         const treePane = page.getByRole('tree', { | ||||
|             name: 'Main Tree' | ||||
|         }); | ||||
|         const sineWaveGeneratorTreeItem = treePane.getByRole('treeitem', { | ||||
|             name: new RegExp(sineWaveObject.name) | ||||
|         }); | ||||
| @@ -131,7 +136,7 @@ test.describe('Display Layout', () => { | ||||
|         await page.locator('.c-tree__item.is-navigated-object .c-disclosure-triangle').click(); | ||||
|  | ||||
|         // Bring up context menu and remove | ||||
|         await page.locator('.c-tree__item.is-alias .c-tree__item__name:text("Test Sine Wave Generator")').first().click({ button: 'right' }); | ||||
|         await sineWaveGeneratorTreeItem.nth(1).click({ button: 'right' }); | ||||
|         await page.locator('li[role="menuitem"]:has-text("Remove")').click(); | ||||
|         await page.locator('button:has-text("OK")').click(); | ||||
|  | ||||
| @@ -146,8 +151,7 @@ test.describe('Display Layout', () => { | ||||
|         }); | ||||
|         // Create a Display Layout | ||||
|         const displayLayout = await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Display Layout', | ||||
|             name: "Test Display Layout" | ||||
|             type: 'Display Layout' | ||||
|         }); | ||||
|         // Edit Display Layout | ||||
|         await page.locator('[title="Edit"]').click(); | ||||
| @@ -155,7 +159,9 @@ 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 | ||||
|         const treePane = page.locator('#tree-pane'); | ||||
|         const treePane = page.getByRole('tree', { | ||||
|             name: 'Main Tree' | ||||
|         }); | ||||
|         const sineWaveGeneratorTreeItem = treePane.getByRole('treeitem', { | ||||
|             name: new RegExp(sineWaveObject.name) | ||||
|         }); | ||||
| @@ -173,7 +179,7 @@ test.describe('Display Layout', () => { | ||||
|         await page.goto(sineWaveObject.url); | ||||
|  | ||||
|         // Bring up context menu and remove | ||||
|         await page.locator('.c-tree__item.is-alias .c-tree__item__name:text("Test Sine Wave Generator")').click({ button: 'right' }); | ||||
|         await sineWaveGeneratorTreeItem.first().click({ button: 'right' }); | ||||
|         await page.locator('li[role="menuitem"]:has-text("Remove")').click(); | ||||
|         await page.locator('button:has-text("OK")').click(); | ||||
|  | ||||
|   | ||||
| @@ -25,26 +25,33 @@ const { createDomainObjectWithDefaults } = require('../../../../appActions'); | ||||
|  | ||||
| test.describe('Flexible Layout', () => { | ||||
|     let sineWaveObject; | ||||
|     let clockObject; | ||||
|     test.beforeEach(async ({ page }) => { | ||||
|         await page.goto('./', { waitUntil: 'networkidle' }); | ||||
|  | ||||
|         // Create Sine Wave Generator | ||||
|         sineWaveObject = await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Sine Wave Generator', | ||||
|             name: "Test Sine Wave Generator" | ||||
|             type: 'Sine Wave Generator' | ||||
|         }); | ||||
|  | ||||
|         // Create Clock Object | ||||
|         await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Clock', | ||||
|             name: "Test Clock" | ||||
|         clockObject = await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Clock' | ||||
|         }); | ||||
|     }); | ||||
|     test('panes have the appropriate draggable attribute while in Edit and Browse modes', async ({ page }) => { | ||||
|         const treePane = page.getByRole('tree', { | ||||
|             name: 'Main Tree' | ||||
|         }); | ||||
|         const sineWaveGeneratorTreeItem = treePane.getByRole('treeitem', { | ||||
|             name: new RegExp(sineWaveObject.name) | ||||
|         }); | ||||
|         const clockTreeItem = treePane.getByRole('treeitem', { | ||||
|             name: new RegExp(clockObject.name) | ||||
|         }); | ||||
|         // Create a Flexible Layout | ||||
|         await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Flexible Layout', | ||||
|             name: "Test Flexible Layout" | ||||
|             type: 'Flexible Layout' | ||||
|         }); | ||||
|         // Edit Flexible Layout | ||||
|         await page.locator('[title="Edit"]').click(); | ||||
| @@ -52,8 +59,8 @@ test.describe('Flexible Layout', () => { | ||||
|         // Expand the 'My Items' folder in the left tree | ||||
|         await page.locator('.c-tree__item__view-control.c-disclosure-triangle').first().click(); | ||||
|         // Add the Sine Wave Generator and Clock to the Flexible Layout | ||||
|         await page.dragAndDrop('text=Test Sine Wave Generator', '.c-fl__container.is-empty'); | ||||
|         await page.dragAndDrop('text=Test Clock', '.c-fl__container.is-empty'); | ||||
|         await sineWaveGeneratorTreeItem.dragTo(page.locator('.c-fl__container.is-empty').first()); | ||||
|         await clockTreeItem.dragTo(page.locator('.c-fl__container.is-empty')); | ||||
|         // Check that panes can be dragged while Flexible Layout is in Edit mode | ||||
|         let dragWrapper = page.locator('.c-fl-container__frames-holder .c-fl-frame__drag-wrapper').first(); | ||||
|         await expect(dragWrapper).toHaveAttribute('draggable', 'true'); | ||||
| @@ -65,10 +72,15 @@ test.describe('Flexible Layout', () => { | ||||
|         await expect(dragWrapper).toHaveAttribute('draggable', 'false'); | ||||
|     }); | ||||
|     test('items in a flexible layout can be removed with object tree context menu when viewing the flexible layout', async ({ page }) => { | ||||
|         const treePane = page.getByRole('tree', { | ||||
|             name: 'Main Tree' | ||||
|         }); | ||||
|         const sineWaveGeneratorTreeItem = treePane.getByRole('treeitem', { | ||||
|             name: new RegExp(sineWaveObject.name) | ||||
|         }); | ||||
|         // Create a Display Layout | ||||
|         await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Flexible Layout', | ||||
|             name: "Test Flexible Layout" | ||||
|             type: 'Flexible Layout' | ||||
|         }); | ||||
|         // Edit Flexible Layout | ||||
|         await page.locator('[title="Edit"]').click(); | ||||
| @@ -76,7 +88,7 @@ test.describe('Flexible Layout', () => { | ||||
|         // Expand the 'My Items' folder in the left tree | ||||
|         await page.locator('.c-tree__item__view-control.c-disclosure-triangle').first().click(); | ||||
|         // Add the Sine Wave Generator to the Flexible Layout and save changes | ||||
|         await page.dragAndDrop('text=Test Sine Wave Generator', '.c-fl__container.is-empty'); | ||||
|         await sineWaveGeneratorTreeItem.dragTo(page.locator('.c-fl__container.is-empty').first()); | ||||
|         await page.locator('button[title="Save"]').click(); | ||||
|         await page.locator('text=Save and Finish Editing').click(); | ||||
|  | ||||
| @@ -86,7 +98,7 @@ test.describe('Flexible Layout', () => { | ||||
|         await page.locator('.c-tree__item.is-navigated-object .c-disclosure-triangle').click(); | ||||
|  | ||||
|         // Bring up context menu and remove | ||||
|         await page.locator('.c-tree__item.is-alias .c-tree__item__name:text("Test Sine Wave Generator")').first().click({ button: 'right' }); | ||||
|         await sineWaveGeneratorTreeItem.first().click({ button: 'right' }); | ||||
|         await page.locator('li[role="menuitem"]:has-text("Remove")').click(); | ||||
|         await page.locator('button:has-text("OK")').click(); | ||||
|  | ||||
| @@ -98,10 +110,16 @@ test.describe('Flexible Layout', () => { | ||||
|             type: 'issue', | ||||
|             description: 'https://github.com/nasa/openmct/issues/3117' | ||||
|         }); | ||||
|         const treePane = page.getByRole('tree', { | ||||
|             name: 'Main Tree' | ||||
|         }); | ||||
|         const sineWaveGeneratorTreeItem = treePane.getByRole('treeitem', { | ||||
|             name: new RegExp(sineWaveObject.name) | ||||
|         }); | ||||
|  | ||||
|         // Create a Flexible Layout | ||||
|         const flexibleLayout = await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Flexible Layout', | ||||
|             name: "Test Flexible Layout" | ||||
|             type: 'Flexible Layout' | ||||
|         }); | ||||
|         // Edit Flexible Layout | ||||
|         await page.locator('[title="Edit"]').click(); | ||||
| @@ -109,7 +127,7 @@ test.describe('Flexible Layout', () => { | ||||
|         // Expand the 'My Items' folder in the left tree | ||||
|         await page.locator('.c-tree__item__view-control.c-disclosure-triangle').click(); | ||||
|         // Add the Sine Wave Generator to the Flexible Layout and save changes | ||||
|         await page.dragAndDrop('text=Test Sine Wave Generator', '.c-fl__container.is-empty'); | ||||
|         await sineWaveGeneratorTreeItem.dragTo(page.locator('.c-fl__container.is-empty').first()); | ||||
|         await page.locator('button[title="Save"]').click(); | ||||
|         await page.locator('text=Save and Finish Editing').click(); | ||||
|  | ||||
| @@ -122,7 +140,7 @@ test.describe('Flexible Layout', () => { | ||||
|         await page.goto(sineWaveObject.url); | ||||
|  | ||||
|         // Bring up context menu and remove | ||||
|         await page.locator('.c-tree__item.is-alias .c-tree__item__name:text("Test Sine Wave Generator")').click({ button: 'right' }); | ||||
|         await sineWaveGeneratorTreeItem.first().click({ button: 'right' }); | ||||
|         await page.locator('li[role="menuitem"]:has-text("Remove")').click(); | ||||
|         await page.locator('button:has-text("OK")').click(); | ||||
|  | ||||
|   | ||||
| @@ -25,8 +25,11 @@ This test suite is dedicated to tests which verify the basic operations surround | ||||
| */ | ||||
|  | ||||
| const { test, expect } = require('../../../../pluginFixtures'); | ||||
| const { expandTreePaneItemByName, createDomainObjectWithDefaults } = require('../../../../appActions'); | ||||
| const { createDomainObjectWithDefaults } = require('../../../../appActions'); | ||||
| const nbUtils = require('../../../../helper/notebookUtils'); | ||||
| const path = require('path'); | ||||
|  | ||||
| const NOTEBOOK_NAME = 'Notebook'; | ||||
|  | ||||
| test.describe('Notebook CRUD Operations', () => { | ||||
|     test.fixme('Can create a Notebook Object', async ({ page }) => { | ||||
| @@ -73,8 +76,7 @@ test.describe('Notebook section tests', () => { | ||||
|  | ||||
|         // Create Notebook | ||||
|         await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Notebook', | ||||
|             name: "Test Notebook" | ||||
|             type: NOTEBOOK_NAME | ||||
|         }); | ||||
|     }); | ||||
|     test('Default and new sections are automatically named Unnamed Section with Unnamed Page', async ({ page }) => { | ||||
| @@ -135,8 +137,7 @@ test.describe('Notebook page tests', () => { | ||||
|  | ||||
|         // Create Notebook | ||||
|         await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Notebook', | ||||
|             name: "Test Notebook" | ||||
|             type: NOTEBOOK_NAME | ||||
|         }); | ||||
|     }); | ||||
|     //Test will need to be implemented after a refactor in #5713 | ||||
| @@ -207,24 +208,30 @@ test.describe('Notebook search tests', () => { | ||||
| }); | ||||
|  | ||||
| test.describe('Notebook entry tests', () => { | ||||
|     // Create Notebook with URL Whitelist | ||||
|     let notebookObject; | ||||
|     test.beforeEach(async ({ page }) => { | ||||
|         // eslint-disable-next-line no-undef | ||||
|         await page.addInitScript({ path: path.join(__dirname, '../../../../helper/', 'addInitNotebookWithUrls.js') }); | ||||
|         await page.goto('./', { waitUntil: 'networkidle' }); | ||||
|  | ||||
|         notebookObject = await createDomainObjectWithDefaults(page, { | ||||
|             type: NOTEBOOK_NAME | ||||
|         }); | ||||
|     }); | ||||
|     test.fixme('When a new entry is created, it should be focused', async ({ page }) => {}); | ||||
|     test('When an object is dropped into a notebook, a new entry is created and it should be focused @unstable', async ({ page }) => { | ||||
|         await page.goto('./#/browse/mine', { waitUntil: 'networkidle' }); | ||||
|  | ||||
|         // Create Notebook | ||||
|         const notebook = await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Notebook', | ||||
|             name: "Embed Test Notebook" | ||||
|         }); | ||||
|         // Create Overlay Plot | ||||
|         await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Overlay Plot', | ||||
|             name: "Dropped Overlay Plot" | ||||
|             type: 'Overlay Plot' | ||||
|         }); | ||||
|  | ||||
|         await expandTreePaneItemByName(page, 'My Items'); | ||||
|         // Navigate to the notebook object | ||||
|         await page.goto(notebookObject.url); | ||||
|  | ||||
|         // Reveal the notebook in the tree | ||||
|         await page.getByTitle('Show selected item in tree').click(); | ||||
|  | ||||
|         await page.goto(notebook.url); | ||||
|         await page.dragAndDrop('role=treeitem[name=/Dropped Overlay Plot/]', '.c-notebook__drag-area'); | ||||
|  | ||||
|         const embed = page.locator('.c-ne__embed__link'); | ||||
| @@ -234,22 +241,16 @@ test.describe('Notebook entry tests', () => { | ||||
|         expect(embedName).toBe('Dropped Overlay Plot'); | ||||
|     }); | ||||
|     test('When an object is dropped into a notebooks existing entry, it should be focused @unstable', async ({ page }) => { | ||||
|         await page.goto('./#/browse/mine', { waitUntil: 'networkidle' }); | ||||
|  | ||||
|         // Create Notebook | ||||
|         const notebook = await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Notebook', | ||||
|             name: "Embed Test Notebook" | ||||
|         }); | ||||
|         // Create Overlay Plot | ||||
|         await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Overlay Plot', | ||||
|             name: "Dropped Overlay Plot" | ||||
|             type: 'Overlay Plot' | ||||
|         }); | ||||
|  | ||||
|         await expandTreePaneItemByName(page, 'My Items'); | ||||
|         // Navigate to the notebook object | ||||
|         await page.goto(notebookObject.url); | ||||
|  | ||||
|         await page.goto(notebook.url); | ||||
|         // Reveal the notebook in the tree | ||||
|         await page.getByTitle('Show selected item in tree').click(); | ||||
|  | ||||
|         await nbUtils.enterTextEntry(page, 'Entry to drop into'); | ||||
|         await page.dragAndDrop('role=treeitem[name=/Dropped Overlay Plot/]', 'text=Entry to drop into'); | ||||
| @@ -263,19 +264,14 @@ 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.fixme('when a valid link is entered into a notebook entry, it becomes clickable when viewing', async ({ page }) => { | ||||
|     test('when a valid link is entered into a notebook entry, it becomes clickable when viewing', async ({ page }) => { | ||||
|         const TEST_LINK = 'http://www.google.com'; | ||||
|         await page.goto('./#/browse/mine', { waitUntil: 'networkidle' }); | ||||
|  | ||||
|         // Create Notebook | ||||
|         const notebook = await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Notebook', | ||||
|             name: "Entry Link Test" | ||||
|         }); | ||||
|         // Navigate to the notebook object | ||||
|         await page.goto(notebookObject.url); | ||||
|  | ||||
|         await expandTreePaneItemByName(page, 'My Items'); | ||||
|  | ||||
|         await page.goto(notebook.url); | ||||
|         // Reveal the notebook in the tree | ||||
|         await page.getByTitle('Show selected item in tree').click(); | ||||
|  | ||||
|         await nbUtils.enterTextEntry(page, `This should be a link: ${TEST_LINK} is it?`); | ||||
|  | ||||
| @@ -293,19 +289,14 @@ test.describe('Notebook entry tests', () => { | ||||
|  | ||||
|         expect(await validLink.count()).toBe(1); | ||||
|     }); | ||||
|     test.fixme('when an invalid link is entered into a notebook entry, it does not become clickable when viewing', async ({ page }) => { | ||||
|     test('when an invalid link is entered into a notebook entry, it does not become clickable when viewing', async ({ page }) => { | ||||
|         const TEST_LINK = 'www.google.com'; | ||||
|         await page.goto('./#/browse/mine', { waitUntil: 'networkidle' }); | ||||
|  | ||||
|         // Create Notebook | ||||
|         const notebook = await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Notebook', | ||||
|             name: "Entry Link Test" | ||||
|         }); | ||||
|         // Navigate to the notebook object | ||||
|         await page.goto(notebookObject.url); | ||||
|  | ||||
|         await expandTreePaneItemByName(page, 'My Items'); | ||||
|  | ||||
|         await page.goto(notebook.url); | ||||
|         // Reveal the notebook in the tree | ||||
|         await page.getByTitle('Show selected item in tree').click(); | ||||
|  | ||||
|         await nbUtils.enterTextEntry(page, `This should NOT be a link: ${TEST_LINK} is it?`); | ||||
|  | ||||
| @@ -313,20 +304,70 @@ test.describe('Notebook entry tests', () => { | ||||
|  | ||||
|         expect(await invalidLink.count()).toBe(0); | ||||
|     }); | ||||
|     test.fixme('when a nefarious link is entered into a notebook entry, it is sanitized when viewing', async ({ page }) => { | ||||
|     test('when a link is entered, but it is not in the whitelisted urls, it does not become clickable when viewing', async ({ page }) => { | ||||
|         const TEST_LINK = 'http://www.bing.com'; | ||||
|  | ||||
|         // Navigate to the notebook object | ||||
|         await page.goto(notebookObject.url); | ||||
|  | ||||
|         // Reveal the notebook in the tree | ||||
|         await page.getByTitle('Show selected item in tree').click(); | ||||
|  | ||||
|         await nbUtils.enterTextEntry(page, `This should NOT be a link: ${TEST_LINK} is it?`); | ||||
|  | ||||
|         const invalidLink = page.locator(`a[href="${TEST_LINK}"]`); | ||||
|  | ||||
|         expect(await invalidLink.count()).toBe(0); | ||||
|     }); | ||||
|     test('when a valid link with a subdomain and a valid domain in the whitelisted urls is entered into a notebook entry, it becomes clickable when viewing', async ({ page }) => { | ||||
|         const INVALID_TEST_LINK = 'http://bing.google.com'; | ||||
|  | ||||
|         // Navigate to the notebook object | ||||
|         await page.goto(notebookObject.url); | ||||
|  | ||||
|         // Reveal the notebook in the tree | ||||
|         await page.getByTitle('Show selected item in tree').click(); | ||||
|  | ||||
|         await nbUtils.enterTextEntry(page, `This should be a link: ${INVALID_TEST_LINK} is it?`); | ||||
|  | ||||
|         const validLink = page.locator(`a[href="${INVALID_TEST_LINK}"]`); | ||||
|  | ||||
|         expect(await validLink.count()).toBe(1); | ||||
|     }); | ||||
|     test('when a valid secure link is entered into a notebook entry, it becomes clickable when viewing', async ({ page }) => { | ||||
|         const TEST_LINK = 'https://www.google.com'; | ||||
|  | ||||
|         // Navigate to the notebook object | ||||
|         await page.goto(notebookObject.url); | ||||
|  | ||||
|         // Reveal the notebook in the tree | ||||
|         await page.getByTitle('Show selected item in tree').click(); | ||||
|  | ||||
|         await nbUtils.enterTextEntry(page, `This should be a link: ${TEST_LINK} is it?`); | ||||
|  | ||||
|         const validLink = page.locator(`a[href="${TEST_LINK}"]`); | ||||
|  | ||||
|         // Start waiting for popup before clicking. Note no await. | ||||
|         const popupPromise = page.waitForEvent('popup'); | ||||
|  | ||||
|         await validLink.click(); | ||||
|         const popup = await popupPromise; | ||||
|  | ||||
|         // Wait for the popup to load. | ||||
|         await popup.waitForLoadState(); | ||||
|         expect.soft(popup.url()).toContain('www.google.com'); | ||||
|  | ||||
|         expect(await validLink.count()).toBe(1); | ||||
|     }); | ||||
|     test('when a nefarious link is entered into a notebook entry, it is sanitized when viewing', async ({ page }) => { | ||||
|         const TEST_LINK = 'http://www.google.com?bad='; | ||||
|         const TEST_LINK_BAD = `http://www.google.com?bad=<script>alert('gimme your cookies')</script>`; | ||||
|         await page.goto('./#/browse/mine', { waitUntil: 'networkidle' }); | ||||
|  | ||||
|         // Create Notebook | ||||
|         const notebook = await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Notebook', | ||||
|             name: "Entry Link Test" | ||||
|         }); | ||||
|         // Navigate to the notebook object | ||||
|         await page.goto(notebookObject.url); | ||||
|  | ||||
|         await expandTreePaneItemByName(page, 'My Items'); | ||||
|  | ||||
|         await page.goto(notebook.url); | ||||
|         // Reveal the notebook in the tree | ||||
|         await page.getByTitle('Show selected item in tree').click(); | ||||
|  | ||||
|         await nbUtils.enterTextEntry(page, `This should be a link, BUT not a bad link: ${TEST_LINK_BAD} is it?`); | ||||
|  | ||||
|   | ||||
| @@ -41,6 +41,7 @@ test.describe('Notebook Tests with CouchDB @couchdb', () => { | ||||
|     }); | ||||
|  | ||||
|     test('Inspect Notebook Entry Network Requests', async ({ page }) => { | ||||
|         await page.getByText('Annotations').click(); | ||||
|         // Expand sidebar | ||||
|         await page.locator('.c-notebook__toggle-nav-button').click(); | ||||
|  | ||||
| @@ -162,20 +163,20 @@ test.describe('Notebook Tests with CouchDB @couchdb', () => { | ||||
|         await page.locator('[aria-label="Notebook Entry Input"] >> nth=2').press('Enter'); | ||||
|  | ||||
|         // Add three tags | ||||
|         await page.hover(`button:has-text("Add Tag") >> nth=2`); | ||||
|         await page.locator(`button:has-text("Add Tag") >> nth=2`).click(); | ||||
|         await page.hover(`button:has-text("Add Tag")`); | ||||
|         await page.locator(`button:has-text("Add Tag")`).click(); | ||||
|         await page.locator('[placeholder="Type to select tag"]').click(); | ||||
|         await page.locator('[aria-label="Autocomplete Options"] >> text=Science').click(); | ||||
|         await page.waitForSelector('[aria-label="Tag"]:has-text("Science")'); | ||||
|  | ||||
|         await page.hover(`button:has-text("Add Tag") >> nth=2`); | ||||
|         await page.locator(`button:has-text("Add Tag") >> nth=2`).click(); | ||||
|         await page.hover(`button:has-text("Add Tag")`); | ||||
|         await page.locator(`button:has-text("Add Tag")`).click(); | ||||
|         await page.locator('[placeholder="Type to select tag"]').click(); | ||||
|         await page.locator('[aria-label="Autocomplete Options"] >> text=Drilling').click(); | ||||
|         await page.waitForSelector('[aria-label="Tag"]:has-text("Drilling")'); | ||||
|  | ||||
|         await page.hover(`button:has-text("Add Tag") >> nth=2`); | ||||
|         await page.locator(`button:has-text("Add Tag") >> nth=2`).click(); | ||||
|         await page.hover(`button:has-text("Add Tag")`); | ||||
|         await page.locator(`button:has-text("Add Tag")`).click(); | ||||
|         await page.locator('[placeholder="Type to select tag"]').click(); | ||||
|         await page.locator('[aria-label="Autocomplete Options"] >> text=Driving').click(); | ||||
|         await page.waitForSelector('[aria-label="Tag"]:has-text("Driving")'); | ||||
| @@ -231,6 +232,7 @@ test.describe('Notebook Tests with CouchDB @couchdb', () => { | ||||
|             type: 'issue', | ||||
|             description: 'https://github.com/akhenry/openmct-yamcs/issues/69' | ||||
|         }); | ||||
|         await page.getByText('Annotations').click(); | ||||
|         await page.locator('text=To start a new entry, click here or drag and drop any object').click(); | ||||
|         await page.locator('[aria-label="Notebook Entry Input"]').click(); | ||||
|         await page.locator('[aria-label="Notebook Entry Input"]').fill(`First Entry`); | ||||
|   | ||||
| @@ -198,7 +198,9 @@ test.describe('Tagging in Notebooks @addInit', () => { | ||||
|             page.click('.c-disclosure-triangle') | ||||
|         ]); | ||||
|  | ||||
|         const treePane = page.locator('#tree-pane'); | ||||
|         const treePane = page.getByRole('tree', { | ||||
|             name: 'Main Tree' | ||||
|         }); | ||||
|         // Click Clock | ||||
|         await treePane.getByRole('treeitem', { | ||||
|             name: clock.name | ||||
|   | ||||
| @@ -32,7 +32,7 @@ test.use({ | ||||
|     } | ||||
| }); | ||||
|  | ||||
| test.fixme('ExportAsJSON', () => { | ||||
| test.describe('Autoscale', () => { | ||||
|     test('User can set autoscale with a valid range @snapshot', async ({ page, openmctConfig }) => { | ||||
|         const { myItemsFolderName } = openmctConfig; | ||||
|  | ||||
| @@ -47,16 +47,32 @@ test.fixme('ExportAsJSON', () => { | ||||
|  | ||||
|         await testYTicks(page, ['-1.00', '-0.50', '0.00', '0.50', '1.00']); | ||||
|  | ||||
|         // enter edit mode | ||||
|         await page.click('button[title="Edit"]'); | ||||
|  | ||||
|         await turnOffAutoscale(page); | ||||
|  | ||||
|         // Make sure that after turning off autoscale, the user selected range values start at the same values the plot had prior. | ||||
|         await testYTicks(page, ['-1.00', '-0.50', '0.00', '0.50', '1.00']); | ||||
|         await setUserDefinedMinAndMax(page, '-2', '2'); | ||||
|  | ||||
|         // save | ||||
|         await page.click('button[title="Save"]'); | ||||
|         await Promise.all([ | ||||
|             page.locator('li[title = "Save and Finish Editing"]').click(), | ||||
|             //Wait for Save Banner to appear | ||||
|             page.waitForSelector('.c-message-banner__message') | ||||
|         ]); | ||||
|         //Wait until Save Banner is gone | ||||
|         await page.locator('.c-message-banner__close-button').click(); | ||||
|         await page.waitForSelector('.c-message-banner__message', { state: 'detached'}); | ||||
|  | ||||
|         // Make sure that after turning off autoscale, the user entered range values are reflexted in the ticks. | ||||
|         await testYTicks(page, ['-2.00', '-1.50', '-1.00', '-0.50', '0.00', '0.50', '1.00', '1.50', '2.00']); | ||||
|  | ||||
|         const canvas = page.locator('canvas').nth(1); | ||||
|  | ||||
|         await canvas.hover({trial: true}); | ||||
|  | ||||
|         expect(await canvas.screenshot()).toMatchSnapshot('autoscale-canvas-prepan.png', { animations: 'disabled' }); | ||||
|         expect.soft(await canvas.screenshot()).toMatchSnapshot('autoscale-canvas-prepan.png', { animations: 'disabled' }); | ||||
|  | ||||
|         //Alt Drag Start | ||||
|         await page.keyboard.down('Alt'); | ||||
| @@ -76,11 +92,12 @@ test.fixme('ExportAsJSON', () => { | ||||
|         await page.keyboard.up('Alt'); | ||||
|  | ||||
|         // Ensure the drag worked. | ||||
|         await testYTicks(page, ['0.00', '0.50', '1.00', '1.50', '2.00']); | ||||
|         await testYTicks(page, ['0.00', '0.50', '1.00', '1.50', '2.00', '2.50', '3.00', '3.50']); | ||||
|  | ||||
|         //Wait for canvas to stablize. | ||||
|         await canvas.hover({trial: true}); | ||||
|  | ||||
|         expect(await canvas.screenshot()).toMatchSnapshot('autoscale-canvas-panned.png', { animations: 'disabled' }); | ||||
|         expect.soft(await canvas.screenshot()).toMatchSnapshot('autoscale-canvas-panned.png', { animations: 'disabled' }); | ||||
|     }); | ||||
| }); | ||||
|  | ||||
| @@ -152,22 +169,25 @@ async function createSinewaveOverlayPlot(page, myItemsFolderName) { | ||||
|  * @param {import('@playwright/test').Page} page | ||||
|  */ | ||||
| async function turnOffAutoscale(page) { | ||||
|     // enter edit mode | ||||
|     await page.locator('text=Unnamed Overlay Plot Snapshot >> button').nth(3).click(); | ||||
|  | ||||
|     // uncheck autoscale | ||||
|     await page.getByRole('listitem').filter({ hasText: 'Auto scale' }).getByRole('checkbox').uncheck(); | ||||
| } | ||||
|  | ||||
|     // save | ||||
|     await page.locator('text=Snapshot Save and Finish Editing Save and Continue Editing >> button').nth(1).click(); | ||||
|     await Promise.all([ | ||||
|         page.locator('text=Save and Finish Editing').click(), | ||||
|         //Wait for Save Banner to appear | ||||
|         page.waitForSelector('.c-message-banner__message') | ||||
|     ]); | ||||
|     //Wait until Save Banner is gone | ||||
|     await page.locator('.c-message-banner__close-button').click(); | ||||
|     await page.waitForSelector('.c-message-banner__message', { state: 'detached'}); | ||||
| /** | ||||
|  * @param {import('@playwright/test').Page} page | ||||
|  * @param {string} min | ||||
|  * @param {string} max | ||||
|  */ | ||||
| async function setUserDefinedMinAndMax(page, min, max) { | ||||
|     // set minimum value | ||||
|     const minRangeInput = page.getByRole('listitem').filter({ hasText: 'Minimum Value' }).locator('input[type="number"]'); | ||||
|     await minRangeInput.click(); | ||||
|     await minRangeInput.fill(min); | ||||
|  | ||||
|     // set maximum value | ||||
|     const maxRangeInput = page.getByRole('listitem').filter({ hasText: 'Maximum Value' }).locator('input[type="number"]'); | ||||
|     await maxRangeInput.click(); | ||||
|     await maxRangeInput.fill(max); | ||||
| } | ||||
|  | ||||
| /** | ||||
| @@ -179,7 +199,7 @@ async function testYTicks(page, values) { | ||||
|     let promises = [yTicks.count().then(c => expect(c).toBe(values.length))]; | ||||
|  | ||||
|     for (let i = 0, l = values.length; i < l; i += 1) { | ||||
|         promises.push(expect(yTicks.nth(i)).toHaveText(values[i])); // eslint-disable-line | ||||
|         promises.push(expect.soft(yTicks.nth(i)).toHaveText(values[i])); // eslint-disable-line | ||||
|     } | ||||
|  | ||||
|     await Promise.all(promises); | ||||
|   | ||||
| Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 19 KiB | 
| Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB | 
| Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 19 KiB | 
| Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 16 KiB | 
| @@ -160,35 +160,16 @@ async function testRegularTicks(page) { | ||||
|  */ | ||||
| async function testLogTicks(page) { | ||||
|     const yTicks = await page.locator('.gl-plot-y-tick-label'); | ||||
|     expect(await yTicks.count()).toBe(28); | ||||
|     expect(await yTicks.count()).toBe(9); | ||||
|     await expect(yTicks.nth(0)).toHaveText('-2.98'); | ||||
|     await expect(yTicks.nth(1)).toHaveText('-2.50'); | ||||
|     await expect(yTicks.nth(2)).toHaveText('-2.00'); | ||||
|     await expect(yTicks.nth(3)).toHaveText('-1.51'); | ||||
|     await expect(yTicks.nth(4)).toHaveText('-1.20'); | ||||
|     await expect(yTicks.nth(5)).toHaveText('-1.00'); | ||||
|     await expect(yTicks.nth(6)).toHaveText('-0.80'); | ||||
|     await expect(yTicks.nth(7)).toHaveText('-0.58'); | ||||
|     await expect(yTicks.nth(8)).toHaveText('-0.40'); | ||||
|     await expect(yTicks.nth(9)).toHaveText('-0.20'); | ||||
|     await expect(yTicks.nth(10)).toHaveText('-0.00'); | ||||
|     await expect(yTicks.nth(11)).toHaveText('0.20'); | ||||
|     await expect(yTicks.nth(12)).toHaveText('0.40'); | ||||
|     await expect(yTicks.nth(13)).toHaveText('0.58'); | ||||
|     await expect(yTicks.nth(14)).toHaveText('0.80'); | ||||
|     await expect(yTicks.nth(15)).toHaveText('1.00'); | ||||
|     await expect(yTicks.nth(16)).toHaveText('1.20'); | ||||
|     await expect(yTicks.nth(17)).toHaveText('1.51'); | ||||
|     await expect(yTicks.nth(18)).toHaveText('2.00'); | ||||
|     await expect(yTicks.nth(19)).toHaveText('2.50'); | ||||
|     await expect(yTicks.nth(20)).toHaveText('2.98'); | ||||
|     await expect(yTicks.nth(21)).toHaveText('3.50'); | ||||
|     await expect(yTicks.nth(22)).toHaveText('4.00'); | ||||
|     await expect(yTicks.nth(23)).toHaveText('4.50'); | ||||
|     await expect(yTicks.nth(24)).toHaveText('5.31'); | ||||
|     await expect(yTicks.nth(25)).toHaveText('7.00'); | ||||
|     await expect(yTicks.nth(26)).toHaveText('8.00'); | ||||
|     await expect(yTicks.nth(27)).toHaveText('9.00'); | ||||
|     await expect(yTicks.nth(1)).toHaveText('-1.51'); | ||||
|     await expect(yTicks.nth(2)).toHaveText('-0.58'); | ||||
|     await expect(yTicks.nth(3)).toHaveText('-0.00'); | ||||
|     await expect(yTicks.nth(4)).toHaveText('0.58'); | ||||
|     await expect(yTicks.nth(5)).toHaveText('1.51'); | ||||
|     await expect(yTicks.nth(6)).toHaveText('2.98'); | ||||
|     await expect(yTicks.nth(7)).toHaveText('5.31'); | ||||
|     await expect(yTicks.nth(8)).toHaveText('9.00'); | ||||
| } | ||||
|  | ||||
| /** | ||||
|   | ||||
| @@ -29,8 +29,11 @@ const { test, expect } = require('../../../../pluginFixtures'); | ||||
| const { createDomainObjectWithDefaults } = require('../../../../appActions'); | ||||
|  | ||||
| test.describe('Overlay Plot', () => { | ||||
|     test.beforeEach(async ({ page }) => { | ||||
|         await page.goto('./', { waitUntil: 'networkidle' }); | ||||
|     }); | ||||
|  | ||||
|     test('Plot legend color is in sync with plot series color', async ({ page }) => { | ||||
|         await page.goto('/', { waitUntil: 'networkidle' }); | ||||
|         const overlayPlot = await createDomainObjectWithDefaults(page, { | ||||
|             type: "Overlay Plot" | ||||
|         }); | ||||
| @@ -56,35 +59,30 @@ test.describe('Overlay Plot', () => { | ||||
|  | ||||
|         expect(color).toBe('rgb(255, 166, 61)'); | ||||
|     }); | ||||
|  | ||||
|     test('The elements pool supports dragging series into multiple y-axis buckets', async ({ page }) => { | ||||
|         await page.goto('/', { waitUntil: 'networkidle' }); | ||||
|         const overlayPlot = await createDomainObjectWithDefaults(page, { | ||||
|             type: "Overlay Plot" | ||||
|         }); | ||||
|  | ||||
|         await createDomainObjectWithDefaults(page, { | ||||
|         const swgA = await createDomainObjectWithDefaults(page, { | ||||
|             type: "Sine Wave Generator", | ||||
|             name: 'swg a', | ||||
|             parent: overlayPlot.uuid | ||||
|         }); | ||||
|         await createDomainObjectWithDefaults(page, { | ||||
|         const swgB = await createDomainObjectWithDefaults(page, { | ||||
|             type: "Sine Wave Generator", | ||||
|             name: 'swg b', | ||||
|             parent: overlayPlot.uuid | ||||
|         }); | ||||
|         await createDomainObjectWithDefaults(page, { | ||||
|         const swgC = await createDomainObjectWithDefaults(page, { | ||||
|             type: "Sine Wave Generator", | ||||
|             name: 'swg c', | ||||
|             parent: overlayPlot.uuid | ||||
|         }); | ||||
|         await createDomainObjectWithDefaults(page, { | ||||
|         const swgD = await createDomainObjectWithDefaults(page, { | ||||
|             type: "Sine Wave Generator", | ||||
|             name: 'swg d', | ||||
|             parent: overlayPlot.uuid | ||||
|         }); | ||||
|         await createDomainObjectWithDefaults(page, { | ||||
|         const swgE = await createDomainObjectWithDefaults(page, { | ||||
|             type: "Sine Wave Generator", | ||||
|             name: 'swg e', | ||||
|             parent: overlayPlot.uuid | ||||
|         }); | ||||
|  | ||||
| @@ -92,33 +90,111 @@ test.describe('Overlay Plot', () => { | ||||
|         await page.click('button[title="Edit"]'); | ||||
|  | ||||
|         // Expand the elements pool vertically | ||||
|         await page.locator('.l-pane__handle').nth(2).hover({ trial: true }); | ||||
|         await page.locator('.l-pane.l-pane--vertical-handle-before', { | ||||
|             hasText: 'Elements' | ||||
|         }).locator('.l-pane__handle').hover(); | ||||
|         await page.mouse.down(); | ||||
|         await page.mouse.move(0, 100); | ||||
|         await page.mouse.up(); | ||||
|  | ||||
|         // Drag swg a, c, e into Y Axis 2 | ||||
|         await page.locator('#inspector-elements-tree >> text=swg a').dragTo(page.locator('[aria-label="Element Item Group Y Axis 2"]')); | ||||
|         await page.locator('#inspector-elements-tree >> text=swg c').dragTo(page.locator('[aria-label="Element Item Group Y Axis 2"]')); | ||||
|         await page.locator('#inspector-elements-tree >> text=swg e').dragTo(page.locator('[aria-label="Element Item Group Y Axis 2"]')); | ||||
|         const yAxis1PropertyGroup = page.locator('[aria-label="Y Axis Properties"]'); | ||||
|         const yAxis2PropertyGroup = page.locator('[aria-label="Y Axis 2 Properties"]'); | ||||
|         const yAxis3PropertyGroup = page.locator('[aria-label="Y Axis 3 Properties"]'); | ||||
|  | ||||
|         // Drag swg b into Y Axis 3 | ||||
|         await page.locator('#inspector-elements-tree >> text=swg b').dragTo(page.locator('[aria-label="Element Item Group Y Axis 3"]')); | ||||
|         // Assert that Y Axis 1 property group is visible only | ||||
|         await expect(yAxis1PropertyGroup).toBeVisible(); | ||||
|         await expect(yAxis2PropertyGroup).toBeHidden(); | ||||
|         await expect(yAxis3PropertyGroup).toBeHidden(); | ||||
|  | ||||
|         // Drag swg a, c, e into Y Axis 2 | ||||
|         await page.locator(`#inspector-elements-tree >> text=${swgA.name}`).dragTo(page.locator('[aria-label="Element Item Group Y Axis 2"]')); | ||||
|         await page.locator(`#inspector-elements-tree >> text=${swgC.name}`).dragTo(page.locator('[aria-label="Element Item Group Y Axis 2"]')); | ||||
|         await page.locator(`#inspector-elements-tree >> text=${swgE.name}`).dragTo(page.locator('[aria-label="Element Item Group Y Axis 2"]')); | ||||
|  | ||||
|         // Assert that Y Axis 1 and Y Axis 2 property groups are visible only | ||||
|         await expect(yAxis1PropertyGroup).toBeVisible(); | ||||
|         await expect(yAxis2PropertyGroup).toBeVisible(); | ||||
|         await expect(yAxis3PropertyGroup).toBeHidden(); | ||||
|  | ||||
|         const yAxis1Group = page.getByLabel("Y Axis 1"); | ||||
|         const yAxis2Group = page.getByLabel("Y Axis 2"); | ||||
|         const yAxis3Group = page.getByLabel("Y Axis 3"); | ||||
|  | ||||
|         // Drag swg b into Y Axis 3 | ||||
|         await page.locator(`#inspector-elements-tree >> text=${swgB.name}`).dragTo(page.locator('[aria-label="Element Item Group Y Axis 3"]')); | ||||
|  | ||||
|         // Assert that all Y Axis property groups are visible | ||||
|         await expect(yAxis1PropertyGroup).toBeVisible(); | ||||
|         await expect(yAxis2PropertyGroup).toBeVisible(); | ||||
|         await expect(yAxis3PropertyGroup).toBeVisible(); | ||||
|  | ||||
|         // Verify that the elements are in the correct buckets and in the correct order | ||||
|         expect(yAxis1Group.getByRole('listitem', { name: 'swg d' })).toBeTruthy(); | ||||
|         expect(yAxis1Group.getByRole('listitem').nth(0).getByText('swg d')).toBeTruthy(); | ||||
|         expect(yAxis2Group.getByRole('listitem', { name: 'swg e' })).toBeTruthy(); | ||||
|         expect(yAxis2Group.getByRole('listitem').nth(0).getByText('swg e')).toBeTruthy(); | ||||
|         expect(yAxis2Group.getByRole('listitem', { name: 'swg c' })).toBeTruthy(); | ||||
|         expect(yAxis2Group.getByRole('listitem').nth(1).getByText('swg c')).toBeTruthy(); | ||||
|         expect(yAxis2Group.getByRole('listitem', { name: 'swg a' })).toBeTruthy(); | ||||
|         expect(yAxis2Group.getByRole('listitem').nth(2).getByText('swg a')).toBeTruthy(); | ||||
|         expect(yAxis3Group.getByRole('listitem', { name: 'swg b' })).toBeTruthy(); | ||||
|         expect(yAxis3Group.getByRole('listitem').nth(0).getByText('swg b')).toBeTruthy(); | ||||
|         expect(yAxis1Group.getByRole('listitem', { name: swgD.name })).toBeTruthy(); | ||||
|         expect(yAxis1Group.getByRole('listitem').nth(0).getByText(swgD.name)).toBeTruthy(); | ||||
|         expect(yAxis2Group.getByRole('listitem', { name: swgE.name })).toBeTruthy(); | ||||
|         expect(yAxis2Group.getByRole('listitem').nth(0).getByText(swgE.name)).toBeTruthy(); | ||||
|         expect(yAxis2Group.getByRole('listitem', { name: swgC.name })).toBeTruthy(); | ||||
|         expect(yAxis2Group.getByRole('listitem').nth(1).getByText(swgC.name)).toBeTruthy(); | ||||
|         expect(yAxis2Group.getByRole('listitem', { name: swgA.name })).toBeTruthy(); | ||||
|         expect(yAxis2Group.getByRole('listitem').nth(2).getByText(swgA.name)).toBeTruthy(); | ||||
|         expect(yAxis3Group.getByRole('listitem', { name: swgB.name })).toBeTruthy(); | ||||
|         expect(yAxis3Group.getByRole('listitem').nth(0).getByText(swgB.name)).toBeTruthy(); | ||||
|     }); | ||||
|  | ||||
|     test('Clicking on an item in the elements pool brings up the plot preview with data points', async ({ page }) => { | ||||
|         const overlayPlot = await createDomainObjectWithDefaults(page, { | ||||
|             type: "Overlay Plot" | ||||
|         }); | ||||
|  | ||||
|         const swgA = await createDomainObjectWithDefaults(page, { | ||||
|             type: "Sine Wave Generator", | ||||
|             parent: overlayPlot.uuid | ||||
|         }); | ||||
|  | ||||
|         await page.goto(overlayPlot.url); | ||||
|         await page.click('button[title="Edit"]'); | ||||
|  | ||||
|         await page.locator(`#inspector-elements-tree >> text=${swgA.name}`).click(); | ||||
|         await page.locator('.js-overlay canvas').nth(1); | ||||
|         const plotPixelSize = await getCanvasPixelsWithData(page); | ||||
|         expect(plotPixelSize).toBeGreaterThan(0); | ||||
|     }); | ||||
| }); | ||||
|  | ||||
| /** | ||||
|  * @param {import('@playwright/test').Page} page | ||||
|  */ | ||||
| async function getCanvasPixelsWithData(page) { | ||||
|     const getTelemValuePromise = new Promise(resolve => page.exposeFunction('getCanvasValue', resolve)); | ||||
|  | ||||
|     await page.evaluate(() => { | ||||
|         // The document canvas is where the plot points and lines are drawn. | ||||
|         // The only way to access the canvas is using document (using page.evaluate) | ||||
|         let data; | ||||
|         let canvas; | ||||
|         let ctx; | ||||
|         canvas = document.querySelector('.js-overlay canvas'); | ||||
|         ctx = canvas.getContext('2d'); | ||||
|         data = ctx.getImageData(0, 0, canvas.width, canvas.height).data; | ||||
|         const imageDataValues = Object.values(data); | ||||
|         let plotPixels = []; | ||||
|         // Each pixel consists of four values within the ImageData.data array. The for loop iterates by multiples of four. | ||||
|         // The values associated with each pixel are R (red), G (green), B (blue), and A (alpha), in that order. | ||||
|         for (let i = 0; i < imageDataValues.length;) { | ||||
|             if (imageDataValues[i] > 0) { | ||||
|                 plotPixels.push({ | ||||
|                     startIndex: i, | ||||
|                     endIndex: i + 3, | ||||
|                     value: `rgb(${imageDataValues[i]}, ${imageDataValues[i + 1]}, ${imageDataValues[i + 2]}, ${imageDataValues[i + 3]})` | ||||
|                 }); | ||||
|             } | ||||
|  | ||||
|             i = i + 4; | ||||
|  | ||||
|         } | ||||
|  | ||||
|         window.getCanvasValue(plotPixels.length); | ||||
|     }); | ||||
|  | ||||
|     return getTelemValuePromise; | ||||
| } | ||||
|   | ||||
							
								
								
									
										139
									
								
								e2e/tests/functional/plugins/plot/stackedPlot.e2e.spec.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,139 @@ | ||||
| /***************************************************************************** | ||||
|  * Open MCT, Copyright (c) 2014-2022, United States Government | ||||
|  * as represented by the Administrator of the National Aeronautics and Space | ||||
|  * Administration. All rights reserved. | ||||
|  * | ||||
|  * Open MCT is licensed under the Apache License, Version 2.0 (the | ||||
|  * "License"); you may not use this file except in compliance with the License. | ||||
|  * You may obtain a copy of the License at | ||||
|  * http://www.apache.org/licenses/LICENSE-2.0. | ||||
|  * | ||||
|  * Unless required by applicable law or agreed to in writing, software | ||||
|  * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT | ||||
|  * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the | ||||
|  * License for the specific language governing permissions and limitations | ||||
|  * under the License. | ||||
|  * | ||||
|  * Open MCT includes source code licensed under additional open source | ||||
|  * licenses. See the Open Source Licenses file (LICENSES.md) included with | ||||
|  * this source code distribution or the Licensing information page available | ||||
|  * at runtime from the About dialog for additional information. | ||||
|  *****************************************************************************/ | ||||
|  | ||||
| /* | ||||
| Tests to verify log plot functionality. Note this test suite if very much under active development and should not | ||||
| necessarily be used for reference when writing new tests in this area. | ||||
| */ | ||||
|  | ||||
| const { test, expect } = require('../../../../pluginFixtures'); | ||||
| const { createDomainObjectWithDefaults } = require('../../../../appActions'); | ||||
|  | ||||
| test.describe('Stacked Plot', () => { | ||||
|     let stackedPlot; | ||||
|     let swgA; | ||||
|     let swgB; | ||||
|     let swgC; | ||||
|  | ||||
|     test.beforeEach(async ({ page }) => { | ||||
|         //Open a browser, navigate to the main page, and wait until all networkevents to resolve | ||||
|         await page.goto('/', { waitUntil: 'networkidle' }); | ||||
|         stackedPlot = await createDomainObjectWithDefaults(page, { | ||||
|             type: "Stacked Plot" | ||||
|         }); | ||||
|  | ||||
|         swgA = await createDomainObjectWithDefaults(page, { | ||||
|             type: "Sine Wave Generator", | ||||
|             parent: stackedPlot.uuid | ||||
|         }); | ||||
|         swgB = await createDomainObjectWithDefaults(page, { | ||||
|             type: "Sine Wave Generator", | ||||
|             parent: stackedPlot.uuid | ||||
|         }); | ||||
|         swgC = await createDomainObjectWithDefaults(page, { | ||||
|             type: "Sine Wave Generator", | ||||
|             parent: stackedPlot.uuid | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     test('Using the remove action removes the correct plot', async ({ page }) => { | ||||
|         const swgAElementsPoolItem = page.locator('#inspector-elements-tree').locator('.c-object-label', { hasText: swgA.name }); | ||||
|         const swgBElementsPoolItem = page.locator('#inspector-elements-tree').locator('.c-object-label', { hasText: swgB.name }); | ||||
|         const swgCElementsPoolItem = page.locator('#inspector-elements-tree').locator('.c-object-label', { hasText: swgC.name }); | ||||
|  | ||||
|         await page.goto(stackedPlot.url); | ||||
|  | ||||
|         await page.click('button[title="Edit"]'); | ||||
|  | ||||
|         // Expand the elements pool vertically | ||||
|         await page.locator('.l-pane__handle').nth(2).hover({ trial: true }); | ||||
|         await page.mouse.down(); | ||||
|         await page.mouse.move(0, 100); | ||||
|         await page.mouse.up(); | ||||
|  | ||||
|         await swgBElementsPoolItem.click({ button: 'right' }); | ||||
|         await page.getByRole('menuitem').filter({ hasText: /Remove/ }).click(); | ||||
|         await page.getByRole('button').filter({ hasText: "OK" }).click(); | ||||
|  | ||||
|         await expect(page.locator('#inspector-elements-tree .js-elements-pool__item')).toHaveCount(2); | ||||
|  | ||||
|         // Confirm that the elements pool contains the items we expect | ||||
|         await expect(swgAElementsPoolItem).toHaveCount(1); | ||||
|         await expect(swgBElementsPoolItem).toHaveCount(0); | ||||
|         await expect(swgCElementsPoolItem).toHaveCount(1); | ||||
|     }); | ||||
|  | ||||
|     test('Selecting a child plot while in browse and edit modes shows its properties in the inspector', async ({ page }) => { | ||||
|         await page.goto(stackedPlot.url); | ||||
|  | ||||
|         // Click on the 1st plot | ||||
|         await page.locator(`[aria-label="Stacked Plot Item ${swgA.name}"] canvas`).nth(1).click(); | ||||
|  | ||||
|         // Assert that the inspector shows the Y Axis properties for swgA | ||||
|         await expect(page.locator('[aria-label="Plot Series Properties"] >> h2')).toContainText("Plot Series"); | ||||
|         await expect(page.getByRole('list', { name: "Y Axis Properties" }).locator("h2")).toContainText("Y Axis"); | ||||
|         await expect(page.locator('[aria-label="Plot Series Properties"] .c-object-label')).toContainText(swgA.name); | ||||
|  | ||||
|         // Click on the 2nd plot | ||||
|         await page.locator(`[aria-label="Stacked Plot Item ${swgB.name}"] canvas`).nth(1).click(); | ||||
|  | ||||
|         // Assert that the inspector shows the Y Axis properties for swgB | ||||
|         await expect(page.locator('[aria-label="Plot Series Properties"] >> h2')).toContainText("Plot Series"); | ||||
|         await expect(page.getByRole('list', { name: "Y Axis Properties" }).locator("h2")).toContainText("Y Axis"); | ||||
|         await expect(page.locator('[aria-label="Plot Series Properties"] .c-object-label')).toContainText(swgB.name); | ||||
|  | ||||
|         // Click on the 3rd plot | ||||
|         await page.locator(`[aria-label="Stacked Plot Item ${swgC.name}"] canvas`).nth(1).click(); | ||||
|  | ||||
|         // Assert that the inspector shows the Y Axis properties for swgC | ||||
|         await expect(page.locator('[aria-label="Plot Series Properties"] >> h2')).toContainText("Plot Series"); | ||||
|         await expect(page.getByRole('list', { name: "Y Axis Properties" }).locator("h2")).toContainText("Y Axis"); | ||||
|         await expect(page.locator('[aria-label="Plot Series Properties"] .c-object-label')).toContainText(swgC.name); | ||||
|  | ||||
|         // Go into edit mode | ||||
|         await page.click('button[title="Edit"]'); | ||||
|  | ||||
|         // Click on canvas for the 1st plot | ||||
|         await page.locator(`[aria-label="Stacked Plot Item ${swgA.name}"]`).click(); | ||||
|  | ||||
|         // Assert that the inspector shows the Y Axis properties for swgA | ||||
|         await expect(page.locator('[aria-label="Plot Series Properties"] >> h2')).toContainText("Plot Series"); | ||||
|         await expect(page.getByRole('list', { name: "Y Axis Properties" }).locator("h2")).toContainText("Y Axis"); | ||||
|         await expect(page.locator('[aria-label="Plot Series Properties"] .c-object-label')).toContainText(swgA.name); | ||||
|  | ||||
|         //Click on canvas for the 2nd plot | ||||
|         await page.locator(`[aria-label="Stacked Plot Item ${swgB.name}"]`).click(); | ||||
|  | ||||
|         // Assert that the inspector shows the Y Axis properties for swgB | ||||
|         await expect(page.locator('[aria-label="Plot Series Properties"] >> h2')).toContainText("Plot Series"); | ||||
|         await expect(page.getByRole('list', { name: "Y Axis Properties" }).locator("h2")).toContainText("Y Axis"); | ||||
|         await expect(page.locator('[aria-label="Plot Series Properties"] .c-object-label')).toContainText(swgB.name); | ||||
|  | ||||
|         //Click on canvas for the 3rd plot | ||||
|         await page.locator(`[aria-label="Stacked Plot Item ${swgC.name}"]`).click(); | ||||
|  | ||||
|         // Assert that the inspector shows the Y Axis properties for swgC | ||||
|         await expect(page.locator('[aria-label="Plot Series Properties"] >> h2')).toContainText("Plot Series"); | ||||
|         await expect(page.getByRole('list', { name: "Y Axis Properties" }).locator("h2")).toContainText("Y Axis"); | ||||
|         await expect(page.locator('[aria-label="Plot Series Properties"] .c-object-label')).toContainText(swgC.name); | ||||
|     }); | ||||
| }); | ||||
| @@ -24,15 +24,22 @@ const { test, expect } = require('../../pluginFixtures.js'); | ||||
| const { createDomainObjectWithDefaults } = require('../../appActions.js'); | ||||
|  | ||||
| test.describe('Recent Objects', () => { | ||||
|     test('Recent Objects CRUD operations', async ({ page }) => { | ||||
|     let recentObjectsList; | ||||
|     let clock; | ||||
|     let folderA; | ||||
|     test.beforeEach(async ({ page }) => { | ||||
|         await page.goto('./', { waitUntil: 'networkidle' }); | ||||
|  | ||||
|         // Set Recent Objects List locator for subsequent tests | ||||
|         recentObjectsList = page.getByRole('list', { | ||||
|             name: 'Recent Objects' | ||||
|         }); | ||||
|  | ||||
|         // Create a folder and nest a Clock within it | ||||
|         const recentObjectsList = page.locator('[aria-label="Recent Objects"]'); | ||||
|         const folderA = await createDomainObjectWithDefaults(page, { | ||||
|         folderA = await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Folder' | ||||
|         }); | ||||
|         const clock = await createDomainObjectWithDefaults(page, { | ||||
|         clock = await createDomainObjectWithDefaults(page, { | ||||
|             type: 'Clock', | ||||
|             parent: folderA.uuid | ||||
|         }); | ||||
| @@ -42,7 +49,8 @@ test.describe('Recent Objects', () => { | ||||
|         await page.mouse.down(); | ||||
|         await page.mouse.move(0, 100); | ||||
|         await page.mouse.up(); | ||||
|  | ||||
|     }); | ||||
|     test('Recent Objects CRUD operations', async ({ page }) => { | ||||
|         // Verify that both created objects appear in the list and are in the correct order | ||||
|         expect(recentObjectsList.getByRole('listitem', { name: clock.name })).toBeTruthy(); | ||||
|         expect(recentObjectsList.getByRole('listitem', { name: folderA.name })).toBeTruthy(); | ||||
| @@ -52,7 +60,7 @@ test.describe('Recent Objects', () => { | ||||
|         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.getByRole('listitem', { name: folderA.name }).getByText(folderA.name).click(); | ||||
|         await page.waitForURL(`**/${folderA.uuid}?*`); | ||||
|         expect(recentObjectsList.getByRole('listitem').nth(0).getByText(folderA.name)).toBeTruthy(); | ||||
|  | ||||
| @@ -63,7 +71,11 @@ test.describe('Recent Objects', () => { | ||||
|         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(await page.getByRole('navigation', { | ||||
|             name: `${clock.name} Breadcrumb` | ||||
|         }).locator('a').filter({ | ||||
|             hasText: folderA.name | ||||
|         }).count()).toBeGreaterThan(0); | ||||
|         expect(recentObjectsList.getByRole('listitem', { name: folderA.name })).toBeTruthy(); | ||||
|  | ||||
|         // Delete | ||||
| @@ -79,7 +91,42 @@ test.describe('Recent Objects', () => { | ||||
|         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"); | ||||
|     test("Clicking on an object in the path of a recent object navigates to the object", async ({ page, openmctConfig }) => { | ||||
|         const { myItemsFolderName } = openmctConfig; | ||||
|         test.info().annotations.push({ | ||||
|             type: 'issue', | ||||
|             description: 'https://github.com/nasa/openmct/issues/6151' | ||||
|         }); | ||||
|         await page.goto('./#/browse/mine'); | ||||
|  | ||||
|         // Navigate to the folder by clicking on its entry in the Clock's breadcrumb | ||||
|         const waitForFolderNavigation = page.waitForURL(`**/${folderA.uuid}?*`); | ||||
|         await page.getByRole('navigation', { | ||||
|             name: `${clock.name} Breadcrumb` | ||||
|         }).locator('a').filter({ | ||||
|             hasText: folderA.name | ||||
|         }).click(); | ||||
|  | ||||
|         // Verify that the hash URL updates correctly | ||||
|         await waitForFolderNavigation; | ||||
|         // eslint-disable-next-line no-useless-escape | ||||
|         expect(page.url()).toMatch(new RegExp(`.*${folderA.uuid}\?.*`)); | ||||
|  | ||||
|         // Navigate to My Items by clicking on its entry in the Clock's breadcrumb | ||||
|         const waitForMyItemsNavigation = page.waitForURL(`**/mine?*`); | ||||
|         await page.getByRole('navigation', { | ||||
|             name: `${clock.name} Breadcrumb` | ||||
|         }).locator('a').filter({ | ||||
|             hasText: myItemsFolderName | ||||
|         }).click(); | ||||
|  | ||||
|         // Verify that the hash URL updates correctly | ||||
|         await waitForMyItemsNavigation; | ||||
|         // eslint-disable-next-line no-useless-escape | ||||
|         expect(page.url()).toMatch(new RegExp(`.*mine\?.*`)); | ||||
|     }); | ||||
|     test.fixme("Clicking on the 'target button' scrolls the object into view in the tree and highlights it", async ({ page }) => { | ||||
|     }); | ||||
|     test.fixme("Tests for context menu actions from recent objects", async ({ page }) => { | ||||
|     }); | ||||
| }); | ||||
|   | ||||
| @@ -116,7 +116,9 @@ async function getAndAssertTreeItems(page, expected) { | ||||
|  * @param {string} name | ||||
|  */ | ||||
| async function expandTreePaneItemByName(page, name) { | ||||
|     const treePane = page.locator('#tree-pane'); | ||||
|     const treePane = page.getByRole('tree', { | ||||
|         name: 'Main Tree' | ||||
|     }); | ||||
|     const treeItem = treePane.locator(`role=treeitem[expanded=false][name=/${name}/]`); | ||||
|     const expandTriangle = treeItem.locator('.c-disclosure-triangle'); | ||||
|     await expandTriangle.click(); | ||||
|   | ||||
| @@ -57,7 +57,7 @@ test.describe('Visual - Tree Pane', () => { | ||||
|             name: 'Z Clock' | ||||
|         }); | ||||
|  | ||||
|         const treePane = "#tree-pane"; | ||||
|         const treePane = "[role=tree][aria-label='Main Tree']"; | ||||
|  | ||||
|         await percySnapshot(page, `Tree Pane w/ collapsed tree (theme: ${theme})`, { | ||||
|             scope: treePane | ||||
| @@ -94,7 +94,7 @@ test.describe('Visual - Tree Pane', () => { | ||||
|  * @param {string} name | ||||
|  */ | ||||
| async function expandTreePaneItemByName(page, name) { | ||||
|     const treePane = page.locator('#tree-pane'); | ||||
|     const treePane = page.getByTestId('tree-pane'); | ||||
|     const treeItem = treePane.locator(`role=treeitem[expanded=false][name=/${name}/]`); | ||||
|     const expandTriangle = treeItem.locator('.c-disclosure-triangle'); | ||||
|     await expandTriangle.click(); | ||||
|   | ||||
| @@ -23,14 +23,18 @@ | ||||
| import EventEmitter from 'EventEmitter'; | ||||
|  | ||||
| export default class SinewaveLimitProvider extends EventEmitter { | ||||
|     #openmct; | ||||
|     #observingStaleness; | ||||
|     #watchingTheClock; | ||||
|     #isRealTime; | ||||
|  | ||||
|     constructor(openmct) { | ||||
|         super(); | ||||
|  | ||||
|         this.openmct = openmct; | ||||
|         this.observingStaleness = {}; | ||||
|         this.watchingTheClock = false; | ||||
|         this.isRealTime = undefined; | ||||
|         this.#openmct = openmct; | ||||
|         this.#observingStaleness = {}; | ||||
|         this.#watchingTheClock = false; | ||||
|         this.#isRealTime = undefined; | ||||
|     } | ||||
|  | ||||
|     supportsStaleness(domainObject) { | ||||
| @@ -38,114 +42,116 @@ export default class SinewaveLimitProvider extends EventEmitter { | ||||
|     } | ||||
|  | ||||
|     isStale(domainObject, options) { | ||||
|         if (!this.providingStaleness(domainObject)) { | ||||
|             return Promise.resolve({ | ||||
|                 isStale: false, | ||||
|                 utc: 0 | ||||
|             }); | ||||
|         if (!this.#providingStaleness(domainObject)) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         const id = this.getObjectKeyString(domainObject); | ||||
|         const id = this.#getObjectKeyString(domainObject); | ||||
|  | ||||
|         if (!this.observerExists(id)) { | ||||
|             this.createObserver(id); | ||||
|         if (!this.#observerExists(id)) { | ||||
|             this.#createObserver(id); | ||||
|         } | ||||
|  | ||||
|         return Promise.resolve(this.observingStaleness[id].isStale); | ||||
|         return Promise.resolve({ | ||||
|             isStale: this.#observingStaleness[id].isStale, | ||||
|             utc: Date.now() | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     subscribeToStaleness(domainObject, callback) { | ||||
|         const id = this.getObjectKeyString(domainObject); | ||||
|         const id = this.#getObjectKeyString(domainObject); | ||||
|  | ||||
|         if (this.isRealTime === undefined) { | ||||
|             this.updateRealTime(this.openmct.time.clock()); | ||||
|         if (this.#isRealTime === undefined) { | ||||
|             this.#updateRealTime(this.#openmct.time.clock()); | ||||
|         } | ||||
|  | ||||
|         this.handleClockUpdate(); | ||||
|         this.#handleClockUpdate(); | ||||
|  | ||||
|         if (this.observerExists(id)) { | ||||
|             this.addCallbackToObserver(id, callback); | ||||
|         if (this.#observerExists(id)) { | ||||
|             this.#addCallbackToObserver(id, callback); | ||||
|         } else { | ||||
|             this.createObserver(id, callback); | ||||
|             this.#createObserver(id, callback); | ||||
|         } | ||||
|  | ||||
|         const intervalId = setInterval(() => { | ||||
|             if (this.providingStaleness(domainObject)) { | ||||
|                 this.updateStaleness(id, !this.observingStaleness[id].isStale); | ||||
|             if (this.#providingStaleness(domainObject)) { | ||||
|                 this.#updateStaleness(id, !this.#observingStaleness[id].isStale); | ||||
|             } | ||||
|         }, 10000); | ||||
|  | ||||
|         return () => { | ||||
|             clearInterval(intervalId); | ||||
|             this.updateStaleness(id, false); | ||||
|             this.handleClockUpdate(); | ||||
|             this.destroyObserver(id); | ||||
|             this.#updateStaleness(id, false); | ||||
|             this.#handleClockUpdate(); | ||||
|             this.#destroyObserver(id); | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     handleClockUpdate() { | ||||
|         let observers = Object.values(this.observingStaleness).length > 0; | ||||
|     #handleClockUpdate() { | ||||
|         let observers = Object.values(this.#observingStaleness).length > 0; | ||||
|  | ||||
|         if (observers && !this.watchingTheClock) { | ||||
|             this.watchingTheClock = true; | ||||
|             this.openmct.time.on('clock', this.updateRealTime, this); | ||||
|         } else if (!observers && this.watchingTheClock) { | ||||
|             this.watchingTheClock = false; | ||||
|             this.openmct.time.off('clock', this.updateRealTime, this); | ||||
|         if (observers && !this.#watchingTheClock) { | ||||
|             this.#watchingTheClock = true; | ||||
|             this.#openmct.time.on('clock', this.#updateRealTime, this); | ||||
|         } else if (!observers && this.#watchingTheClock) { | ||||
|             this.#watchingTheClock = false; | ||||
|             this.#openmct.time.off('clock', this.#updateRealTime, this); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     updateRealTime(clock) { | ||||
|         this.isRealTime = clock !== undefined; | ||||
|     #updateRealTime(clock) { | ||||
|         this.#isRealTime = clock !== undefined; | ||||
|  | ||||
|         if (!this.isRealTime) { | ||||
|             Object.keys(this.observingStaleness).forEach((id) => { | ||||
|                 this.updateStaleness(id, false); | ||||
|         if (!this.#isRealTime) { | ||||
|             Object.keys(this.#observingStaleness).forEach((id) => { | ||||
|                 this.#updateStaleness(id, false); | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     updateStaleness(id, isStale) { | ||||
|         this.observingStaleness[id].isStale = isStale; | ||||
|         this.observingStaleness[id].utc = Date.now(); | ||||
|         this.observingStaleness[id].callback({ | ||||
|             isStale: this.observingStaleness[id].isStale, | ||||
|             utc: this.observingStaleness[id].utc | ||||
|     #updateStaleness(id, isStale) { | ||||
|         this.#observingStaleness[id].isStale = isStale; | ||||
|         this.#observingStaleness[id].utc = Date.now(); | ||||
|         this.#observingStaleness[id].callback({ | ||||
|             isStale: this.#observingStaleness[id].isStale, | ||||
|             utc: this.#observingStaleness[id].utc | ||||
|         }); | ||||
|         this.emit('stalenessEvent', { | ||||
|             id, | ||||
|             isStale: this.observingStaleness[id].isStale | ||||
|             isStale: this.#observingStaleness[id].isStale | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     createObserver(id, callback) { | ||||
|         this.observingStaleness[id] = { | ||||
|     #createObserver(id, callback) { | ||||
|         this.#observingStaleness[id] = { | ||||
|             isStale: false, | ||||
|             utc: Date.now() | ||||
|         }; | ||||
|  | ||||
|         if (typeof callback === 'function') { | ||||
|             this.addCallbackToObserver(id, callback); | ||||
|             this.#addCallbackToObserver(id, callback); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     destroyObserver(id) { | ||||
|         delete this.observingStaleness[id]; | ||||
|     #destroyObserver(id) { | ||||
|         if (this.#observingStaleness[id]) { | ||||
|             delete this.#observingStaleness[id]; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     providingStaleness(domainObject) { | ||||
|         return domainObject.telemetry?.staleness === true && this.isRealTime; | ||||
|     #providingStaleness(domainObject) { | ||||
|         return domainObject.telemetry?.staleness === true && this.#isRealTime; | ||||
|     } | ||||
|  | ||||
|     getObjectKeyString(object) { | ||||
|         return this.openmct.objects.makeKeyString(object.identifier); | ||||
|     #getObjectKeyString(object) { | ||||
|         return this.#openmct.objects.makeKeyString(object.identifier); | ||||
|     } | ||||
|  | ||||
|     addCallbackToObserver(id, callback) { | ||||
|         this.observingStaleness[id].callback = callback; | ||||
|     #addCallbackToObserver(id, callback) { | ||||
|         this.#observingStaleness[id].callback = callback; | ||||
|     } | ||||
|  | ||||
|     observerExists(id) { | ||||
|         return this.observingStaleness?.[id]; | ||||
|     #observerExists(id) { | ||||
|         return this.#observingStaleness?.[id]; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -275,7 +275,7 @@ function pointForTimestamp(timestamp, name, imageSamples, delay) { | ||||
|         local: Math.floor(timestamp / delay) * delay, | ||||
|         url, | ||||
|         sunOrientation: getCompassValues(0, 360), | ||||
|         cameraPan: getCompassValues(0, 360), | ||||
|         cameraAzimuth: getCompassValues(0, 360), | ||||
|         heading: getCompassValues(0, 360), | ||||
|         transformations: navCamTransformations, | ||||
|         imageDownloadName | ||||
|   | ||||
| @@ -1,47 +1,35 @@ | ||||
| import CompositionAPI from './CompositionAPI'; | ||||
| import { createOpenMct, resetApplicationState } from '../../utils/testing'; | ||||
| import CompositionCollection from './CompositionCollection'; | ||||
|  | ||||
| describe('The Composition API', function () { | ||||
|     let publicAPI; | ||||
|     let compositionAPI; | ||||
|     let topicService; | ||||
|     let mutationTopic; | ||||
|  | ||||
|     beforeEach(function () { | ||||
|     beforeEach(function (done) { | ||||
|         publicAPI = createOpenMct(); | ||||
|         compositionAPI = publicAPI.composition; | ||||
|  | ||||
|         mutationTopic = jasmine.createSpyObj('mutationTopic', [ | ||||
|             'listen' | ||||
|         ]); | ||||
|         topicService = jasmine.createSpy('topicService'); | ||||
|         topicService.and.returnValue(mutationTopic); | ||||
|         publicAPI = {}; | ||||
|         publicAPI.objects = jasmine.createSpyObj('ObjectAPI', [ | ||||
|             'get', | ||||
|             'mutate', | ||||
|             'observe', | ||||
|             'areIdsEqual' | ||||
|         const mockObjectProvider = jasmine.createSpyObj("mock provider", [ | ||||
|             "create", | ||||
|             "update", | ||||
|             "get" | ||||
|         ]); | ||||
|  | ||||
|         publicAPI.objects.areIdsEqual.and.callFake(function (id1, id2) { | ||||
|             return id1.namespace === id2.namespace && id1.key === id2.key; | ||||
|         mockObjectProvider.create.and.returnValue(Promise.resolve(true)); | ||||
|         mockObjectProvider.update.and.returnValue(Promise.resolve(true)); | ||||
|         mockObjectProvider.get.and.callFake((identifier) => { | ||||
|             return Promise.resolve({identifier}); | ||||
|         }); | ||||
|  | ||||
|         publicAPI.composition = jasmine.createSpyObj('CompositionAPI', [ | ||||
|             'checkPolicy' | ||||
|         ]); | ||||
|         publicAPI.composition.checkPolicy.and.returnValue(true); | ||||
|         publicAPI.objects.addProvider('test', mockObjectProvider); | ||||
|         publicAPI.objects.addProvider('custom', mockObjectProvider); | ||||
|  | ||||
|         publicAPI.objects.eventEmitter = jasmine.createSpyObj('eventemitter', [ | ||||
|             'on' | ||||
|         ]); | ||||
|         publicAPI.objects.get.and.callFake(function (identifier) { | ||||
|             return Promise.resolve({identifier: identifier}); | ||||
|         }); | ||||
|         publicAPI.$injector = jasmine.createSpyObj('$injector', [ | ||||
|             'get' | ||||
|         ]); | ||||
|         publicAPI.$injector.get.and.returnValue(topicService); | ||||
|         compositionAPI = new CompositionAPI(publicAPI); | ||||
|         publicAPI.on('start', done); | ||||
|         publicAPI.startHeadless(); | ||||
|     }); | ||||
|  | ||||
|     afterEach(() => { | ||||
|         return resetApplicationState(publicAPI); | ||||
|     }); | ||||
|  | ||||
|     it('returns falsy if an object does not support composition', function () { | ||||
| @@ -106,6 +94,9 @@ describe('The Composition API', function () { | ||||
|             let listener; | ||||
|             beforeEach(function () { | ||||
|                 listener = jasmine.createSpy('reorderListener'); | ||||
|                 spyOn(publicAPI.objects, 'mutate'); | ||||
|                 publicAPI.objects.mutate.and.callThrough(); | ||||
|  | ||||
|                 composition.on('reorder', listener); | ||||
|  | ||||
|                 return composition.load(); | ||||
| @@ -136,18 +127,20 @@ describe('The Composition API', function () { | ||||
|             }); | ||||
|         }); | ||||
|         it('supports adding an object to composition', function () { | ||||
|             let addListener = jasmine.createSpy('addListener'); | ||||
|             let mockChildObject = { | ||||
|                 identifier: { | ||||
|                     key: 'mock-key', | ||||
|                     namespace: '' | ||||
|                 } | ||||
|             }; | ||||
|             composition.on('add', addListener); | ||||
|             composition.add(mockChildObject); | ||||
|  | ||||
|             expect(domainObject.composition.length).toBe(4); | ||||
|             expect(domainObject.composition[3]).toEqual(mockChildObject.identifier); | ||||
|             return new Promise((resolve) => { | ||||
|                 composition.on('add', resolve); | ||||
|                 composition.add(mockChildObject); | ||||
|             }).then(() => { | ||||
|                 expect(domainObject.composition.length).toBe(4); | ||||
|                 expect(domainObject.composition[3]).toEqual(mockChildObject.identifier); | ||||
|             }); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|   | ||||
| @@ -224,7 +224,7 @@ export default class CompositionProvider { | ||||
|       * @private | ||||
|       * @param {DomainObject} oldDomainObject | ||||
|       */ | ||||
|     #onMutation(oldDomainObject) { | ||||
|     #onMutation(newDomainObject, oldDomainObject) { | ||||
|         const id = objectUtils.makeKeyString(oldDomainObject.identifier); | ||||
|         const listeners = this.#listeningTo[id]; | ||||
|  | ||||
| @@ -232,8 +232,8 @@ export default class CompositionProvider { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         const oldComposition = listeners.composition.map(objectUtils.makeKeyString); | ||||
|         const newComposition = oldDomainObject.composition.map(objectUtils.makeKeyString); | ||||
|         const oldComposition = oldDomainObject.composition.map(objectUtils.makeKeyString); | ||||
|         const newComposition = newDomainObject.composition.map(objectUtils.makeKeyString); | ||||
|  | ||||
|         const added = _.difference(newComposition, oldComposition).map(objectUtils.parseKeyString); | ||||
|         const removed = _.difference(oldComposition, newComposition).map(objectUtils.parseKeyString); | ||||
| @@ -248,8 +248,6 @@ export default class CompositionProvider { | ||||
|             }; | ||||
|         } | ||||
|  | ||||
|         listeners.composition = newComposition.map(objectUtils.parseKeyString); | ||||
|  | ||||
|         added.forEach(function (addedChild) { | ||||
|             listeners.add.forEach(notify(addedChild)); | ||||
|         }); | ||||
|   | ||||
| @@ -99,8 +99,7 @@ export default class DefaultCompositionProvider extends CompositionProvider { | ||||
|             objectListeners = this.listeningTo[keyString] = { | ||||
|                 add: [], | ||||
|                 remove: [], | ||||
|                 reorder: [], | ||||
|                 composition: [].slice.apply(domainObject.composition) | ||||
|                 reorder: [] | ||||
|             }; | ||||
|         } | ||||
|  | ||||
| @@ -172,8 +171,9 @@ export default class DefaultCompositionProvider extends CompositionProvider { | ||||
|      */ | ||||
|     add(parent, childId) { | ||||
|         if (!this.includes(parent, childId)) { | ||||
|             parent.composition.push(childId); | ||||
|             this.publicAPI.objects.mutate(parent, 'composition', parent.composition); | ||||
|             const composition = structuredClone(parent.composition); | ||||
|             composition.push(childId); | ||||
|             this.publicAPI.objects.mutate(parent, 'composition', composition); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -22,7 +22,6 @@ | ||||
|  | ||||
| <template> | ||||
| <mct-tree | ||||
|     id="locator-tree" | ||||
|     :is-selector-tree="true" | ||||
|     :initial-selection="model.parent" | ||||
|     @tree-item-selection="handleItemSelection" | ||||
|   | ||||
| @@ -75,21 +75,23 @@ class MutableDomainObject { | ||||
|         return eventOff; | ||||
|     } | ||||
|     $set(path, value) { | ||||
|         const oldModel = JSON.parse(JSON.stringify(this)); | ||||
|         const oldValue = _.get(oldModel, path); | ||||
|         MutableDomainObject.mutateObject(this, path, value); | ||||
|  | ||||
|         //Emit secret synchronization event first, so that all objects are in sync before subsequent events fired. | ||||
|         this._globalEventEmitter.emit(qualifiedEventName(this, '$_synchronize_model'), this); | ||||
|  | ||||
|         //Emit a general "any object" event | ||||
|         this._globalEventEmitter.emit(ANY_OBJECT_EVENT, this); | ||||
|         this._globalEventEmitter.emit(ANY_OBJECT_EVENT, this, oldModel); | ||||
|         //Emit wildcard event, with path so that callback knows what changed | ||||
|         this._globalEventEmitter.emit(qualifiedEventName(this, '*'), this, path, value); | ||||
|         this._globalEventEmitter.emit(qualifiedEventName(this, '*'), this, path, value, oldModel, oldValue); | ||||
|  | ||||
|         //Emit events specific to properties affected | ||||
|         let parentPropertiesList = path.split('.'); | ||||
|         for (let index = parentPropertiesList.length; index > 0; index--) { | ||||
|             let parentPropertyPath = parentPropertiesList.slice(0, index).join('.'); | ||||
|             this._globalEventEmitter.emit(qualifiedEventName(this, parentPropertyPath), _.get(this, parentPropertyPath)); | ||||
|             this._globalEventEmitter.emit(qualifiedEventName(this, parentPropertyPath), _.get(this, parentPropertyPath), _.get(oldModel, parentPropertyPath)); | ||||
|         } | ||||
|  | ||||
|         //TODO: Emit events for listeners of child properties when parent changes. | ||||
|   | ||||
| @@ -225,24 +225,21 @@ export default class ObjectAPI { | ||||
|             throw new Error('Provider does not support get!'); | ||||
|         } | ||||
|  | ||||
|         let objectPromise = provider.get(identifier, abortSignal).then(result => { | ||||
|         let objectPromise = provider.get(identifier, abortSignal).then(domainObject => { | ||||
|             delete this.cache[keystring]; | ||||
|             domainObject = this.applyGetInterceptors(identifier, domainObject); | ||||
|  | ||||
|             result = this.applyGetInterceptors(identifier, result); | ||||
|             if (result.isMutable) { | ||||
|                 result.$refresh(result); | ||||
|             } else { | ||||
|                 let mutableDomainObject = this.toMutable(result); | ||||
|                 mutableDomainObject.$refresh(result); | ||||
|             if (this.supportsMutation(identifier)) { | ||||
|                 const mutableDomainObject = this.toMutable(domainObject); | ||||
|                 mutableDomainObject.$refresh(domainObject); | ||||
|                 this.destroyMutable(mutableDomainObject); | ||||
|             } | ||||
|  | ||||
|             return result; | ||||
|         }).catch((result) => { | ||||
|             console.warn(`Failed to retrieve ${keystring}:`, result); | ||||
|  | ||||
|             return domainObject; | ||||
|         }).catch((error) => { | ||||
|             console.warn(`Failed to retrieve ${keystring}:`, error); | ||||
|             delete this.cache[keystring]; | ||||
|  | ||||
|             result = this.applyGetInterceptors(identifier); | ||||
|             const result = this.applyGetInterceptors(identifier); | ||||
|  | ||||
|             return result; | ||||
|         }); | ||||
| @@ -648,7 +645,7 @@ export default class ObjectAPI { | ||||
|      * @param {module:openmct.DomainObject} object the object to observe | ||||
|      * @param {string} path the property to observe | ||||
|      * @param {Function} callback a callback to invoke when new values for | ||||
|      *        this property are observed | ||||
|      *        this property are observed. | ||||
|      * @method observe | ||||
|      * @memberof module:openmct.ObjectAPI# | ||||
|      */ | ||||
|   | ||||
| @@ -399,7 +399,7 @@ describe("The Object API", () => { | ||||
|                     unlisten = objectAPI.observe(mutableSecondInstance, 'otherAttribute', mutationCallback); | ||||
|                     objectAPI.mutate(mutable, 'otherAttribute', 'some-new-value'); | ||||
|                 }).then(function () { | ||||
|                     expect(mutationCallback).toHaveBeenCalledWith('some-new-value'); | ||||
|                     expect(mutationCallback).toHaveBeenCalledWith('some-new-value', 'other-attribute-value'); | ||||
|                     unlisten(); | ||||
|                 }); | ||||
|             }); | ||||
| @@ -419,14 +419,20 @@ describe("The Object API", () => { | ||||
|  | ||||
|                     objectAPI.mutate(mutable, 'objectAttribute.embeddedObject.embeddedKey', 'updated-embedded-value'); | ||||
|                 }).then(function () { | ||||
|                     expect(embeddedKeyCallback).toHaveBeenCalledWith('updated-embedded-value'); | ||||
|                     expect(embeddedKeyCallback).toHaveBeenCalledWith('updated-embedded-value', 'embedded-value'); | ||||
|                     expect(embeddedObjectCallback).toHaveBeenCalledWith({ | ||||
|                         embeddedKey: 'updated-embedded-value' | ||||
|                     }, { | ||||
|                         embeddedKey: 'embedded-value' | ||||
|                     }); | ||||
|                     expect(objectAttributeCallback).toHaveBeenCalledWith({ | ||||
|                         embeddedObject: { | ||||
|                             embeddedKey: 'updated-embedded-value' | ||||
|                         } | ||||
|                     }, { | ||||
|                         embeddedObject: { | ||||
|                             embeddedKey: 'embedded-value' | ||||
|                         } | ||||
|                     }); | ||||
|  | ||||
|                     listeners.forEach(listener => listener()); | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| <template> | ||||
| <div class="c-overlay"> | ||||
| <div class="c-overlay js-overlay"> | ||||
|     <div | ||||
|         class="c-overlay__blocker" | ||||
|         @click="destroy" | ||||
| @@ -26,7 +26,7 @@ | ||||
|                 v-for="(button, index) in buttons" | ||||
|                 ref="buttons" | ||||
|                 :key="index" | ||||
|                 class="c-button" | ||||
|                 class="c-button js-overlay__button" | ||||
|                 tabindex="0" | ||||
|                 :class="{'c-button--major': focusIndex===index}" | ||||
|                 @focus="focusIndex=index" | ||||
|   | ||||
| @@ -32,14 +32,18 @@ class IndependentTimeContext extends TimeContext { | ||||
|         this.openmct = openmct; | ||||
|         this.unlisteners = []; | ||||
|         this.globalTimeContext = globalTimeContext; | ||||
|         this.upstreamTimeContext = undefined; | ||||
|         // We always start with the global time context. | ||||
|         // This upstream context will be undefined when an independent time context is added later. | ||||
|         this.upstreamTimeContext = this.globalTimeContext; | ||||
|         this.objectPath = objectPath; | ||||
|         this.refreshContext = this.refreshContext.bind(this); | ||||
|         this.resetContext = this.resetContext.bind(this); | ||||
|         this.removeIndependentContext = this.removeIndependentContext.bind(this); | ||||
|  | ||||
|         this.refreshContext(); | ||||
|  | ||||
|         this.globalTimeContext.on('refreshContext', this.refreshContext); | ||||
|         this.globalTimeContext.on('removeOwnContext', this.removeIndependentContext); | ||||
|     } | ||||
|  | ||||
|     bounds(newBounds) { | ||||
| @@ -202,16 +206,16 @@ class IndependentTimeContext extends TimeContext { | ||||
|     } | ||||
|  | ||||
|     getUpstreamContext() { | ||||
|         const objectKey = this.openmct.objects.makeKeyString(this.objectPath[this.objectPath.length - 1].identifier); | ||||
|         const doesObjectHaveTimeContext = this.globalTimeContext.independentContexts.get(objectKey); | ||||
|         if (doesObjectHaveTimeContext) { | ||||
|         // If a view has an independent context, don't return an upstream context | ||||
|         // Be aware that when a new independent time context is created, we assign the global context as default | ||||
|         if (this.hasOwnContext()) { | ||||
|             return undefined; | ||||
|         } | ||||
|  | ||||
|         let timeContext = this.globalTimeContext; | ||||
|         this.objectPath.some((item, index) => { | ||||
|             const key = this.openmct.objects.makeKeyString(item.identifier); | ||||
|             //last index is the view object itself | ||||
|             // we're only interested in parents, not self, so index > 0 | ||||
|             const itemContext = this.globalTimeContext.independentContexts.get(key); | ||||
|             if (index > 0 && itemContext && itemContext.hasOwnContext()) { | ||||
|                 //upstream time context | ||||
| @@ -225,6 +229,43 @@ class IndependentTimeContext extends TimeContext { | ||||
|  | ||||
|         return timeContext; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Set the time context of a view to follow any upstream time contexts as necessary (defaulting to the global context) | ||||
|      * This needs to be separate from refreshContext | ||||
|      */ | ||||
|     removeIndependentContext(viewKey) { | ||||
|         const key = this.openmct.objects.makeKeyString(this.objectPath[0].identifier); | ||||
|         if (viewKey && key === viewKey) { | ||||
|             //this is necessary as the upstream context gets reassigned after this | ||||
|             this.stopFollowingTimeContext(); | ||||
|  | ||||
|             let timeContext = this.globalTimeContext; | ||||
|  | ||||
|             this.objectPath.some((item, index) => { | ||||
|                 const objectKey = this.openmct.objects.makeKeyString(item.identifier); | ||||
|                 // we're only interested in any parents, not self, so index > 0 | ||||
|                 const itemContext = this.globalTimeContext.independentContexts.get(objectKey); | ||||
|                 if (index > 0 && itemContext && itemContext.hasOwnContext()) { | ||||
|                     //upstream time context | ||||
|                     timeContext = itemContext; | ||||
|  | ||||
|                     return true; | ||||
|                 } | ||||
|  | ||||
|                 return false; | ||||
|             }); | ||||
|  | ||||
|             this.upstreamTimeContext = timeContext; | ||||
|  | ||||
|             this.followTimeContext(); | ||||
|  | ||||
|             // Emit bounds so that views that are changing context get the upstream bounds | ||||
|             this.emit('bounds', this.bounds()); | ||||
|             // now that the view's context is set, tell others to check theirs in case they were following this view's context. | ||||
|             this.globalTimeContext.emit('refreshContext', viewKey); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| export default IndependentTimeContext; | ||||
|   | ||||
| @@ -149,7 +149,7 @@ class TimeAPI extends GlobalTimeContext { | ||||
|  | ||||
|         return () => { | ||||
|             //follow any upstream time context | ||||
|             this.emit('refreshContext'); | ||||
|             this.emit('removeOwnContext', key); | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -93,21 +93,82 @@ describe("The Independent Time API", function () { | ||||
|     }); | ||||
|  | ||||
|     it("follows a parent time context given the objectPath", () => { | ||||
|         let timeContext = api.getContextForView([{ | ||||
|         api.getContextForView([{ | ||||
|             identifier: { | ||||
|                 namespace: '', | ||||
|                 key: 'blah' | ||||
|             } | ||||
|         }]); | ||||
|         let destroyTimeContext = api.addIndependentContext('blah', independentBounds); | ||||
|         let timeContext = api.getContextForView([{ | ||||
|             identifier: { | ||||
|                 namespace: '', | ||||
|                 key: domainObjectKey | ||||
|             } | ||||
|         }, { | ||||
|             identifier: { | ||||
|                 namespace: '', | ||||
|                 key: 'blah' | ||||
|             } | ||||
|         }]); | ||||
|         expect(timeContext.bounds()).toEqual(independentBounds); | ||||
|         destroyTimeContext(); | ||||
|         expect(timeContext.bounds()).toEqual(bounds); | ||||
|     }); | ||||
|  | ||||
|     it("uses an object's independent time context if the parent doesn't have one", () => { | ||||
|         const domainObjectKey2 = `${domainObjectKey}-2`; | ||||
|         const domainObjectKey3 = `${domainObjectKey}-3`; | ||||
|         let timeContext = api.getContextForView([{ | ||||
|             identifier: { | ||||
|                 namespace: '', | ||||
|                 key: domainObjectKey | ||||
|             } | ||||
|         }]); | ||||
|         let destroyTimeContext = api.addIndependentContext('blah', independentBounds); | ||||
|         let timeContext2 = api.getContextForView([{ | ||||
|             identifier: { | ||||
|                 namespace: '', | ||||
|                 key: domainObjectKey2 | ||||
|             } | ||||
|         }]); | ||||
|         let timeContext3 = api.getContextForView([{ | ||||
|             identifier: { | ||||
|                 namespace: '', | ||||
|                 key: domainObjectKey3 | ||||
|             } | ||||
|         }]); | ||||
|         // all bounds follow global time context | ||||
|         expect(timeContext.bounds()).toEqual(bounds); | ||||
|         expect(timeContext2.bounds()).toEqual(bounds); | ||||
|         expect(timeContext3.bounds()).toEqual(bounds); | ||||
|         // only first item has own context | ||||
|         let destroyTimeContext = api.addIndependentContext(domainObjectKey, independentBounds); | ||||
|         expect(timeContext.bounds()).toEqual(independentBounds); | ||||
|         expect(timeContext2.bounds()).toEqual(bounds); | ||||
|         expect(timeContext3.bounds()).toEqual(bounds); | ||||
|         // first and second item have own context | ||||
|         let destroyTimeContext2 = api.addIndependentContext(domainObjectKey2, independentBounds); | ||||
|         expect(timeContext.bounds()).toEqual(independentBounds); | ||||
|         expect(timeContext2.bounds()).toEqual(independentBounds); | ||||
|         expect(timeContext3.bounds()).toEqual(bounds); | ||||
|         // all items have own time context | ||||
|         let destroyTimeContext3 = api.addIndependentContext(domainObjectKey3, independentBounds); | ||||
|         expect(timeContext.bounds()).toEqual(independentBounds); | ||||
|         expect(timeContext2.bounds()).toEqual(independentBounds); | ||||
|         expect(timeContext3.bounds()).toEqual(independentBounds); | ||||
|         //remove own contexts one at a time - should revert to global time context | ||||
|         destroyTimeContext(); | ||||
|         expect(timeContext.bounds()).toEqual(bounds); | ||||
|         expect(timeContext2.bounds()).toEqual(independentBounds); | ||||
|         expect(timeContext3.bounds()).toEqual(independentBounds); | ||||
|         destroyTimeContext2(); | ||||
|         expect(timeContext.bounds()).toEqual(bounds); | ||||
|         expect(timeContext2.bounds()).toEqual(bounds); | ||||
|         expect(timeContext3.bounds()).toEqual(independentBounds); | ||||
|         destroyTimeContext3(); | ||||
|         expect(timeContext.bounds()).toEqual(bounds); | ||||
|         expect(timeContext2.bounds()).toEqual(bounds); | ||||
|         expect(timeContext3.bounds()).toEqual(bounds); | ||||
|     }); | ||||
|  | ||||
|     it("Allows setting of valid bounds", function () { | ||||
|   | ||||
| @@ -48,11 +48,11 @@ | ||||
|                 </tr> | ||||
|                 <lad-row | ||||
|                     v-for="ladRow in ladTelemetryObjects[ladTable.key]" | ||||
|                     :key="ladRow.key" | ||||
|                     :key="combineKeys(ladTable.key, ladRow.key)" | ||||
|                     :domain-object="ladRow.domainObject" | ||||
|                     :path-to-table="ladTable.objectPath" | ||||
|                     :has-units="hasUnits" | ||||
|                     :is-stale="staleObjects.includes(ladRow.key)" | ||||
|                     :is-stale="staleObjects.includes(combineKeys(ladTable.key, ladRow.key))" | ||||
|                     @rowContextClick="updateViewContext" | ||||
|                 /> | ||||
|             </template> | ||||
| @@ -160,10 +160,18 @@ export default { | ||||
|                 removeCallback | ||||
|             }); | ||||
|         }, | ||||
|         combineKeys(ladKey, telemetryObjectKey) { | ||||
|             return `${ladKey}-${telemetryObjectKey}`; | ||||
|         }, | ||||
|         removeLadTable(identifier) { | ||||
|             let index = this.ladTableObjects.findIndex(ladTable => this.openmct.objects.makeKeyString(identifier) === ladTable.key); | ||||
|             let ladTable = this.ladTableObjects[index]; | ||||
|  | ||||
|             this.ladTelemetryObjects[ladTable.key].forEach(telemetryObject => { | ||||
|                 let combinedKey = this.combineKeys(ladTable.key, telemetryObject.key); | ||||
|                 this.unwatchStaleness(combinedKey); | ||||
|             }); | ||||
|  | ||||
|             this.$delete(this.ladTelemetryObjects, ladTable.key); | ||||
|             this.ladTableObjects.splice(index, 1); | ||||
|         }, | ||||
| @@ -178,59 +186,58 @@ export default { | ||||
|                 let telemetryObject = {}; | ||||
|                 telemetryObject.key = this.openmct.objects.makeKeyString(domainObject.identifier); | ||||
|                 telemetryObject.domainObject = domainObject; | ||||
|                 const combinedKey = this.combineKeys(ladTable.key, telemetryObject.key); | ||||
|  | ||||
|                 let telemetryObjects = this.ladTelemetryObjects[ladTable.key]; | ||||
|                 const telemetryObjects = this.ladTelemetryObjects[ladTable.key]; | ||||
|                 telemetryObjects.push(telemetryObject); | ||||
|  | ||||
|                 this.$set(this.ladTelemetryObjects, ladTable.key, telemetryObjects); | ||||
|  | ||||
|                 // if tracking already, possibly in another table, return | ||||
|                 if (this.stalenessSubscription[telemetryObject.key]) { | ||||
|                     return; | ||||
|                 } else { | ||||
|                     this.stalenessSubscription[telemetryObject.key] = {}; | ||||
|                     this.stalenessSubscription[telemetryObject.key].stalenessUtils = new StalenessUtils(this.openmct, domainObject); | ||||
|                 } | ||||
|                 this.stalenessSubscription[combinedKey] = {}; | ||||
|                 this.stalenessSubscription[combinedKey].stalenessUtils = new StalenessUtils(this.openmct, domainObject); | ||||
|  | ||||
|                 this.openmct.telemetry.isStale(domainObject).then((stalenessResponse) => { | ||||
|                     if (stalenessResponse !== undefined) { | ||||
|                         this.handleStaleness(telemetryObject.key, stalenessResponse); | ||||
|                         this.handleStaleness(combinedKey, stalenessResponse); | ||||
|                     } | ||||
|                 }); | ||||
|                 const stalenessSubscription = this.openmct.telemetry.subscribeToStaleness(domainObject, (stalenessResponse) => { | ||||
|                     this.handleStaleness(telemetryObject.key, stalenessResponse); | ||||
|                     this.handleStaleness(combinedKey, stalenessResponse); | ||||
|                 }); | ||||
|  | ||||
|                 this.stalenessSubscription[telemetryObject.key].unsubscribe = stalenessSubscription; | ||||
|                 this.stalenessSubscription[combinedKey].unsubscribe = stalenessSubscription; | ||||
|             }; | ||||
|         }, | ||||
|         removeTelemetryObject(ladTable) { | ||||
|             return (identifier) => { | ||||
|                 const SKIP_CHECK = true; | ||||
|                 const keystring = this.openmct.objects.makeKeyString(identifier); | ||||
|                 let telemetryObjects = this.ladTelemetryObjects[ladTable.key]; | ||||
|                 const telemetryObjects = this.ladTelemetryObjects[ladTable.key]; | ||||
|                 const combinedKey = this.combineKeys(ladTable.key, keystring); | ||||
|                 let index = telemetryObjects.findIndex(telemetryObject => keystring === telemetryObject.key); | ||||
|  | ||||
|                 this.unwatchStaleness(combinedKey); | ||||
|  | ||||
|                 telemetryObjects.splice(index, 1); | ||||
|  | ||||
|                 this.$set(this.ladTelemetryObjects, ladTable.key, telemetryObjects); | ||||
|  | ||||
|                 this.stalenessSubscription[keystring].unsubscribe(); | ||||
|                 this.stalenessSubscription[keystring].stalenessUtils.destroy(); | ||||
|                 this.handleStaleness(keystring, { isStale: false }, SKIP_CHECK); | ||||
|             }; | ||||
|         }, | ||||
|         handleStaleness(id, stalenessResponse, skipCheck = false) { | ||||
|             if (skipCheck || this.stalenessSubscription[id].stalenessUtils.shouldUpdateStaleness(stalenessResponse)) { | ||||
|                 const index = this.staleObjects.indexOf(id); | ||||
|                 if (stalenessResponse.isStale) { | ||||
|                     if (index === -1) { | ||||
|                         this.staleObjects.push(id); | ||||
|                     } | ||||
|                 } else { | ||||
|                     if (index !== -1) { | ||||
|                         this.staleObjects.splice(index, 1); | ||||
|                     } | ||||
|         unwatchStaleness(combinedKey) { | ||||
|             const SKIP_CHECK = true; | ||||
|  | ||||
|             this.stalenessSubscription[combinedKey].unsubscribe(); | ||||
|             this.stalenessSubscription[combinedKey].stalenessUtils.destroy(); | ||||
|             this.handleStaleness(combinedKey, { isStale: false }, SKIP_CHECK); | ||||
|  | ||||
|             delete this.stalenessSubscription[combinedKey]; | ||||
|         }, | ||||
|         handleStaleness(combinedKey, stalenessResponse, skipCheck = false) { | ||||
|             if (skipCheck || this.stalenessSubscription[combinedKey].stalenessUtils.shouldUpdateStaleness(stalenessResponse)) { | ||||
|                 const index = this.staleObjects.indexOf(combinedKey); | ||||
|                 const foundStaleObject = index > -1; | ||||
|                 if (stalenessResponse.isStale && !foundStaleObject) { | ||||
|                     this.staleObjects.push(combinedKey); | ||||
|                 } else if (!stalenessResponse.isStale && foundStaleObject) { | ||||
|                     this.staleObjects.splice(index, 1); | ||||
|                 } | ||||
|             } | ||||
|         }, | ||||
|   | ||||
| @@ -232,10 +232,12 @@ export default { | ||||
|             this.stalenessSubscription[keyString].stalenessUtils = new StalenessUtils(this.openmct, domainObject); | ||||
|  | ||||
|             this.openmct.telemetry.isStale(domainObject).then((stalenessResponse) => { | ||||
|                 this.hanldeStaleness(keyString, stalenessResponse); | ||||
|                 if (stalenessResponse !== undefined) { | ||||
|                     this.handleStaleness(keyString, stalenessResponse); | ||||
|                 } | ||||
|             }); | ||||
|             const stalenessSubscription = this.openmct.telemetry.subscribeToStaleness(domainObject, (stalenessResponse) => { | ||||
|                 this.hanldeStaleness(keyString, stalenessResponse); | ||||
|                 this.handleStaleness(keyString, stalenessResponse); | ||||
|             }); | ||||
|  | ||||
|             this.stalenessSubscription[keyString].unsubscribe = stalenessSubscription; | ||||
| @@ -259,9 +261,10 @@ export default { | ||||
|                     keyString, | ||||
|                     isStale: false | ||||
|                 }); | ||||
|                 delete this.stalenessSubscription[keyString]; | ||||
|             } | ||||
|         }, | ||||
|         hanldeStaleness(keyString, stalenessResponse) { | ||||
|         handleStaleness(keyString, stalenessResponse) { | ||||
|             if (this.stalenessSubscription[keyString].stalenessUtils.shouldUpdateStaleness(stalenessResponse)) { | ||||
|                 this.emitStaleness({ | ||||
|                     keyString, | ||||
|   | ||||
| @@ -83,6 +83,11 @@ export default class AllTelemetryCriterion extends TelemetryCriterion { | ||||
|             if (!this.stalenessSubscription[id]) { | ||||
|                 this.stalenessSubscription[id] = {}; | ||||
|                 this.stalenessSubscription[id].stalenessUtils = new StalenessUtils(this.openmct, telemetryObject); | ||||
|                 this.openmct.telemetry.isStale(telemetryObject).then((stalenessResponse) => { | ||||
|                     if (stalenessResponse !== undefined) { | ||||
|                         this.handleStaleTelemetry(id, stalenessResponse); | ||||
|                     } | ||||
|                 }); | ||||
|                 this.stalenessSubscription[id].unsubscribe = this.openmct.telemetry.subscribeToStaleness( | ||||
|                     telemetryObject, | ||||
|                     (stalenessResponse) => { | ||||
|   | ||||
| @@ -59,7 +59,7 @@ export default class CreateAction extends PropertiesAction { | ||||
|             _.set(this.domainObject, key, value); | ||||
|         }); | ||||
|  | ||||
|         const parentDomainObject = parentDomainObjectPath[0]; | ||||
|         const parentDomainObject = this.openmct.objects.toMutable(parentDomainObjectPath[0]); | ||||
|  | ||||
|         this.domainObject.modified = Date.now(); | ||||
|         this.domainObject.location = this.openmct.objects.makeKeyString(parentDomainObject.identifier); | ||||
| @@ -85,6 +85,7 @@ export default class CreateAction extends PropertiesAction { | ||||
|             console.error(err); | ||||
|             this.openmct.notifications.error(`Error saving objects: ${err}`); | ||||
|         } finally { | ||||
|             this.openmct.objects.destroyMutable(parentDomainObject); | ||||
|             dialog.dismiss(); | ||||
|         } | ||||
|  | ||||
| @@ -142,18 +143,21 @@ export default class CreateAction extends PropertiesAction { | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         this.domainObject = domainObject; | ||||
|         this.domainObject = this.openmct.objects.toMutable(domainObject); | ||||
|  | ||||
|         if (definition.initialize) { | ||||
|             definition.initialize(domainObject); | ||||
|             definition.initialize(this.domainObject); | ||||
|         } | ||||
|  | ||||
|         const createWizard = new CreateWizard(this.openmct, domainObject, this.parentDomainObject); | ||||
|         const createWizard = new CreateWizard(this.openmct, this.domainObject, this.parentDomainObject); | ||||
|         const formStructure = createWizard.getFormStructure(true); | ||||
|         formStructure.title = 'Create a New ' + definition.name; | ||||
|  | ||||
|         this.openmct.forms.showForm(formStructure) | ||||
|             .then(this._onSave.bind(this)) | ||||
|             .catch(this._onCancel.bind(this)); | ||||
|             .catch(this._onCancel.bind(this)) | ||||
|             .finally(() => { | ||||
|                 this.openmct.objects.destroyMutable(this.domainObject); | ||||
|             }); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -26,18 +26,23 @@ | ||||
|     :style="`width: 100%; height: 100%`" | ||||
| > | ||||
|     <CompassHUD | ||||
|         v-if="showCompassHUD" | ||||
|         :sun-heading="sunHeading" | ||||
|         :camera-angle-of-view="cameraAngleOfView" | ||||
|         :camera-pan="cameraPan" | ||||
|         :heading="heading" | ||||
|         :camera-azimuth="cameraAzimuth" | ||||
|         :transformations="transformations" | ||||
|         :has-gimble="hasGimble" | ||||
|         :normalized-camera-azimuth="normalizedCameraAzimuth" | ||||
|         :sun-heading="sunHeading" | ||||
|     /> | ||||
|     <CompassRose | ||||
|         v-if="showCompassRose" | ||||
|         :camera-pan="cameraPan" | ||||
|         :camera-angle-of-view="cameraAngleOfView" | ||||
|         :heading="heading" | ||||
|         :sized-image-dimensions="sizedImageDimensions" | ||||
|         :sun-heading="sunHeading" | ||||
|         :camera-azimuth="cameraAzimuth" | ||||
|         :transformations="transformations" | ||||
|         :has-gimble="hasGimble" | ||||
|         :normalized-camera-azimuth="normalizedCameraAzimuth" | ||||
|         :sun-heading="sunHeading" | ||||
|         :sized-image-dimensions="sizedImageDimensions" | ||||
|     /> | ||||
| </div> | ||||
| </template> | ||||
| @@ -45,6 +50,7 @@ | ||||
| <script> | ||||
| import CompassHUD from './CompassHUD.vue'; | ||||
| import CompassRose from './CompassRose.vue'; | ||||
| import { rotate } from './utils'; | ||||
|  | ||||
| export default { | ||||
|     components: { | ||||
| @@ -62,11 +68,14 @@ export default { | ||||
|         } | ||||
|     }, | ||||
|     computed: { | ||||
|         showCompassHUD() { | ||||
|             return this.hasCameraPan && this.cameraAngleOfView > 0; | ||||
|         hasGimble() { | ||||
|             return this.cameraAzimuth !== undefined; | ||||
|         }, | ||||
|         showCompassRose() { | ||||
|             return (this.hasCameraPan || this.hasHeading) && this.cameraAngleOfView > 0; | ||||
|         // compass ordinal orientation of camera | ||||
|         normalizedCameraAzimuth() { | ||||
|             return this.hasGimble | ||||
|                 ? rotate(this.cameraAzimuth) | ||||
|                 : rotate(this.heading, -this.transformations.rotation || 0); | ||||
|         }, | ||||
|         // horizontal rotation from north in degrees | ||||
|         heading() { | ||||
| @@ -80,14 +89,11 @@ export default { | ||||
|             return this.image.sunOrientation; | ||||
|         }, | ||||
|         // horizontal rotation from north in degrees | ||||
|         cameraPan() { | ||||
|         cameraAzimuth() { | ||||
|             return this.image.cameraPan; | ||||
|         }, | ||||
|         hasCameraPan() { | ||||
|             return this.cameraPan !== undefined; | ||||
|         }, | ||||
|         cameraAngleOfView() { | ||||
|             return this.transformations?.cameraAngleOfView; | ||||
|             return this.transformations.cameraAngleOfView; | ||||
|         }, | ||||
|         transformations() { | ||||
|             return this.image.transformations; | ||||
|   | ||||
| @@ -94,17 +94,33 @@ const COMPASS_POINTS = [ | ||||
|  | ||||
| export default { | ||||
|     props: { | ||||
|         cameraAngleOfView: { | ||||
|             type: Number, | ||||
|             required: true | ||||
|         }, | ||||
|         heading: { | ||||
|             type: Number, | ||||
|             required: true | ||||
|         }, | ||||
|         cameraAzimuth: { | ||||
|             type: Number, | ||||
|             default: undefined | ||||
|         }, | ||||
|         transformations: { | ||||
|             type: Object, | ||||
|             required: true | ||||
|         }, | ||||
|         hasGimble: { | ||||
|             type: Boolean, | ||||
|             required: true | ||||
|         }, | ||||
|         normalizedCameraAzimuth: { | ||||
|             type: Number, | ||||
|             required: true | ||||
|         }, | ||||
|         sunHeading: { | ||||
|             type: Number, | ||||
|             default: undefined | ||||
|         }, | ||||
|         cameraAngleOfView: { | ||||
|             type: Number, | ||||
|             default: undefined | ||||
|         }, | ||||
|         cameraPan: { | ||||
|             type: Number, | ||||
|             required: true | ||||
|         } | ||||
|     }, | ||||
|     computed: { | ||||
| @@ -130,10 +146,13 @@ export default { | ||||
|                 left: `${ percentage * 100 }%` | ||||
|             }; | ||||
|         }, | ||||
|         cameraRotation() { | ||||
|             return this.transformations?.rotation; | ||||
|         }, | ||||
|         visibleRange() { | ||||
|             return [ | ||||
|                 rotate(this.cameraPan, -this.cameraAngleOfView / 2), | ||||
|                 rotate(this.cameraPan, this.cameraAngleOfView / 2) | ||||
|                 rotate(this.normalizedCameraAzimuth, -this.cameraAngleOfView / 2), | ||||
|                 rotate(this.normalizedCameraAzimuth, this.cameraAngleOfView / 2) | ||||
|             ]; | ||||
|         } | ||||
|     } | ||||
|   | ||||
| @@ -75,7 +75,6 @@ | ||||
|                     :style="sunHeadingStyle" | ||||
|                 /> | ||||
|  | ||||
|                 <!-- Camera FOV --> | ||||
|                 <mask | ||||
|                     id="mask2" | ||||
|                     class="c-cr__cam-fov-l-mask" | ||||
| @@ -107,55 +106,61 @@ | ||||
|                         height="100" | ||||
|                     /> | ||||
|                 </mask> | ||||
|  | ||||
|                 <!-- Equipment (spacecraft) body holder. Transforms relative to the camera position. --> | ||||
|                 <g | ||||
|                     v-if="hasHeading" | ||||
|                     class="cr-vrover" | ||||
|                     :style="camAngleAndPositionStyle" | ||||
|                 > | ||||
|                     <!-- Equipment body. Rotates relative to the camera gimbal value for cams that gimbal. --> | ||||
|                     <path | ||||
|                         class="cr-vrover__body" | ||||
|                         :style="camGimbalAngleStyle" | ||||
|                         fill-rule="evenodd" | ||||
|                         clip-rule="evenodd" | ||||
|                         d="M5 0C2.23858 0 0 2.23858 0 5V95C0 97.7614 2.23858 100 5 100H95C97.7614 100 100 97.7614 100 95V5C100 2.23858 97.7614 0 95 0H5ZM85 59L50 24L15 59H33V75H67.0455V59H85Z" | ||||
|                     /> | ||||
|                 </g> | ||||
|  | ||||
|                 <g | ||||
|                     class="c-cr__cam-fov" | ||||
|                     class="c-cr-cam-and-body" | ||||
|                     :style="cameraHeadingStyle" | ||||
|                 > | ||||
|                     <g mask="url(#mask2)"> | ||||
|                         <rect | ||||
|                             class="c-cr__cam-fov-r" | ||||
|                             x="49" | ||||
|                             width="51" | ||||
|                             height="100" | ||||
|                             :style="cameraFOVStyleRightHalf" | ||||
|                     <!-- Equipment (spacecraft) body holder. Transforms relative to the camera position. --> | ||||
|                     <g | ||||
|                         v-if="hasHeading" | ||||
|                         class="cr-vrover" | ||||
|                         :style="camAngleAndPositionStyle" | ||||
|                     > | ||||
|                         <!-- Equipment body. Rotates relative to the camera pan value for cameras that gimble. --> | ||||
|                         <path | ||||
|                             class="cr-vrover__body" | ||||
|                             :style="gimbledCameraPanStyle" | ||||
|                             x | ||||
|                             fill-rule="evenodd" | ||||
|                             clip-rule="evenodd" | ||||
|                             d="M5 0C2.23858 0 0 2.23858 0 5V95C0 97.7614 2.23858 100 5 100H95C97.7614 100 100 97.7614 100 95V5C100 2.23858 97.7614 0 95 0H5ZM85 59L50 24L15 59H33V75H67.0455V59H85Z" | ||||
|                         /> | ||||
|                     </g> | ||||
|                     <g mask="url(#mask1)"> | ||||
|                         <rect | ||||
|                             class="c-cr__cam-fov-l" | ||||
|                             width="51" | ||||
|                             height="100" | ||||
|                             :style="cameraFOVStyleLeftHalf" | ||||
|  | ||||
|                     <!-- Camera FOV --> | ||||
|                     <g | ||||
|                         class="c-cr__cam-fov" | ||||
|                     > | ||||
|                         <g mask="url(#mask2)"> | ||||
|                             <rect | ||||
|                                 class="c-cr__cam-fov-r" | ||||
|                                 x="49" | ||||
|                                 width="51" | ||||
|                                 height="100" | ||||
|                                 :style="cameraFOVStyleRightHalf" | ||||
|                             /> | ||||
|                         </g> | ||||
|                         <g mask="url(#mask1)"> | ||||
|                             <rect | ||||
|                                 class="c-cr__cam-fov-l" | ||||
|                                 width="51" | ||||
|                                 height="100" | ||||
|                                 :style="cameraFOVStyleLeftHalf" | ||||
|                             /> | ||||
|                         </g> | ||||
|                         <polygon | ||||
|                             class="c-cr__cam" | ||||
|                             points="0,0 100,0 70,40 70,100 30,100 30,40" | ||||
|                         /> | ||||
|                     </g> | ||||
|                     <polygon | ||||
|                         class="c-cr__cam" | ||||
|                         points="0,0 100,0 70,40 70,100 30,100 30,40" | ||||
|                     /> | ||||
|  | ||||
|                 </g> | ||||
|             </g> | ||||
|  | ||||
|             <!-- NSEW and ticks --> | ||||
|             <g | ||||
|                 class="c-cr__nsew" | ||||
|                 :style="compassRoseStyle" | ||||
|                 :style="compassDialStyle" | ||||
|             > | ||||
|                 <g class="c-cr__ticks-major"> | ||||
|                     <path d="M50 3L43 10H57L50 3Z" /> | ||||
| @@ -254,23 +259,32 @@ import { throttle } from 'lodash'; | ||||
|  | ||||
| export default { | ||||
|     props: { | ||||
|         cameraAngleOfView: { | ||||
|             type: Number, | ||||
|             required: true | ||||
|         }, | ||||
|         heading: { | ||||
|             type: Number, | ||||
|             required: true, | ||||
|             default() { | ||||
|                 return 0; | ||||
|             } | ||||
|             required: true | ||||
|         }, | ||||
|         sunHeading: { | ||||
|             type: Number, | ||||
|             default: undefined | ||||
|         }, | ||||
|         cameraPan: { | ||||
|         cameraAzimuth: { | ||||
|             type: Number, | ||||
|             default: undefined | ||||
|         }, | ||||
|         transformations: { | ||||
|             type: Object, | ||||
|             required: true | ||||
|         }, | ||||
|         hasGimble: { | ||||
|             type: Boolean, | ||||
|             required: true | ||||
|         }, | ||||
|         normalizedCameraAzimuth: { | ||||
|             type: Number, | ||||
|             required: true | ||||
|         }, | ||||
|         sunHeading: { | ||||
|             type: Number, | ||||
|             default: undefined | ||||
|         }, | ||||
|         sizedImageDimensions: { | ||||
| @@ -284,18 +298,6 @@ export default { | ||||
|         }; | ||||
|     }, | ||||
|     computed: { | ||||
|         cameraHeading() { | ||||
|             return this.cameraPan ?? this.heading; | ||||
|         }, | ||||
|         cameraAngleOfView() { | ||||
|             const cameraAngleOfView = this.transformations?.cameraAngleOfView; | ||||
|  | ||||
|             if (!cameraAngleOfView) { | ||||
|                 console.warn('No Camera Angle of View provided'); | ||||
|             } | ||||
|  | ||||
|             return cameraAngleOfView; | ||||
|         }, | ||||
|         camAngleAndPositionStyle() { | ||||
|             const translateX = this.transformations?.translateX; | ||||
|             const translateY = this.transformations?.translateY; | ||||
| @@ -304,18 +306,22 @@ export default { | ||||
|  | ||||
|             return { transform: `translate(${translateX}%, ${translateY}%) rotate(${rotation}deg) scale(${scale})` }; | ||||
|         }, | ||||
|         camGimbalAngleStyle() { | ||||
|             const rotation = rotate(this.north, this.heading); | ||||
|         gimbledCameraPanStyle() { | ||||
|             if (!this.hasGimble) { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             const gimbledCameraPan = rotate(this.normalizedCameraAzimuth, -this.heading); | ||||
|  | ||||
|             return { | ||||
|                 transform: `rotate(${ rotation }deg)` | ||||
|                 transform: `rotate(${ -gimbledCameraPan }deg)` | ||||
|             }; | ||||
|         }, | ||||
|         compassRoseStyle() { | ||||
|         compassDialStyle() { | ||||
|             return { transform: `rotate(${ this.north }deg)` }; | ||||
|         }, | ||||
|         north() { | ||||
|             return this.lockCompass ? rotate(-this.cameraHeading) : 0; | ||||
|             return this.lockCompass ? rotate(-this.normalizedCameraAzimuth) : 0; | ||||
|         }, | ||||
|         cardinalTextRotateN() { | ||||
|             return { transform: `translateY(-27%) rotate(${ -this.north }deg)` }; | ||||
| @@ -332,14 +338,6 @@ export default { | ||||
|         hasHeading() { | ||||
|             return this.heading !== undefined; | ||||
|         }, | ||||
|         headingStyle() { | ||||
|             /* Replaced with computed camGimbalStyle, but left here just in case. */ | ||||
|             const rotation = rotate(this.north, this.heading); | ||||
|  | ||||
|             return { | ||||
|                 transform: `rotate(${ rotation }deg)` | ||||
|             }; | ||||
|         }, | ||||
|         hasSunHeading() { | ||||
|             return this.sunHeading !== undefined; | ||||
|         }, | ||||
| @@ -351,7 +349,7 @@ export default { | ||||
|             }; | ||||
|         }, | ||||
|         cameraHeadingStyle() { | ||||
|             const rotation = rotate(this.north, this.cameraHeading); | ||||
|             const rotation = rotate(this.north, this.normalizedCameraAzimuth); | ||||
|  | ||||
|             return { | ||||
|                 transform: `rotate(${ rotation }deg)` | ||||
|   | ||||
| @@ -35,8 +35,15 @@ describe("The Compass component", () => { | ||||
|             roll: 90, | ||||
|             pitch: 90, | ||||
|             cameraTilt: 100, | ||||
|             cameraPan: 90, | ||||
|             sunAngle: 30 | ||||
|             cameraAzimuth: 90, | ||||
|             sunAngle: 30, | ||||
|             transformations: { | ||||
|                 translateX: 0, | ||||
|                 translateY: 18, | ||||
|                 rotation: 0, | ||||
|                 scale: 0.3, | ||||
|                 cameraAngleOfView: 70 | ||||
|             } | ||||
|         }; | ||||
|         let propsData = { | ||||
|             naturalAspectRatio: 0.9, | ||||
| @@ -44,8 +51,7 @@ describe("The Compass component", () => { | ||||
|             sizedImageDimensions: { | ||||
|                 width: 100, | ||||
|                 height: 100 | ||||
|             }, | ||||
|             compassRoseSizingClasses: '--rose-small --rose-min' | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         app = new Vue({ | ||||
| @@ -54,7 +60,6 @@ describe("The Compass component", () => { | ||||
|                 return propsData; | ||||
|             }, | ||||
|             template: `<Compass | ||||
|                 :compass-rose-sizing-classes="compassRoseSizingClasses" | ||||
|                 :image="image" | ||||
|                 :natural-aspect-ratio="naturalAspectRatio" | ||||
|                 :sized-image-dimensions="sizedImageDimensions" | ||||
| @@ -67,7 +72,7 @@ describe("The Compass component", () => { | ||||
|         app.$destroy(); | ||||
|     }); | ||||
|  | ||||
|     describe("when a heading value exists on the image", () => { | ||||
|     describe("when a heading value and cameraAngleOfView exists on the image", () => { | ||||
|  | ||||
|         it("should display a compass rose", () => { | ||||
|             let compassRoseElement = instance.$el.querySelector(COMPASS_ROSE_CLASS | ||||
|   | ||||
| @@ -94,7 +94,6 @@ | ||||
|                 <Compass | ||||
|                     v-if="shouldDisplayCompass" | ||||
|                     :image="focusedImage" | ||||
|                     :natural-aspect-ratio="focusedImageNaturalAspectRatio" | ||||
|                     :sized-image-dimensions="sizedImageDimensions" | ||||
|                 /> | ||||
|             </div> | ||||
| @@ -171,7 +170,7 @@ | ||||
|         > | ||||
|             <ImageThumbnail | ||||
|                 v-for="(image, index) in imageHistory" | ||||
|                 :key="`${image.thumbnailUrl || image.url}${image.time}`" | ||||
|                 :key="`${image.thumbnailUrl || image.url}-${image.time}-${index}`" | ||||
|                 :image="image" | ||||
|                 :active="focusedImageIndex === index" | ||||
|                 :selected="focusedImageIndex === index && isPaused" | ||||
| @@ -430,9 +429,12 @@ export default { | ||||
|                 && imageHeightAndWidth | ||||
|                 && this.zoomFactor === 1 | ||||
|                 && this.imagePanned !== true; | ||||
|             const hasCameraConfigurations = this.focusedImage?.transformations !== undefined; | ||||
|             const hasHeading = this.focusedImage?.heading !== undefined; | ||||
|             const hasCameraAngleOfView = this.focusedImage?.transformations?.cameraAngleOfView > 0; | ||||
|  | ||||
|             return display && hasCameraConfigurations; | ||||
|             return display | ||||
|                 && hasCameraAngleOfView | ||||
|                 && hasHeading; | ||||
|         }, | ||||
|         isSpacecraftPositionFresh() { | ||||
|             let isFresh = undefined; | ||||
| @@ -582,11 +584,34 @@ export default { | ||||
|             }, | ||||
|             deep: true | ||||
|         }, | ||||
|         focusedImageIndex() { | ||||
|             this.trackDuration(); | ||||
|             this.resetAgeCSS(); | ||||
|             this.updateRelatedTelemetryForFocusedImage(); | ||||
|             this.getImageNaturalDimensions(); | ||||
|         focusedImage: { | ||||
|             handler(newImage, oldImage) { | ||||
|                 const newTime = newImage?.time; | ||||
|                 const oldTime = oldImage?.time; | ||||
|                 const newUrl = newImage?.url; | ||||
|                 const oldUrl = oldImage?.url; | ||||
|  | ||||
|                 // Skip if it's all falsy | ||||
|                 if (!newTime && !oldTime && !newUrl && !oldUrl) { | ||||
|                     return; | ||||
|                 } | ||||
|  | ||||
|                 // Skip if it's the same image | ||||
|                 if (newTime === oldTime && newUrl === oldUrl) { | ||||
|                     return; | ||||
|                 } | ||||
|  | ||||
|                 // Update image duration and reset age CSS | ||||
|                 this.trackDuration(); | ||||
|                 this.resetAgeCSS(); | ||||
|  | ||||
|                 // Reset image dimensions and calculate new dimensions | ||||
|                 // on new image load | ||||
|                 this.getImageNaturalDimensions(); | ||||
|  | ||||
|                 // Get the related telemetry for the new image | ||||
|                 this.updateRelatedTelemetryForFocusedImage(); | ||||
|             } | ||||
|         }, | ||||
|         bounds() { | ||||
|             this.scrollHandler(); | ||||
| @@ -771,6 +796,10 @@ export default { | ||||
|             this.layers = layersMetadata; | ||||
|             if (this.domainObject.configuration) { | ||||
|                 const persistedLayers = this.domainObject.configuration.layers; | ||||
|                 if (!persistedLayers) { | ||||
|                     return; | ||||
|                 } | ||||
|  | ||||
|                 layersMetadata.forEach((layer) => { | ||||
|                     const persistedLayer = persistedLayers.find(object => object.name === layer.name); | ||||
|                     if (persistedLayer) { | ||||
|   | ||||
| @@ -153,9 +153,6 @@ export default { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             // forcibly reset the imageContainer size to prevent an aspect ratio distortion | ||||
|             delete this.imageContainerWidth; | ||||
|             delete this.imageContainerHeight; | ||||
|             this.bounds = bounds; // setting bounds for ImageryView watcher | ||||
|         }, | ||||
|         timeSystemChange() { | ||||
|   | ||||
| @@ -84,6 +84,8 @@ export default class MoveAction { | ||||
|         this.addToNewParent(this.object, parent); | ||||
|         this.removeFromOldParent(this.object); | ||||
|  | ||||
|         await this.saveTransaction(); | ||||
|  | ||||
|         if (!inNavigationPath) { | ||||
|             return; | ||||
|         } | ||||
| @@ -102,8 +104,6 @@ export default class MoveAction { | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         await this.saveTransaction(); | ||||
|  | ||||
|         this.navigateTo(newObjectPath); | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -381,7 +381,7 @@ export default { | ||||
|             }); | ||||
|         }, | ||||
|         updateSelection(selection) { | ||||
|             if (selection?.[0]?.[1]?.context?.targetDetails?.entryId === undefined) { | ||||
|             if (selection?.[0]?.[0]?.context?.targetDetails?.entryId === undefined) { | ||||
|                 this.selectedEntryId = ''; | ||||
|             } | ||||
|         }, | ||||
|   | ||||
| @@ -77,13 +77,13 @@ | ||||
|                     aria-label="Notebook Entry Input" | ||||
|                     tabindex="0" | ||||
|                     :contenteditable="canEdit" | ||||
|                     v-bind.prop="formattedText" | ||||
|                     @mouseover="checkEditability($event)" | ||||
|                     @mouseleave="canEdit = true" | ||||
|                     @focus="editingEntry()" | ||||
|                     @blur="updateEntryValue($event)" | ||||
|                     @keydown.enter.exact.prevent | ||||
|                     @keyup.enter.exact.prevent="forceBlur($event)" | ||||
|                     v-html="formattedText" | ||||
|                 > | ||||
|                 </div> | ||||
|             </template> | ||||
| @@ -99,7 +99,7 @@ | ||||
|                 </div> | ||||
|             </template> | ||||
|  | ||||
|             <div> | ||||
|             <div class="c-ne__tags c-tag-holder"> | ||||
|                 <div | ||||
|                     v-for="(tag, index) in entryTags" | ||||
|                     :key="index" | ||||
| @@ -235,7 +235,8 @@ export default { | ||||
|         return { | ||||
|             editMode: false, | ||||
|             canEdit: true, | ||||
|             enableEmbedsWrapperScroll: false | ||||
|             enableEmbedsWrapperScroll: false, | ||||
|             urlWhitelist: null | ||||
|         }; | ||||
|     }, | ||||
|     computed: { | ||||
| @@ -250,7 +251,7 @@ export default { | ||||
|             let text = sanitizeHtml(this.entry.text, SANITIZATION_SCHEMA); | ||||
|  | ||||
|             if (this.editMode || !this.urlWhitelist) { | ||||
|                 return text; | ||||
|                 return { innerText: text }; | ||||
|             } | ||||
|  | ||||
|             text = text.replace(URL_REGEX, (match) => { | ||||
| @@ -268,7 +269,7 @@ export default { | ||||
|                 return result; | ||||
|             }); | ||||
|  | ||||
|             return text; | ||||
|             return { innerHTML: text }; | ||||
|         }, | ||||
|         isSelectedEntry() { | ||||
|             return this.selectedEntryId === this.entry.id; | ||||
| @@ -456,7 +457,7 @@ export default { | ||||
|             this.editMode = false; | ||||
|             const value = $event.target.innerText; | ||||
|             if (value !== this.entry.text && value.match(/\S/)) { | ||||
|                 this.entry.text = value; | ||||
|                 this.entry.text = sanitizeHtml(value, SANITIZATION_SCHEMA); | ||||
|                 this.timestampAndUpdate(); | ||||
|             } else { | ||||
|                 this.$emit('cancelEdit'); | ||||
| @@ -472,16 +473,11 @@ export default { | ||||
|             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', | ||||
|                             item: this.domainObject, | ||||
|                             targetDetails, | ||||
|                             targetDomainObjects, | ||||
|                             annotations: this.notebookAnnotations, | ||||
|   | ||||
| @@ -6,8 +6,7 @@ export const NOTEBOOK_DEFAULT = 'DEFAULT'; | ||||
| export const NOTEBOOK_SNAPSHOT = 'SNAPSHOT'; | ||||
| export const NOTEBOOK_VIEW_TYPE = 'notebook-vue'; | ||||
| export const RESTRICTED_NOTEBOOK_VIEW_TYPE = 'restricted-notebook-vue'; | ||||
| export const NOTEBOOK_INSTALLED_KEY = '_NOTEBOOK_PLUGIN_INSTALLED'; | ||||
| export const RESTRICTED_NOTEBOOK_INSTALLED_KEY = '_RESTRICTED_NOTEBOOK_PLUGIN_INSTALLED'; | ||||
| export const NOTEBOOK_BASE_INSTALLED = '_NOTEBOOK_BASE_FUNCTIONALITY_INSTALLED'; | ||||
|  | ||||
| // these only deals with constants, figured this could skip going into a utils file | ||||
| export function isNotebookOrAnnotationType(domainObject) { | ||||
|   | ||||
| @@ -33,8 +33,7 @@ import { | ||||
|     RESTRICTED_NOTEBOOK_TYPE, | ||||
|     NOTEBOOK_VIEW_TYPE, | ||||
|     RESTRICTED_NOTEBOOK_VIEW_TYPE, | ||||
|     NOTEBOOK_INSTALLED_KEY, | ||||
|     RESTRICTED_NOTEBOOK_INSTALLED_KEY | ||||
|     NOTEBOOK_BASE_INSTALLED | ||||
| } from './notebook-constants'; | ||||
|  | ||||
| import Vue from 'vue'; | ||||
| @@ -63,7 +62,7 @@ function addLegacyNotebookGetInterceptor(openmct) { | ||||
|  | ||||
| function installBaseNotebookFunctionality(openmct) { | ||||
|     // only need to do this once | ||||
|     if (openmct[NOTEBOOK_INSTALLED_KEY] || openmct[RESTRICTED_NOTEBOOK_INSTALLED_KEY]) { | ||||
|     if (openmct[NOTEBOOK_BASE_INSTALLED]) { | ||||
|         return; | ||||
|     } | ||||
|  | ||||
| @@ -101,14 +100,12 @@ function installBaseNotebookFunctionality(openmct) { | ||||
|     openmct.indicators.add(indicator); | ||||
|  | ||||
|     monkeyPatchObjectAPIForNotebooks(openmct); | ||||
|  | ||||
|     openmct[NOTEBOOK_BASE_INSTALLED] = true; | ||||
| } | ||||
|  | ||||
| function NotebookPlugin(name = 'Notebook', entryUrlWhitelist = []) { | ||||
|     return function install(openmct) { | ||||
|         if (openmct[NOTEBOOK_INSTALLED_KEY]) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         const icon = 'icon-notebook'; | ||||
|         const description = 'Create and save timestamped notes with embedded object snapshots.'; | ||||
|         const snapshotContainer = getSnapshotContainer(openmct); | ||||
| @@ -122,17 +119,11 @@ function NotebookPlugin(name = 'Notebook', entryUrlWhitelist = []) { | ||||
|         openmct.objectViews.addProvider(notebookView, entryUrlWhitelist); | ||||
|  | ||||
|         installBaseNotebookFunctionality(openmct); | ||||
|  | ||||
|         openmct[NOTEBOOK_INSTALLED_KEY] = true; | ||||
|     }; | ||||
| } | ||||
|  | ||||
| function RestrictedNotebookPlugin(name = 'Notebook Shift Log', entryUrlWhitelist = []) { | ||||
|     return function install(openmct) { | ||||
|         if (openmct[RESTRICTED_NOTEBOOK_INSTALLED_KEY]) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         const icon = 'icon-notebook-shift-log'; | ||||
|         const description = 'Create and save timestamped notes with embedded object snapshots with the ability to commit and lock pages.'; | ||||
|         const snapshotContainer = getSnapshotContainer(openmct); | ||||
| @@ -144,8 +135,6 @@ function RestrictedNotebookPlugin(name = 'Notebook Shift Log', entryUrlWhitelist | ||||
|         openmct.objectViews.addProvider(notebookView, entryUrlWhitelist); | ||||
|  | ||||
|         installBaseNotebookFunctionality(openmct); | ||||
|  | ||||
|         openmct[RESTRICTED_NOTEBOOK_INSTALLED_KEY] = true; | ||||
|     }; | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -23,16 +23,8 @@ | ||||
| <div | ||||
|     v-if="loaded" | ||||
|     class="gl-plot" | ||||
|     :class="[plotLegendExpandedStateClass, plotLegendPositionClass]" | ||||
| > | ||||
|     <plot-legend | ||||
|         v-if="!isNestedWithinAStackedPlot" | ||||
|         :cursor-locked="!!lockHighlightPoint" | ||||
|         :series="seriesModels" | ||||
|         :highlights="highlights" | ||||
|         :legend="legend" | ||||
|         @legendHoverChanged="legendHoverChanged" | ||||
|     /> | ||||
|     <slot></slot> | ||||
|     <div class="plot-wrapper-axis-and-display-area flex-elem grows"> | ||||
|         <div | ||||
|             v-if="seriesModels.length" | ||||
| @@ -42,13 +34,14 @@ | ||||
|                 v-for="(yAxis, index) in yAxesIds" | ||||
|                 :id="yAxis.id" | ||||
|                 :key="`yAxis-${yAxis.id}-${index}`" | ||||
|                 :multiple-left-axes="multipleLeftAxes" | ||||
|                 :has-multiple-left-axes="hasMultipleLeftAxes" | ||||
|                 :position="yAxis.id > 2 ? 'right' : 'left'" | ||||
|                 :class="{'plot-yaxis-right': yAxis.id > 2}" | ||||
|                 :tick-width="yAxis.tickWidth" | ||||
|                 :used-tick-width="plotFirstLeftTickWidth" | ||||
|                 :plot-left-tick-width="yAxis.id > 2 ? yAxis.tickWidth: plotLeftTickWidth" | ||||
|                 @yKeyChanged="setYAxisKey" | ||||
|                 @tickWidthChanged="onTickWidthChange" | ||||
|                 @plotYTickWidth="onYTickWidthChange" | ||||
|                 @toggleAxisVisibility="toggleSeriesForYAxis" | ||||
|             /> | ||||
|         </div> | ||||
| @@ -69,7 +62,6 @@ | ||||
|                     v-show="gridLines && !options.compact" | ||||
|                     :axis-type="'xAxis'" | ||||
|                     :position="'right'" | ||||
|                     @plotTickWidth="onTickWidthChange" | ||||
|                 /> | ||||
|  | ||||
|                 <mct-ticks | ||||
| @@ -79,7 +71,7 @@ | ||||
|                     :axis-type="'yAxis'" | ||||
|                     :position="'bottom'" | ||||
|                     :axis-id="yAxis.id" | ||||
|                     @plotTickWidth="onTickWidthChange" | ||||
|                     @plotTickWidth="onYTickWidthChange" | ||||
|                 /> | ||||
|  | ||||
|                 <div | ||||
| @@ -94,7 +86,6 @@ | ||||
|                         :highlights="highlights" | ||||
|                         :annotated-points="annotatedPoints" | ||||
|                         :annotation-selections="annotationSelections" | ||||
|                         :show-limit-line-labels="showLimitLineLabels" | ||||
|                         :hidden-y-axis-ids="hiddenYAxisIds" | ||||
|                         :annotation-viewing-and-editing-allowed="annotationViewingAndEditingAllowed" | ||||
|                         @plotReinitializeCanvas="initCanvas" | ||||
| @@ -217,7 +208,6 @@ import LinearScale from "./LinearScale"; | ||||
| import PlotConfigurationModel from './configuration/PlotConfigurationModel'; | ||||
| import configStore from './configuration/ConfigStore'; | ||||
|  | ||||
| import PlotLegend from "./legend/PlotLegend.vue"; | ||||
| import MctTicks from "./MctTicks.vue"; | ||||
| import MctChart from "./chart/MctChart.vue"; | ||||
| import XAxis from "./axis/XAxis.vue"; | ||||
| @@ -232,7 +222,6 @@ export default { | ||||
|     components: { | ||||
|         XAxis, | ||||
|         YAxis, | ||||
|         PlotLegend, | ||||
|         MctTicks, | ||||
|         MctChart | ||||
|     }, | ||||
| @@ -258,10 +247,14 @@ export default { | ||||
|                 return false; | ||||
|             } | ||||
|         }, | ||||
|         plotTickWidth: { | ||||
|             type: Number, | ||||
|         parentYTickWidth: { | ||||
|             type: Object, | ||||
|             default() { | ||||
|                 return 0; | ||||
|                 return { | ||||
|                     leftTickWidth: 0, | ||||
|                     rightTickWidth: 0, | ||||
|                     hasMultipleLeftAxes: false | ||||
|                 }; | ||||
|             } | ||||
|         }, | ||||
|         limitLineLabels: { | ||||
| @@ -296,7 +289,6 @@ export default { | ||||
|             isRealTime: this.openmct.time.clock() !== undefined, | ||||
|             loaded: false, | ||||
|             isTimeOutOfSync: false, | ||||
|             showLimitLineLabels: this.limitLineLabels, | ||||
|             isFrozenOnMouseDown: false, | ||||
|             cursorGuide: this.initCursorGuide, | ||||
|             gridLines: this.initGridLines, | ||||
| @@ -308,13 +300,14 @@ export default { | ||||
|     computed: { | ||||
|         xAxisStyle() { | ||||
|             const rightAxis = this.yAxesIds.find(yAxis => yAxis.id > 2); | ||||
|             const leftOffset = this.multipleLeftAxes ? 2 * AXES_PADDING : AXES_PADDING; | ||||
|             const leftOffset = this.hasMultipleLeftAxes ? 2 * AXES_PADDING : AXES_PADDING; | ||||
|             let style = { | ||||
|                 left: `${this.plotLeftTickWidth + leftOffset}px` | ||||
|             }; | ||||
|             const parentRightAxisWidth = this.parentYTickWidth.rightTickWidth; | ||||
|  | ||||
|             if (rightAxis) { | ||||
|                 style.right = `${rightAxis.tickWidth + AXES_PADDING}px`; | ||||
|             if (parentRightAxisWidth || rightAxis) { | ||||
|                 style.right = `${(parentRightAxisWidth || rightAxis.tickWidth) + AXES_PADDING}px`; | ||||
|             } | ||||
|  | ||||
|             return style; | ||||
| @@ -322,8 +315,8 @@ export default { | ||||
|         yAxesIds() { | ||||
|             return this.yAxes.filter(yAxis => yAxis.seriesCount > 0); | ||||
|         }, | ||||
|         multipleLeftAxes() { | ||||
|             return this.yAxes.filter(yAxis => yAxis.seriesCount > 0 && yAxis.id <= 2).length > 1; | ||||
|         hasMultipleLeftAxes() { | ||||
|             return this.parentYTickWidth.hasMultipleLeftAxes || this.yAxes.filter(yAxis => yAxis.seriesCount > 0 && yAxis.id <= 2).length > 1; | ||||
|         }, | ||||
|         isNestedWithinAStackedPlot() { | ||||
|             const isNavigatedObject = this.openmct.router.isNavigatedObject([this.domainObject].concat(this.path)); | ||||
| @@ -334,22 +327,13 @@ export default { | ||||
|             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 | ||||
|         // 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')}` : ''; | ||||
|         }, | ||||
|         plotLegendExpandedStateClass() { | ||||
|             if (this.isNestedWithinAStackedPlot) { | ||||
|                 return ''; | ||||
|             } | ||||
|         plotFirstLeftTickWidth() { | ||||
|             const firstYAxis = this.yAxes.find(yAxis => yAxis.id === 1); | ||||
|  | ||||
|             if (this.config.legend.get('expanded')) { | ||||
|                 return 'plot-legend-expanded'; | ||||
|             } else { | ||||
|                 return 'plot-legend-collapsed'; | ||||
|             } | ||||
|             return firstYAxis ? firstYAxis.tickWidth : 0; | ||||
|         }, | ||||
|         plotLeftTickWidth() { | ||||
|             let leftTickWidth = 0; | ||||
| @@ -360,17 +344,12 @@ export default { | ||||
|  | ||||
|                 leftTickWidth = leftTickWidth + yAxis.tickWidth; | ||||
|             }); | ||||
|             const parentLeftTickWidth = this.parentYTickWidth.leftTickWidth; | ||||
|  | ||||
|             return this.plotTickWidth || leftTickWidth; | ||||
|             return parentLeftTickWidth || leftTickWidth; | ||||
|         } | ||||
|     }, | ||||
|     watch: { | ||||
|         limitLineLabels: { | ||||
|             handler(limitLineLabels) { | ||||
|                 this.legendHoverChanged(limitLineLabels); | ||||
|             }, | ||||
|             deep: true | ||||
|         }, | ||||
|         initGridLines(newGridLines) { | ||||
|             this.gridLines = newGridLines; | ||||
|         }, | ||||
| @@ -406,8 +385,7 @@ export default { | ||||
|             })); | ||||
|         } | ||||
|  | ||||
|         const configId = this.openmct.objects.makeKeyString(this.domainObject.identifier); | ||||
|         this.$emit('configLoaded', configId); | ||||
|         this.$emit('configLoaded', true); | ||||
|  | ||||
|         this.listenTo(this.config.series, 'add', this.addSeries, this); | ||||
|         this.listenTo(this.config.series, 'remove', this.removeSeries, this); | ||||
| @@ -439,15 +417,20 @@ export default { | ||||
|     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 | ||||
|             // on clicking on a search result we highlight the annotation and zoom - we know it's an annotation result when isAnnotationSearchResult === true | ||||
|             // We shouldn't zoom when we're selecting existing annotations to view them or creating new annotations. | ||||
|             const selectionType = selection?.[0]?.[0]?.context?.type; | ||||
|             const validSelectionTypes = ['clicked-on-plot-selection', 'plot-annotation-search-result']; | ||||
|             const isAnnotationSearchResult = selectionType === 'plot-annotation-search-result'; | ||||
|  | ||||
|             if (!validSelectionTypes.includes(selectionType)) { | ||||
|                 // wrong type of selection | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             const selectionType = selection?.[0]?.[1]?.context?.type; | ||||
|             if (selectionType !== 'plot-points-selection') { | ||||
|                 // wrong type of selection | ||||
|             if (selectionContext | ||||
|                 && (!isAnnotationSearchResult) | ||||
|                 && this.openmct.objects.areIdsEqual(selectionContext.identifier, this.domainObject.identifier)) { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
| @@ -460,7 +443,18 @@ export default { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             const selectedAnnotations = selection?.[0]?.[1]?.context?.annotations; | ||||
|             const selectedAnnotations = selection?.[0]?.[0]?.context?.annotations; | ||||
|             //This section is only for the annotations search results entry to displaying annotations | ||||
|             if (isAnnotationSearchResult) { | ||||
|                 this.showAnnotationsFromSearchResults(selectedAnnotations); | ||||
|             } | ||||
|  | ||||
|             //This section is common to all entry points for annotation display | ||||
|             this.prepareExistingAnnotationSelection(selectedAnnotations); | ||||
|         }, | ||||
|         showAnnotationsFromSearchResults(selectedAnnotations) { | ||||
|         //Start section | ||||
|  | ||||
|             if (selectedAnnotations?.length) { | ||||
|                 // just use first annotation | ||||
|                 const boundingBoxes = Object.values(selectedAnnotations[0].targets); | ||||
| @@ -494,10 +488,9 @@ export default { | ||||
|                     min: minY, | ||||
|                     max: maxY | ||||
|                 }); | ||||
|                 //Zoom out just a touch so that the highlighted section for annotations doesn't take over the whole view - which is not a nice look. | ||||
|                 this.zoom('out', 0.2); | ||||
|             } | ||||
|  | ||||
|             this.prepareExistingAnnotationSelection(selectedAnnotations); | ||||
|         }, | ||||
|         handleKeyDown(event) { | ||||
|             if (event.key === 'Alt') { | ||||
| @@ -575,6 +568,14 @@ export default { | ||||
|         updateTicksAndSeriesForYAxis(newAxisId, oldAxisId) { | ||||
|             this.updateAxisUsageCount(oldAxisId, -1); | ||||
|             this.updateAxisUsageCount(newAxisId, 1); | ||||
|  | ||||
|             const foundYAxis = this.yAxes.find(yAxis => yAxis.id === oldAxisId); | ||||
|             if (foundYAxis.seriesCount === 0) { | ||||
|                 this.onYTickWidthChange({ | ||||
|                     width: foundYAxis.tickWidth, | ||||
|                     yAxisId: foundYAxis.id | ||||
|                 }); | ||||
|             } | ||||
|         }, | ||||
|  | ||||
|         updateAxisUsageCount(yAxisId, updateCountBy) { | ||||
| @@ -688,9 +689,15 @@ export default { | ||||
|                 series.reset(); | ||||
|             }); | ||||
|         }, | ||||
|         shareCommonParent(domainObjectToFind) { | ||||
|             return false; | ||||
|         }, | ||||
|         compositionPathContainsId(domainObjectToFind) { | ||||
|             if (!domainObjectToFind.composition) { | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|         compositionPathContainsId(domainObjectToClear) { | ||||
|             return domainObjectToClear.composition.some((compositionIdentifier) => { | ||||
|             return domainObjectToFind.composition.some((compositionIdentifier) => { | ||||
|                 return this.openmct.objects.areIdsEqual(compositionIdentifier, this.domainObject.identifier); | ||||
|             }); | ||||
|         }, | ||||
| @@ -820,27 +827,35 @@ export default { | ||||
|  | ||||
|         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); | ||||
|                 Object.keys(annotationToSelect.targets).forEach(targetKeyString => { | ||||
|                     const target = annotationToSelect.targets[targetKeyString]; | ||||
|                     const series = this.seriesModels.find(seriesModel => seriesModel.keyString === targetKeyString); | ||||
|                     if (!series) { | ||||
|                         return; | ||||
|                     } | ||||
|  | ||||
|                     const yAxisId = series.get('yAxisId'); | ||||
|                     const rectangle = { | ||||
|                         start: { | ||||
|                             x: target.minX, | ||||
|                             y: [target.minY], | ||||
|                             yAxisIds: [yAxisId] | ||||
|                         }, | ||||
|                         end: { | ||||
|                             x: target.maxX, | ||||
|                             y: [target.maxY], | ||||
|                             yAxisIds: [yAxisId] | ||||
|                         }, | ||||
|                         color: [1, 1, 1, 0.10] | ||||
|                     }; | ||||
|                     this.rectangles.push(rectangle); | ||||
|                 }); | ||||
|             }); | ||||
|         }, | ||||
|         gatherNearbyAnnotations() { | ||||
|             const nearbyAnnotations = []; | ||||
|             this.config.series.models.forEach(series => { | ||||
|                 if (series.closest.annotationsById) { | ||||
|                 if (series?.closest?.annotationsById) { | ||||
|                     Object.values(series.closest.annotationsById).forEach(closeAnnotation => { | ||||
|                         const addedAnnotationAlready = nearbyAnnotations.some(annotation => { | ||||
|                             return _.isEqual(annotation.targets, closeAnnotation.targets) | ||||
| @@ -938,8 +953,13 @@ export default { | ||||
|             } | ||||
|         }, | ||||
|  | ||||
|         onTickWidthChange(data, fromDifferentObject) { | ||||
|             const {width, yAxisId} = data; | ||||
|         /** | ||||
|        * Aggregate widths of all left and right y axes and send them up to any parent plots | ||||
|        * @param {Object} tickWidthWithYAxisId - the width and yAxisId of the tick bar | ||||
|        * @param fromDifferentObject | ||||
|        */ | ||||
|         onYTickWidthChange(tickWidthWithYAxisId, fromDifferentObject) { | ||||
|             const {width, yAxisId} = tickWidthWithYAxisId; | ||||
|             if (yAxisId) { | ||||
|                 const index = this.yAxes.findIndex(yAxis => yAxis.id === yAxisId); | ||||
|                 if (fromDifferentObject) { | ||||
| @@ -948,13 +968,23 @@ export default { | ||||
|                 } else { | ||||
|                 // Otherwise, only accept tick with if it's larger. | ||||
|                     const newWidth = Math.max(width, this.yAxes[index].tickWidth); | ||||
|                     if (newWidth !== this.yAxes[index].tickWidth) { | ||||
|                     if (width !== this.yAxes[index].tickWidth) { | ||||
|                         this.yAxes[index].tickWidth = newWidth; | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 const id = this.openmct.objects.makeKeyString(this.domainObject.identifier); | ||||
|                 this.$emit('plotTickWidth', this.yAxes[index].tickWidth, id); | ||||
|                 const leftTickWidth = this.yAxes.filter(yAxis => yAxis.id < 3).reduce((previous, current) => { | ||||
|                     return previous + current.tickWidth; | ||||
|                 }, 0); | ||||
|                 const rightTickWidth = this.yAxes.filter(yAxis => yAxis.id > 2).reduce((previous, current) => { | ||||
|                     return previous + current.tickWidth; | ||||
|                 }, 0); | ||||
|                 this.$emit('plotYTickWidth', { | ||||
|                     hasMultipleLeftAxes: this.hasMultipleLeftAxes, | ||||
|                     leftTickWidth, | ||||
|                     rightTickWidth | ||||
|                 }, id); | ||||
|             } | ||||
|         }, | ||||
|  | ||||
| @@ -1036,8 +1066,6 @@ export default { | ||||
|  | ||||
|         highlightValues(point) { | ||||
|             this.highlightPoint = point; | ||||
|             // TODO: used in StackedPlotController | ||||
|             this.$emit('plotHighlightUpdate', point); | ||||
|             if (this.lockHighlightPoint) { | ||||
|                 return; | ||||
|             } | ||||
| @@ -1149,7 +1177,7 @@ export default { | ||||
|                     endPixels: this.positionOverElement, | ||||
|                     start: this.positionOverPlot, | ||||
|                     end: this.positionOverPlot, | ||||
|                     color: [1, 1, 1, 0.5] | ||||
|                     color: [1, 1, 1, 0.25] | ||||
|                 }; | ||||
|                 if (annotationEvent) { | ||||
|                     this.marquee.annotationEvent = true; | ||||
| @@ -1160,57 +1188,92 @@ export default { | ||||
|             } | ||||
|         }, | ||||
|         selectNearbyAnnotations(event) { | ||||
|             // need to stop propagation right away to prevent selecting the plot itself | ||||
|             event.stopPropagation(); | ||||
|  | ||||
|             if (!this.annotationViewingAndEditingAllowed || this.annotationSelections.length) { | ||||
|             const nearbyAnnotations = this.gatherNearbyAnnotations(); | ||||
|  | ||||
|             if (this.annotationViewingAndEditingAllowed && this.annotationSelections.length) { | ||||
|                 //no annotations were found, but we are adding some now | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             const nearbyAnnotations = this.gatherNearbyAnnotations(); | ||||
|             const { targetDomainObjects, targetDetails } = this.prepareExistingAnnotationSelection(nearbyAnnotations); | ||||
|             this.selectPlotAnnotations({ | ||||
|                 targetDetails, | ||||
|                 targetDomainObjects, | ||||
|                 annotations: nearbyAnnotations | ||||
|             }); | ||||
|             if (this.annotationViewingAndEditingAllowed && nearbyAnnotations.length) { | ||||
|                 //show annotations if some were found | ||||
|                 const { targetDomainObjects, targetDetails } = this.prepareExistingAnnotationSelection(nearbyAnnotations); | ||||
|                 this.selectPlotAnnotations({ | ||||
|                     targetDetails, | ||||
|                     targetDomainObjects, | ||||
|                     annotations: nearbyAnnotations | ||||
|                 }); | ||||
|  | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             //Fall through to here if either there is no new selection add tags or no existing annotations were retrieved | ||||
|             this.selectPlot(); | ||||
|         }, | ||||
|         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 | ||||
|                             } | ||||
|                         } | ||||
|                     ]; | ||||
|         selectPlot() { | ||||
|             // should show plot itself if we didn't find any annotations | ||||
|             const selection = this.createPathSelection(); | ||||
|             this.openmct.selection.select(selection, true); | ||||
|         }, | ||||
|         selectNewPlotAnnotations(minX, minY, maxX, maxY, pointsInBox, event) { | ||||
|             const boundingBox = { | ||||
|                 minX, | ||||
|                 minY, | ||||
|                 maxX, | ||||
|                 maxY | ||||
|         createPathSelection() { | ||||
|             let selection = []; | ||||
|             selection.unshift({ | ||||
|                 element: this.$el, | ||||
|                 context: { | ||||
|                     item: this.domainObject | ||||
|                 } | ||||
|             }); | ||||
|             this.path.forEach((pathObject, index) => { | ||||
|                 selection.push({ | ||||
|                     element: this.openmct.layout.$refs.browseObject.$el, | ||||
|                     context: { | ||||
|                         item: pathObject | ||||
|                     } | ||||
|                 }); | ||||
|             }); | ||||
|  | ||||
|             return selection; | ||||
|         }, | ||||
|         selectPlotAnnotations({targetDetails, targetDomainObjects, annotations}) { | ||||
|             const annotationContext = { | ||||
|                 type: 'clicked-on-plot-selection', | ||||
|                 targetDetails, | ||||
|                 targetDomainObjects, | ||||
|                 annotations, | ||||
|                 annotationType: this.openmct.annotation.ANNOTATION_TYPES.PLOT_SPATIAL, | ||||
|                 onAnnotationChange: this.onAnnotationChange | ||||
|             }; | ||||
|             const selection = this.createPathSelection(); | ||||
|             if (selection.length && this.openmct.objects.areIdsEqual(selection[0].context.item.identifier, this.domainObject.identifier)) { | ||||
|                 selection[0].context = { | ||||
|                     ...selection[0].context, | ||||
|                     ...annotationContext | ||||
|                 }; | ||||
|             } else { | ||||
|                 selection.unshift({ | ||||
|                     element: this.$el, | ||||
|                     context: { | ||||
|                         item: this.domainObject, | ||||
|                         ...annotationContext | ||||
|                     } | ||||
|                 }); | ||||
|             } | ||||
|  | ||||
|             this.openmct.selection.select(selection, true); | ||||
|         }, | ||||
|         selectNewPlotAnnotations(boundingBoxPerYAxis, pointsInBox, event) { | ||||
|             let targetDomainObjects = {}; | ||||
|             let targetDetails = {}; | ||||
|             let annotations = {}; | ||||
|             let annotations = []; | ||||
|             pointsInBox.forEach(pointInBox => { | ||||
|                 if (pointInBox.length) { | ||||
|                     const seriesID = pointInBox[0].series.keyString; | ||||
|                     targetDetails[seriesID] = boundingBox; | ||||
|                     const boundingBoxWithId = boundingBoxPerYAxis.find(box => box.id === pointInBox[0].series.get('yAxisId')); | ||||
|                     targetDetails[seriesID] = boundingBoxWithId?.boundingBox; | ||||
|  | ||||
|                     targetDomainObjects[seriesID] = pointInBox[0].series.domainObject; | ||||
|                 } | ||||
|             }); | ||||
| @@ -1225,10 +1288,23 @@ export default { | ||||
|             rawAnnotations.forEach(rawAnnotation => { | ||||
|                 if (rawAnnotation.targets) { | ||||
|                     const targetValues = Object.values(rawAnnotation.targets); | ||||
|                     const targetKeys = Object.keys(rawAnnotation.targets); | ||||
|                     if (targetValues && targetValues.length) { | ||||
|                         // just get the first one | ||||
|                         const boundingBox = Object.values(targetValues)?.[0]; | ||||
|                         const pointsInBox = this.getPointsInBox(boundingBox, rawAnnotation); | ||||
|                         let boundingBoxPerYAxis = []; | ||||
|                         targetValues.forEach((boundingBox, index) => { | ||||
|                             const seriesId = targetKeys[index]; | ||||
|                             const series = this.seriesModels.find(seriesModel => seriesModel.keyString === seriesId); | ||||
|                             if (!series) { | ||||
|                                 return; | ||||
|                             } | ||||
|  | ||||
|                             boundingBoxPerYAxis.push({ | ||||
|                                 id: series.get('yAxisId'), | ||||
|                                 boundingBox | ||||
|                             }); | ||||
|                         }); | ||||
|  | ||||
|                         const pointsInBox = this.getPointsInBox(boundingBoxPerYAxis, rawAnnotation); | ||||
|                         if (pointsInBox && pointsInBox.length) { | ||||
|                             annotationsByPoints.push(pointsInBox.flat()); | ||||
|                         } | ||||
| @@ -1238,10 +1314,17 @@ export default { | ||||
|  | ||||
|             return annotationsByPoints.flat(); | ||||
|         }, | ||||
|         getPointsInBox(boundingBox, rawAnnotation) { | ||||
|         getPointsInBox(boundingBoxPerYAxis, rawAnnotation) { | ||||
|             // load series models in KD-Trees | ||||
|             const seriesKDTrees = []; | ||||
|             this.seriesModels.forEach(seriesModel => { | ||||
|                 const boundingBoxWithId = boundingBoxPerYAxis.find(box => box.id === seriesModel.get('yAxisId')); | ||||
|                 const boundingBox = boundingBoxWithId?.boundingBox; | ||||
|                 //Series was probably added after the last annotations were saved | ||||
|                 if (!boundingBox) { | ||||
|                     return; | ||||
|                 } | ||||
|  | ||||
|                 const seriesData = seriesModel.getSeriesData(); | ||||
|                 if (seriesData && seriesData.length) { | ||||
|                     const kdTree = new KDBush(seriesData, | ||||
| @@ -1283,25 +1366,31 @@ export default { | ||||
|             return seriesKDTrees; | ||||
|         }, | ||||
|         endAnnotationMarquee(event) { | ||||
|             const minX = Math.min(this.marquee.start.x, this.marquee.end.x); | ||||
|             const startMinY = this.marquee.start.y.reduce((previousY, currentY) => { | ||||
|                 return Math.min(previousY, currentY); | ||||
|             }, this.marquee.start.y[0]); | ||||
|             const endMinY = this.marquee.end.y.reduce((previousY, currentY) => { | ||||
|                 return Math.min(previousY, currentY); | ||||
|             }, this.marquee.end.y[0]); | ||||
|             const minY = Math.min(startMinY, endMinY); | ||||
|             const maxX = Math.max(this.marquee.start.x, this.marquee.end.x); | ||||
|             const maxY = Math.max(startMinY, endMinY); | ||||
|             const boundingBox = { | ||||
|                 minX, | ||||
|                 minY, | ||||
|                 maxX, | ||||
|                 maxY | ||||
|             }; | ||||
|             const pointsInBox = this.getPointsInBox(boundingBox); | ||||
|             const boundingBoxPerYAxis = []; | ||||
|             this.yAxisListWithRange.forEach((yAxis, yIndex) => { | ||||
|                 const minX = Math.min(this.marquee.start.x, this.marquee.end.x); | ||||
|                 const minY = Math.min(this.marquee.start.y[yIndex], this.marquee.end.y[yIndex]); | ||||
|                 const maxX = Math.max(this.marquee.start.x, this.marquee.end.x); | ||||
|                 const maxY = Math.max(this.marquee.start.y[yIndex], this.marquee.end.y[yIndex]); | ||||
|                 const boundingBox = { | ||||
|                     minX, | ||||
|                     minY, | ||||
|                     maxX, | ||||
|                     maxY | ||||
|                 }; | ||||
|                 boundingBoxPerYAxis.push({ | ||||
|                     id: yAxis.get('id'), | ||||
|                     boundingBox | ||||
|                 }); | ||||
|             }); | ||||
|  | ||||
|             const pointsInBox = this.getPointsInBox(boundingBoxPerYAxis); | ||||
|             if (!pointsInBox) { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             this.annotationSelections = pointsInBox.flat(); | ||||
|             this.selectNewPlotAnnotations(minX, minY, maxX, maxY, pointsInBox, event); | ||||
|             this.selectNewPlotAnnotations(boundingBoxPerYAxis, pointsInBox, event); | ||||
|         }, | ||||
|         endZoomMarquee() { | ||||
|             const startPixels = this.marquee.startPixels; | ||||
| @@ -1681,7 +1770,9 @@ export default { | ||||
|         }, | ||||
|  | ||||
|         destroy() { | ||||
|             configStore.deleteStore(this.config.id); | ||||
|             if (this.config) { | ||||
|                 configStore.deleteStore(this.config.id); | ||||
|             } | ||||
|  | ||||
|             this.stopListening(); | ||||
|  | ||||
| @@ -1722,9 +1813,6 @@ export default { | ||||
|                 this.config.series.models.forEach(this.loadSeriesData, this); | ||||
|             } | ||||
|         }, | ||||
|         legendHoverChanged(data) { | ||||
|             this.showLimitLineLabels = data; | ||||
|         }, | ||||
|         toggleCursorGuide() { | ||||
|             this.cursorGuide = !this.cursorGuide; | ||||
|             this.$emit('cursorGuide', this.cursorGuide); | ||||
|   | ||||
| @@ -86,6 +86,8 @@ import eventHelpers from "./lib/eventHelpers"; | ||||
| import { ticks, getLogTicks, getFormattedTicks } from "./tickUtils"; | ||||
| import configStore from "./configuration/ConfigStore"; | ||||
|  | ||||
| const SECONDARY_TICK_NUMBER = 2; | ||||
|  | ||||
| export default { | ||||
|     inject: ['openmct', 'domainObject'], | ||||
|     props: { | ||||
| @@ -205,7 +207,7 @@ export default { | ||||
|             } | ||||
|  | ||||
|             if (this.axisType === 'yAxis' && this.axis.get('logMode')) { | ||||
|                 return getLogTicks(range.min, range.max, number, 4); | ||||
|                 return getLogTicks(range.min, range.max, number, SECONDARY_TICK_NUMBER); | ||||
|             } else { | ||||
|                 return ticks(range.min, range.max, number); | ||||
|             } | ||||
|   | ||||
| @@ -36,12 +36,26 @@ | ||||
|             :model="{progressPerc: undefined}" | ||||
|         /> | ||||
|         <mct-plot | ||||
|             :class="[plotLegendExpandedStateClass, plotLegendPositionClass]" | ||||
|             :init-grid-lines="gridLines" | ||||
|             :init-cursor-guide="cursorGuide" | ||||
|             :options="options" | ||||
|             :limit-line-labels="limitLineLabels" | ||||
|             @loadingUpdated="loadingUpdated" | ||||
|             @statusUpdated="setStatus" | ||||
|         /> | ||||
|             @configLoaded="updateReady" | ||||
|             @lockHighlightPoint="lockHighlightPointUpdated" | ||||
|             @highlights="highlightsUpdated" | ||||
|         > | ||||
|             <plot-legend | ||||
|                 v-if="configReady" | ||||
|                 :cursor-locked="lockHighlightPoint" | ||||
|                 :highlights="highlights" | ||||
|                 @legendHoverChanged="legendHoverChanged" | ||||
|                 @expanded="updateExpanded" | ||||
|                 @position="updatePosition" | ||||
|             /> | ||||
|         </mct-plot> | ||||
|     </div> | ||||
| </div> | ||||
| </template> | ||||
| @@ -50,13 +64,15 @@ | ||||
| import eventHelpers from './lib/eventHelpers'; | ||||
| import ImageExporter from '../../exporters/ImageExporter'; | ||||
| import MctPlot from './MctPlot.vue'; | ||||
| import PlotLegend from "./legend/PlotLegend.vue"; | ||||
| import ProgressBar from "../../ui/components/ProgressBar.vue"; | ||||
| import StalenessUtils from '@/utils/staleness'; | ||||
|  | ||||
| export default { | ||||
|     components: { | ||||
|         MctPlot, | ||||
|         ProgressBar | ||||
|         ProgressBar, | ||||
|         PlotLegend | ||||
|     }, | ||||
|     inject: ['openmct', 'domainObject', 'path'], | ||||
|     props: { | ||||
| @@ -77,7 +93,13 @@ export default { | ||||
|             gridLines: !this.options.compact, | ||||
|             loading: false, | ||||
|             status: '', | ||||
|             staleObjects: [] | ||||
|             staleObjects: [], | ||||
|             limitLineLabels: undefined, | ||||
|             lockHighlightPoint: false, | ||||
|             highlights: [], | ||||
|             expanded: false, | ||||
|             position: undefined, | ||||
|             configReady: false | ||||
|         }; | ||||
|     }, | ||||
|     computed: { | ||||
| @@ -87,6 +109,16 @@ export default { | ||||
|             } | ||||
|  | ||||
|             return ''; | ||||
|         }, | ||||
|         plotLegendPositionClass() { | ||||
|             return this.position ? `plot-legend-${this.position}` : ''; | ||||
|         }, | ||||
|         plotLegendExpandedStateClass() { | ||||
|             if (this.expanded) { | ||||
|                 return 'plot-legend-expanded'; | ||||
|             } else { | ||||
|                 return 'plot-legend-collapsed'; | ||||
|             } | ||||
|         } | ||||
|     }, | ||||
|     mounted() { | ||||
| @@ -134,6 +166,7 @@ export default { | ||||
|             this.stalenessSubscription[keystring].unsubscribe(); | ||||
|             this.stalenessSubscription[keystring].stalenessUtils.destroy(); | ||||
|             this.handleStaleness(keystring, { isStale: false }, SKIP_CHECK); | ||||
|             delete this.stalenessSubscription[keystring]; | ||||
|         }, | ||||
|         handleStaleness(id, stalenessResponse, skipCheck = false) { | ||||
|             if (skipCheck || this.stalenessSubscription[id].stalenessUtils.shouldUpdateStaleness(stalenessResponse, id)) { | ||||
| @@ -183,6 +216,24 @@ export default { | ||||
|                 exportPNG: this.exportPNG, | ||||
|                 exportJPG: this.exportJPG | ||||
|             }; | ||||
|         }, | ||||
|         lockHighlightPointUpdated(data) { | ||||
|             this.lockHighlightPoint = data; | ||||
|         }, | ||||
|         highlightsUpdated(data) { | ||||
|             this.highlights = data; | ||||
|         }, | ||||
|         legendHoverChanged(data) { | ||||
|             this.limitLineLabels = data; | ||||
|         }, | ||||
|         updateExpanded(expanded) { | ||||
|             this.expanded = expanded; | ||||
|         }, | ||||
|         updatePosition(position) { | ||||
|             this.position = position; | ||||
|         }, | ||||
|         updateReady(ready) { | ||||
|             this.configReady = ready; | ||||
|         } | ||||
|     } | ||||
| }; | ||||
|   | ||||
| @@ -152,7 +152,7 @@ export default { | ||||
|             this.selectedXKeyOptionKey = this.xKeyOptions.length > 0 ? this.getXKeyOption(xAxisKey).key : xAxisKey; | ||||
|         }, | ||||
|         onTickWidthChange(width) { | ||||
|             this.$emit('tickWidthChanged', width); | ||||
|             this.$emit('plotXTickWidth', width); | ||||
|         } | ||||
|     } | ||||
| }; | ||||
|   | ||||
| @@ -101,7 +101,13 @@ export default { | ||||
|                 return 0; | ||||
|             } | ||||
|         }, | ||||
|         multipleLeftAxes: { | ||||
|         usedTickWidth: { | ||||
|             type: Number, | ||||
|             default() { | ||||
|                 return 0; | ||||
|             } | ||||
|         }, | ||||
|         hasMultipleLeftAxes: { | ||||
|             type: Boolean, | ||||
|             default() { | ||||
|                 return false; | ||||
| @@ -138,14 +144,14 @@ export default { | ||||
|             let style = { | ||||
|                 width: `${this.tickWidth + AXIS_PADDING}px` | ||||
|             }; | ||||
|             const multipleAxesPadding = this.multipleLeftAxes ? AXIS_PADDING : 0; | ||||
|             const multipleAxesPadding = this.hasMultipleLeftAxes ? AXIS_PADDING : 0; | ||||
|  | ||||
|             if (this.position === 'right') { | ||||
|                 style.left = `-${this.tickWidth + AXIS_PADDING}px`; | ||||
|             } else { | ||||
|                 const thisIsTheSecondLeftAxis = (this.id - 1) > 0; | ||||
|                 if (this.multipleLeftAxes && thisIsTheSecondLeftAxis) { | ||||
|                     style.left = 0; | ||||
|                 if (this.hasMultipleLeftAxes && thisIsTheSecondLeftAxis) { | ||||
|                     style.left = `${this.plotLeftTickWidth - this.usedTickWidth - this.tickWidth}px`; | ||||
|                     style['border-right'] = `1px solid`; | ||||
|                 } else { | ||||
|                     style.left = `${ this.plotLeftTickWidth - this.tickWidth + multipleAxesPadding}px`; | ||||
| @@ -202,6 +208,7 @@ export default { | ||||
|             } | ||||
|  | ||||
|             this.listenTo(series, 'change:yAxisId', this.addOrRemoveSeries.bind(this, series), this); | ||||
|             this.listenTo(series, 'change:color', this.updateSeriesColors.bind(this, series), this); | ||||
|         }, | ||||
|         removeSeries(plotSeries) { | ||||
|             const seriesIndex = this.seriesModels.findIndex(model => this.openmct.objects.areIdsEqual(model.get('identifier'), plotSeries.get('identifier'))); | ||||
| @@ -216,6 +223,9 @@ export default { | ||||
|                 return model.get('yKey') === this.seriesModels[0].get('yKey'); | ||||
|             }); | ||||
|             this.singleSeries = this.seriesModels.length === 1; | ||||
|             this.updateSeriesColors(); | ||||
|         }, | ||||
|         updateSeriesColors() { | ||||
|             this.seriesColors = this.seriesModels.map(model => { | ||||
|                 return model.get('color').asHexString(); | ||||
|             }); | ||||
| @@ -252,7 +262,7 @@ export default { | ||||
|             } | ||||
|         }, | ||||
|         onTickWidthChange(data) { | ||||
|             this.$emit('tickWidthChanged', { | ||||
|             this.$emit('plotYTickWidth', { | ||||
|                 width: data.width, | ||||
|                 yAxisId: this.id | ||||
|             }); | ||||
|   | ||||
| @@ -105,6 +105,9 @@ export default class MCTChartAlarmLineSet { | ||||
|  | ||||
|     reset() { | ||||
|         this.limits = []; | ||||
|         if (this.series.limits) { | ||||
|             this.getLimitPoints(this.series); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     destroy() { | ||||
|   | ||||
| @@ -52,6 +52,41 @@ const MARKER_SIZE = 6.0; | ||||
| const HIGHLIGHT_SIZE = MARKER_SIZE * 2.0; | ||||
| const ANNOTATION_SIZE = MARKER_SIZE * 3.0; | ||||
| const CLEARANCE = 15; | ||||
| // These attributes are changed in the plot model, but we don't need to react to the changes. | ||||
| const NO_HANDLING_NEEDED_ATTRIBUTES = { | ||||
|     label: 'label', | ||||
|     values: 'values', | ||||
|     format: 'format', | ||||
|     color: 'color', | ||||
|     name: 'name', | ||||
|     unit: 'unit' | ||||
| }; | ||||
| // These attributes in turn set one of HANDLED_ATTRIBUTES, so we don't need specific listeners for them - this prevents excessive redraws. | ||||
| const IMPLICIT_HANDLED_ATTRIBUTES = { | ||||
|     range: 'range', | ||||
|     //series stats update y axis stats | ||||
|     stats: 'stats', | ||||
|     frozen: 'frozen', | ||||
|     autoscale: 'autoscale', | ||||
|     autoscalePadding: 'autoscalePadding', | ||||
|     logMode: 'logMode', | ||||
|     yKey: 'yKey' | ||||
| }; | ||||
| // Attribute changes that we are specifically handling with listeners | ||||
| const HANDLED_ATTRIBUTES = { | ||||
|     //X and Y Axis attributes | ||||
|     key: 'key', | ||||
|     displayRange: 'displayRange', | ||||
|     //series attributes | ||||
|     xKey: 'xKey', | ||||
|     interpolate: 'interpolate', | ||||
|     markers: 'markers', | ||||
|     markerShape: 'markerShape', | ||||
|     markerSize: 'markerSize', | ||||
|     alarmMarkers: 'alarmMarkers', | ||||
|     limitLines: 'limitLines', | ||||
|     yAxisId: 'yAxisId' | ||||
| }; | ||||
|  | ||||
| export default { | ||||
|     inject: ['openmct', 'domainObject', 'path'], | ||||
| @@ -121,6 +156,7 @@ export default { | ||||
|         hiddenYAxisIds() { | ||||
|             this.hiddenYAxisIds.forEach(id => { | ||||
|                 this.resetYOffsetAndSeriesDataForYAxis(id); | ||||
|                 this.drawLimitLines(); | ||||
|             }); | ||||
|             this.scheduleDraw(); | ||||
|         } | ||||
| @@ -137,14 +173,16 @@ export default { | ||||
|         this.offset = { | ||||
|             [yAxisId]: {} | ||||
|         }; | ||||
|         this.listenTo(this.config.yAxis, 'change:key', this.resetYOffsetAndSeriesDataForYAxis.bind(this, yAxisId), this); | ||||
|         this.listenTo(this.config.yAxis, 'change', this.updateLimitsAndDraw); | ||||
|         this.listenTo(this.config.yAxis, `change:${HANDLED_ATTRIBUTES.displayRange}`, this.scheduleDraw); | ||||
|         this.listenTo(this.config.yAxis, `change:${HANDLED_ATTRIBUTES.key}`, this.resetYOffsetAndSeriesDataForYAxis.bind(this, yAxisId), this); | ||||
|         this.listenTo(this.config.yAxis, 'change', this.redrawIfNotAlreadyHandled); | ||||
|         if (this.config.additionalYAxes.length) { | ||||
|             this.config.additionalYAxes.forEach(yAxis => { | ||||
|                 const id = yAxis.get('id'); | ||||
|                 this.offset[id] = {}; | ||||
|                 this.listenTo(yAxis, 'change', this.updateLimitsAndDraw); | ||||
|                 this.listenTo(yAxis, 'change:key', this.resetYOffsetAndSeriesDataForYAxis.bind(this, id), this); | ||||
|                 this.listenTo(yAxis, `change:${HANDLED_ATTRIBUTES.displayRange}`, this.scheduleDraw); | ||||
|                 this.listenTo(yAxis, `change:${HANDLED_ATTRIBUTES.key}`, this.resetYOffsetAndSeriesDataForYAxis.bind(this, id), this); | ||||
|                 this.listenTo(yAxis, 'change', this.redrawIfNotAlreadyHandled); | ||||
|             }); | ||||
|         } | ||||
|  | ||||
| @@ -161,7 +199,8 @@ export default { | ||||
|         this.listenTo(this.config.series, 'add', this.onSeriesAdd, this); | ||||
|         this.listenTo(this.config.series, 'remove', this.onSeriesRemove, this); | ||||
|  | ||||
|         this.listenTo(this.config.xAxis, 'change', this.updateLimitsAndDraw); | ||||
|         this.listenTo(this.config.xAxis, 'change:displayRange', this.scheduleDraw); | ||||
|         this.listenTo(this.config.xAxis, 'change', this.redrawIfNotAlreadyHandled); | ||||
|         this.config.series.forEach(this.onSeriesAdd, this); | ||||
|         this.$emit('chartLoaded'); | ||||
|     }, | ||||
| @@ -190,21 +229,33 @@ export default { | ||||
|             this.changeLimitLines(mode, o, series); | ||||
|         }, | ||||
|         onSeriesAdd(series) { | ||||
|             this.listenTo(series, 'change:xKey', this.reDraw, this); | ||||
|             this.listenTo(series, 'change:interpolate', this.changeInterpolate, this); | ||||
|             this.listenTo(series, 'change:markers', this.changeMarkers, this); | ||||
|             this.listenTo(series, 'change:alarmMarkers', this.changeAlarmMarkers, this); | ||||
|             this.listenTo(series, 'change:limitLines', this.changeLimitLines, this); | ||||
|             this.listenTo(series, 'change:yAxisId', this.resetAxisAndRedraw, this); | ||||
|             this.listenTo(series, 'change', this.scheduleDraw); | ||||
|             this.listenTo(series, `change:${HANDLED_ATTRIBUTES.xKey}`, this.reDraw, this); | ||||
|             this.listenTo(series, `change:${HANDLED_ATTRIBUTES.interpolate}`, this.changeInterpolate, this); | ||||
|             this.listenTo(series, `change:${HANDLED_ATTRIBUTES.markers}`, this.changeMarkers, this); | ||||
|             this.listenTo(series, `change:${HANDLED_ATTRIBUTES.alarmMarkers}`, this.changeAlarmMarkers, this); | ||||
|             this.listenTo(series, `change:${HANDLED_ATTRIBUTES.limitLines}`, this.changeLimitLines, this); | ||||
|             this.listenTo(series, `change:${HANDLED_ATTRIBUTES.yAxisId}`, this.resetAxisAndRedraw, this); | ||||
|             this.listenTo(series, `change:${HANDLED_ATTRIBUTES.markerShape}`, this.scheduleDraw, this); | ||||
|             this.listenTo(series, `change:${HANDLED_ATTRIBUTES.markerSize}`, this.scheduleDraw, this); | ||||
|             this.listenTo(series, 'change', this.redrawIfNotAlreadyHandled); | ||||
|             this.listenTo(series, 'add', this.onAddPoint); | ||||
|             this.makeChartElement(series); | ||||
|             this.makeLimitLines(series); | ||||
|         }, | ||||
|         onAddPoint(point, insertIndex, series) { | ||||
|             const mainYAxisId = this.config.yAxis.get('id'); | ||||
|             const seriesYAxisId = series.get('yAxisId'); | ||||
|             const xRange = this.config.xAxis.get('displayRange'); | ||||
|             //TODO: get the yAxis of this series | ||||
|             const yRange = this.config.yAxis.get('displayRange'); | ||||
|  | ||||
|             let yRange; | ||||
|             if (seriesYAxisId === mainYAxisId) { | ||||
|                 yRange = this.config.yAxis.get('displayRange'); | ||||
|             } else { | ||||
|                 yRange = this.config.additionalYAxes.find( | ||||
|                     yAxis => yAxis.get('id') === seriesYAxisId | ||||
|                 ).get('displayRange'); | ||||
|             } | ||||
|  | ||||
|             const xValue = series.getXVal(point); | ||||
|             const yValue = series.getYVal(point); | ||||
|  | ||||
| @@ -519,6 +570,21 @@ export default { | ||||
|  | ||||
|             return true; | ||||
|         }, | ||||
|         redrawIfNotAlreadyHandled(attribute, value, oldValue) { | ||||
|             if (Object.keys(HANDLED_ATTRIBUTES).includes(attribute) && oldValue) { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             if (Object.keys(IMPLICIT_HANDLED_ATTRIBUTES).includes(attribute) && oldValue) { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             if (Object.keys(NO_HANDLING_NEEDED_ATTRIBUTES).includes(attribute) && oldValue) { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             this.updateLimitsAndDraw(); | ||||
|         }, | ||||
|         updateLimitsAndDraw() { | ||||
|             this.drawLimitLines(); | ||||
|             this.scheduleDraw(); | ||||
| @@ -615,9 +681,13 @@ export default { | ||||
|             alarmSets.forEach(this.drawAlarmPoints, this); | ||||
|         }, | ||||
|         drawLimitLines() { | ||||
|             Array.from(this.$refs.limitArea.children).forEach((el) => el.remove()); | ||||
|             this.config.series.models.forEach(series => { | ||||
|                 const yAxisId = series.get('yAxisId'); | ||||
|                 this.drawLimitLinesForSeries(yAxisId, series); | ||||
|  | ||||
|                 if (this.hiddenYAxisIds.indexOf(yAxisId) < 0) { | ||||
|                     this.drawLimitLinesForSeries(yAxisId, series); | ||||
|                 } | ||||
|             }); | ||||
|         }, | ||||
|         drawLimitLinesForSeries(yAxisId, series) { | ||||
| @@ -631,12 +701,11 @@ export default { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             Array.from(this.$refs.limitArea.children).forEach((el) => el.remove()); | ||||
|             let limitPointOverlap = []; | ||||
|             this.limitLines.forEach((limitLine) => { | ||||
|                 let limitContainerEl = this.$refs.limitArea; | ||||
|                 limitLine.limits.forEach((limit) => { | ||||
|                     if (!series.includes(limit.seriesKey)) { | ||||
|                     if (series.keyString !== limit.seriesKey) { | ||||
|                         return; | ||||
|                     } | ||||
|  | ||||
| @@ -744,6 +813,10 @@ export default { | ||||
|             } | ||||
|         }, | ||||
|         annotatedPointWithinRange(annotatedPoint, xRange, yRange) { | ||||
|             if (!yRange) { | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             const xValue = annotatedPoint.series.getXVal(annotatedPoint.point); | ||||
|             const yValue = annotatedPoint.series.getYVal(annotatedPoint.point); | ||||
|  | ||||
|   | ||||
| @@ -68,27 +68,26 @@ export default class PlotConfigurationModel extends Model { | ||||
|         //Add any axes in addition to the main yAxis above - we must always have at least 1 y-axis | ||||
|         //Addition axes ids will be the MAIN_Y_AXES_ID + x where x is between 1 and MAX_ADDITIONAL_AXES | ||||
|         this.additionalYAxes = []; | ||||
|         if (Array.isArray(options.model.additionalYAxes)) { | ||||
|             const maxLength = Math.min(MAX_ADDITIONAL_AXES, options.model.additionalYAxes.length); | ||||
|             for (let yAxisCount = 0; yAxisCount < maxLength; yAxisCount++) { | ||||
|                 const yAxis = options.model.additionalYAxes[yAxisCount]; | ||||
|         const hasAdditionalAxesConfiguration = Array.isArray(options.model.additionalYAxes); | ||||
|  | ||||
|         for (let yAxisCount = 0; yAxisCount < MAX_ADDITIONAL_AXES; yAxisCount++) { | ||||
|             const yAxisId = MAIN_Y_AXES_ID + yAxisCount + 1; | ||||
|             const yAxis = hasAdditionalAxesConfiguration && options.model.additionalYAxes.find(additionalYAxis => additionalYAxis?.id === yAxisId); | ||||
|             if (yAxis) { | ||||
|                 this.additionalYAxes.push(new YAxisModel({ | ||||
|                     model: yAxis, | ||||
|                     plot: this, | ||||
|                     openmct: options.openmct, | ||||
|                     id: yAxis.id || (MAIN_Y_AXES_ID + yAxisCount + 1) | ||||
|                     id: yAxis.id | ||||
|                 })); | ||||
|             } else { | ||||
|                 this.additionalYAxes.push(new YAxisModel({ | ||||
|                     plot: this, | ||||
|                     openmct: options.openmct, | ||||
|                     id: yAxisId | ||||
|                 })); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // If the saved options config doesn't include information about all the additional axes, we initialize the remaining here | ||||
|         for (let axesCount = this.additionalYAxes.length; axesCount < MAX_ADDITIONAL_AXES; axesCount++) { | ||||
|             this.additionalYAxes.push(new YAxisModel({ | ||||
|                 plot: this, | ||||
|                 openmct: options.openmct, | ||||
|                 id: MAIN_Y_AXES_ID + axesCount + 1 | ||||
|             })); | ||||
|         } | ||||
|         // end add additional axes | ||||
|  | ||||
|         this.legend = new LegendModel({ | ||||
|   | ||||
| @@ -73,7 +73,7 @@ export default class PlotSeries extends Model { | ||||
|  | ||||
|         super(options); | ||||
|  | ||||
|         this.logMode = options.collection.plot.model.yAxis.logMode; | ||||
|         this.logMode = this.getLogMode(options); | ||||
|  | ||||
|         this.listenTo(this, 'change:xKey', this.onXKeyChange, this); | ||||
|         this.listenTo(this, 'change:yKey', this.onYKeyChange, this); | ||||
| @@ -87,6 +87,17 @@ export default class PlotSeries extends Model { | ||||
|         this.unPlottableValues = [undefined, Infinity, -Infinity]; | ||||
|     } | ||||
|  | ||||
|     getLogMode(options) { | ||||
|         const yAxisId = this.get('yAxisId'); | ||||
|         if (yAxisId === 1) { | ||||
|             return options.collection.plot.model.yAxis.logMode; | ||||
|         } else { | ||||
|             const foundYAxis = options.collection.plot.model.additionalYAxes.find(yAxis => yAxis.id === yAxisId); | ||||
|  | ||||
|             return foundYAxis ? foundYAxis.logMode : false; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Set defaults for telemetry series. | ||||
|      * @param {import('./Model').ModelOptions<PlotSeriesModelType, PlotSeriesModelOptions>} options | ||||
| @@ -241,6 +252,7 @@ export default class PlotSeries extends Model { | ||||
|         } | ||||
|  | ||||
|         const valueMetadata = this.metadata.value(newKey); | ||||
|         //TODO: Should we do this even if there is a persisted config? | ||||
|         if (!this.persistedConfig || !this.persistedConfig.interpolate) { | ||||
|             if (valueMetadata.format === 'enum') { | ||||
|                 this.set('interpolate', 'stepAfter'); | ||||
|   | ||||
| @@ -56,6 +56,10 @@ export default class SeriesCollection extends Collection { | ||||
|             const series = this.byIdentifier(seriesConfig.identifier); | ||||
|             if (series) { | ||||
|                 series.persistedConfig = seriesConfig; | ||||
|                 if (!series.persistedConfig.yAxisId) { | ||||
|                     return; | ||||
|                 } | ||||
|  | ||||
|                 if (series.get('yAxisId') !== series.persistedConfig.yAxisId) { | ||||
|                     series.set('yAxisId', series.persistedConfig.yAxisId); | ||||
|                 } | ||||
| @@ -63,6 +67,10 @@ export default class SeriesCollection extends Collection { | ||||
|         }, this); | ||||
|     } | ||||
|     watchTelemetryContainer(domainObject) { | ||||
|         if (domainObject.type === 'telemetry.plot.stacked') { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         const composition = this.openmct.composition.get(domainObject); | ||||
|         this.listenTo(composition, 'add', this.addTelemetryObject, this); | ||||
|         this.listenTo(composition, 'remove', this.removeTelemetryObject, this); | ||||
|   | ||||
| @@ -57,7 +57,14 @@ export default class YAxisModel extends Model { | ||||
|         this.listenTo(this, 'change:logMode', this.onLogModeChange, this); | ||||
|         this.listenTo(this, 'change:frozen', this.toggleFreeze, this); | ||||
|         this.listenTo(this, 'change:range', this.updateDisplayRange, this); | ||||
|         this.updateDisplayRange(this.get('range')); | ||||
|         const range = this.get('range'); | ||||
|         this.updateDisplayRange(range); | ||||
|         //This is an edge case and should not happen | ||||
|         const invalidRange = !range || (range?.min === undefined || range?.max === undefined); | ||||
|         const invalidAutoScaleOff = (options.model.autoscale === false) && invalidRange; | ||||
|         if (invalidAutoScaleOff) { | ||||
|             this.set('autoscale', true); | ||||
|         } | ||||
|     } | ||||
|     /** | ||||
|      * @param {import('./SeriesCollection').default} seriesCollection | ||||
| @@ -250,23 +257,6 @@ export default class YAxisModel extends Model { | ||||
|             } | ||||
|  | ||||
|             this.set('displayRange', _range); | ||||
|         } else { | ||||
|             // Otherwise use the last known displayRange as the initial | ||||
|             // values for the user-defined range, so that we don't end up | ||||
|             // with any error from an undefined user range. | ||||
|  | ||||
|             const _range = this.get('displayRange'); | ||||
|  | ||||
|             if (!_range) { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             if (this.get('logMode')) { | ||||
|                 _range.min = antisymlog(_range.min, 10); | ||||
|                 _range.max = antisymlog(_range.max, 10); | ||||
|             } | ||||
|  | ||||
|             this.set('range', _range); | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @@ -287,7 +277,8 @@ export default class YAxisModel extends Model { | ||||
|         this.resetSeries(); | ||||
|     } | ||||
|     resetSeries() { | ||||
|         this.plot.series.forEach((plotSeries) => { | ||||
|         const series = this.getSeriesForYAxis(this.seriesCollection); | ||||
|         series.forEach((plotSeries) => { | ||||
|             plotSeries.logMode = this.get('logMode'); | ||||
|             plotSeries.reset(plotSeries.getSeriesData()); | ||||
|         }); | ||||
| @@ -376,11 +367,8 @@ export default class YAxisModel extends Model { | ||||
|             autoscale: true, | ||||
|             logMode: options.model?.logMode ?? false, | ||||
|             autoscalePadding: 0.1, | ||||
|             id: options.id | ||||
|  | ||||
|             // 'range' is not specified here, it is undefined at first. When the | ||||
|             // user turns off autoscale, the current 'displayRange' is used for | ||||
|             // the initial value of 'range'. | ||||
|             id: options.id, | ||||
|             range: options.model?.range | ||||
|         }; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -27,6 +27,7 @@ | ||||
|     <ul | ||||
|         v-if="!isStackedPlotObject" | ||||
|         class="c-tree" | ||||
|         aria-label="Plot Series Properties" | ||||
|     > | ||||
|         <h2 title="Plot series display properties in this object">Plot Series</h2> | ||||
|         <plot-options-item | ||||
| @@ -43,6 +44,7 @@ | ||||
|             v-for="(yAxis, index) in yAxesWithSeries" | ||||
|             :key="`yAxis-${index}`" | ||||
|             class="l-inspector-part js-yaxis-properties" | ||||
|             :aria-label="yAxesWithSeries.length > 1 ? `Y Axis ${yAxis.id} Properties` : 'Y Axis Properties'" | ||||
|         > | ||||
|             <h2 title="Y axis settings for this object">Y Axis {{ yAxesWithSeries.length > 1 ? yAxis.id : '' }}</h2> | ||||
|             <li class="grid-row"> | ||||
| @@ -71,7 +73,7 @@ | ||||
|                 </div> | ||||
|             </li> | ||||
|             <li | ||||
|                 v-if="!yAxis.autoscale && yAxis.rangeMin" | ||||
|                 v-if="!yAxis.autoscale && yAxis.rangeMin !== ''" | ||||
|                 class="grid-row" | ||||
|             > | ||||
|                 <div | ||||
| @@ -81,7 +83,7 @@ | ||||
|                 <div class="grid-cell value">{{ yAxis.rangeMin }}</div> | ||||
|             </li> | ||||
|             <li | ||||
|                 v-if="!yAxis.autoscale && yAxis.rangeMax" | ||||
|                 v-if="!yAxis.autoscale && yAxis.rangeMax !== ''" | ||||
|                 class="grid-row" | ||||
|             > | ||||
|                 <div | ||||
| @@ -93,7 +95,7 @@ | ||||
|         </ul> | ||||
|     </div> | ||||
|     <div | ||||
|         v-if="plotSeries.length && (isStackedPlotObject || !isNestedWithinAStackedPlot)" | ||||
|         v-if="isStackedPlotObject || !isNestedWithinAStackedPlot" | ||||
|         class="grid-properties" | ||||
|     > | ||||
|         <ul | ||||
| @@ -190,10 +192,13 @@ export default { | ||||
|     mounted() { | ||||
|         eventHelpers.extend(this); | ||||
|         this.config = this.getConfig(); | ||||
|         this.initYAxesConfiguration(); | ||||
|         if (!this.isStackedPlotObject) { | ||||
|             this.initYAxesConfiguration(); | ||||
|             this.registerListeners(); | ||||
|         } else { | ||||
|             this.initLegendConfiguration(); | ||||
|         } | ||||
|  | ||||
|         this.registerListeners(); | ||||
|         this.initLegendConfiguration(); | ||||
|         this.loaded = true; | ||||
|  | ||||
|     }, | ||||
| @@ -212,8 +217,8 @@ export default { | ||||
|                     autoscale: this.config.yAxis.get('autoscale'), | ||||
|                     logMode: this.config.yAxis.get('logMode'), | ||||
|                     autoscalePadding: this.config.yAxis.get('autoscalePadding'), | ||||
|                     rangeMin: range ? range.min : '', | ||||
|                     rangeMax: range ? range.max : '' | ||||
|                     rangeMin: range?.min ?? '', | ||||
|                     rangeMax: range?.max ?? '' | ||||
|                 }); | ||||
|                 this.config.additionalYAxes.forEach(yAxis => { | ||||
|                     range = yAxis.get('range'); | ||||
| @@ -225,8 +230,8 @@ export default { | ||||
|                         autoscale: yAxis.get('autoscale'), | ||||
|                         logMode: yAxis.get('logMode'), | ||||
|                         autoscalePadding: yAxis.get('autoscalePadding'), | ||||
|                         rangeMin: range ? range.min : '', | ||||
|                         rangeMax: range ? range.max : '' | ||||
|                         rangeMin: range?.min ?? '', | ||||
|                         rangeMax: range?.max ?? '' | ||||
|                     }); | ||||
|                 }); | ||||
|             } | ||||
| @@ -245,9 +250,9 @@ export default { | ||||
|             } | ||||
|         }, | ||||
|         getConfig() { | ||||
|             this.configId = this.openmct.objects.makeKeyString(this.domainObject.identifier); | ||||
|             const configId = this.openmct.objects.makeKeyString(this.domainObject.identifier); | ||||
|  | ||||
|             return configStore.get(this.configId); | ||||
|             return configStore.get(configId); | ||||
|         }, | ||||
|         registerListeners() { | ||||
|             if (this.config) { | ||||
|   | ||||
| @@ -27,6 +27,7 @@ | ||||
|     <ul | ||||
|         v-if="!isStackedPlotObject" | ||||
|         class="c-tree" | ||||
|         aria-label="Plot Series Properties" | ||||
|     > | ||||
|         <h2 title="Display properties for this object">Plot Series</h2> | ||||
|         <li | ||||
| @@ -53,7 +54,6 @@ | ||||
|     > | ||||
|         <h2 title="Legend options">Legend</h2> | ||||
|         <legend-form | ||||
|             v-if="plotSeries.length" | ||||
|             class="grid-properties" | ||||
|             :legend="config.legend" | ||||
|         /> | ||||
| @@ -97,20 +97,23 @@ export default { | ||||
|     mounted() { | ||||
|         eventHelpers.extend(this); | ||||
|         this.config = this.getConfig(); | ||||
|         this.yAxes = [{ | ||||
|             id: this.config.yAxis.id, | ||||
|             seriesCount: 0 | ||||
|         }]; | ||||
|         if (this.config.additionalYAxes) { | ||||
|             this.yAxes = this.yAxes.concat(this.config.additionalYAxes.map(yAxis => { | ||||
|                 return { | ||||
|                     id: yAxis.id, | ||||
|                     seriesCount: 0 | ||||
|                 }; | ||||
|             })); | ||||
|         if (!this.isStackedPlotObject) { | ||||
|             this.yAxes = [{ | ||||
|                 id: this.config.yAxis.id, | ||||
|                 seriesCount: 0 | ||||
|             }]; | ||||
|             if (this.config.additionalYAxes) { | ||||
|                 this.yAxes = this.yAxes.concat(this.config.additionalYAxes.map(yAxis => { | ||||
|                     return { | ||||
|                         id: yAxis.id, | ||||
|                         seriesCount: 0 | ||||
|                     }; | ||||
|                 })); | ||||
|             } | ||||
|  | ||||
|             this.registerListeners(); | ||||
|         } | ||||
|  | ||||
|         this.registerListeners(); | ||||
|         this.loaded = true; | ||||
|     }, | ||||
|     beforeDestroy() { | ||||
| @@ -150,23 +153,50 @@ export default { | ||||
|  | ||||
|         addSeries(series, index) { | ||||
|             const yAxisId = series.get('yAxisId'); | ||||
|             this.updateAxisUsageCount(yAxisId, 1); | ||||
|             this.incrementAxisUsageCount(yAxisId); | ||||
|             this.$set(this.plotSeries, index, series); | ||||
|             this.setYAxisLabel(yAxisId); | ||||
|  | ||||
|             if (this.isStackedPlotObject) { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             // If the series moves to a different yAxis, update the seriesCounts for both yAxes | ||||
|             // so we can display the configuration options for all used yAxes | ||||
|             this.listenTo(series, 'change:yAxisId', (newYAxisId, oldYAxisId) => { | ||||
|                 this.incrementAxisUsageCount(newYAxisId); | ||||
|                 this.decrementAxisUsageCount(oldYAxisId); | ||||
|             }, this); | ||||
|         }, | ||||
|  | ||||
|         removeSeries(series, index) { | ||||
|             const yAxisId = series.get('yAxisId'); | ||||
|             this.updateAxisUsageCount(yAxisId, -1); | ||||
|             this.decrementAxisUsageCount(yAxisId); | ||||
|             this.plotSeries.splice(index, 1); | ||||
|             this.setYAxisLabel(yAxisId); | ||||
|  | ||||
|             if (this.isStackedPlotObject) { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             this.stopListening(series, 'change:yAxisId'); | ||||
|         }, | ||||
|  | ||||
|         incrementAxisUsageCount(yAxisId) { | ||||
|             this.updateAxisUsageCount(yAxisId, 1); | ||||
|         }, | ||||
|  | ||||
|         decrementAxisUsageCount(yAxisId) { | ||||
|             this.updateAxisUsageCount(yAxisId, -1); | ||||
|         }, | ||||
|  | ||||
|         updateAxisUsageCount(yAxisId, updateCount) { | ||||
|             const foundYAxis = this.findYAxisForId(yAxisId); | ||||
|             if (foundYAxis) { | ||||
|                 foundYAxis.seriesCount = foundYAxis.seriesCount + updateCount; | ||||
|             if (!foundYAxis) { | ||||
|                 throw new Error(`yAxis with id ${yAxisId} not found`); | ||||
|             } | ||||
|  | ||||
|             foundYAxis.seriesCount = foundYAxis.seriesCount + updateCount; | ||||
|         }, | ||||
|  | ||||
|         updateSeriesConfigForObject(config) { | ||||
|   | ||||
| @@ -12,11 +12,12 @@ export default function PlotsInspectorViewProvider(openmct) { | ||||
|             } | ||||
|  | ||||
|             let object = selection[0][0].context.item; | ||||
|             let parent = selection[0].length > 1 && selection[0][1].context.item; | ||||
|  | ||||
|             const isOverlayPlotObject = object && object.type === 'telemetry.plot.overlay'; | ||||
|             const isStackedPlotObject = object && object.type === 'telemetry.plot.stacked'; | ||||
|             const isParentStackedPlotObject = parent && parent.type === 'telemetry.plot.stacked'; | ||||
|  | ||||
|             return isStackedPlotObject || isOverlayPlotObject; | ||||
|             return isOverlayPlotObject || isParentStackedPlotObject; | ||||
|         }, | ||||
|         view: function (selection) { | ||||
|             let component; | ||||
|   | ||||
| @@ -12,12 +12,10 @@ export default function StackedPlotsInspectorViewProvider(openmct) { | ||||
|             } | ||||
|  | ||||
|             const object = selection[0][0].context.item; | ||||
|             const parent = selection[0].length > 1 && selection[0][1].context.item; | ||||
|  | ||||
|             const isOverlayPlotObject = object && object.type === 'telemetry.plot.overlay'; | ||||
|             const isParentStackedPlotObject = parent && parent.type === 'telemetry.plot.stacked'; | ||||
|             const isStackedPlotObject = object && object.type === 'telemetry.plot.stacked'; | ||||
|  | ||||
|             return !isOverlayPlotObject && isParentStackedPlotObject; | ||||
|             return isStackedPlotObject; | ||||
|         }, | ||||
|         view: function (selection) { | ||||
|             let component; | ||||
|   | ||||
| @@ -1,6 +1,9 @@ | ||||
| <template> | ||||
| <div v-if="loaded"> | ||||
|     <ul class="l-inspector-part"> | ||||
|     <ul | ||||
|         class="l-inspector-part" | ||||
|         :aria-label="id > 1 ? `Y Axis ${id} Properties` : 'Y Axis Properties'" | ||||
|     > | ||||
|         <h2>Y Axis {{ id > 1 ? id : '' }}</h2> | ||||
|         <li class="grid-row"> | ||||
|             <div | ||||
| @@ -78,7 +81,7 @@ | ||||
|             >Minimum Value</div> | ||||
|             <div class="grid-cell value"> | ||||
|                 <input | ||||
|                     v-model="rangeMin" | ||||
|                     v-model.lazy="rangeMin" | ||||
|                     class="c-input--flex" | ||||
|                     type="number" | ||||
|                     @change="updateForm('range')" | ||||
| @@ -91,7 +94,7 @@ | ||||
|                 title="Maximum Y axis value." | ||||
|             >Maximum Value</div> | ||||
|             <div class="grid-cell value"><input | ||||
|                 v-model="rangeMax" | ||||
|                 v-model.lazy="rangeMax" | ||||
|                 class="c-input--flex" | ||||
|                 type="number" | ||||
|                 @change="updateForm('range')" | ||||
| @@ -128,6 +131,12 @@ export default { | ||||
|             loaded: false | ||||
|         }; | ||||
|     }, | ||||
|     beforeDestroy() { | ||||
|         if (this.autoscale === false && this.validationErrors.range) { | ||||
|             this.autoscale = true; | ||||
|             this.updateForm('autoscale'); | ||||
|         } | ||||
|     }, | ||||
|     mounted() { | ||||
|         eventHelpers.extend(this); | ||||
|         this.getConfig(); | ||||
| @@ -169,12 +178,9 @@ export default { | ||||
|                     objectPath: `${prefix}.logMode` | ||||
|                 }, | ||||
|                 range: { | ||||
|                     objectPath: `${prefix}.range'`, | ||||
|                     objectPath: `${prefix}.range`, | ||||
|                     coerce: function coerceRange(range) { | ||||
|                         const newRange = { | ||||
|                             min: -1, | ||||
|                             max: 1 | ||||
|                         }; | ||||
|                         const newRange = {}; | ||||
|  | ||||
|                         if (range && typeof range.min !== 'undefined' && range.min !== null) { | ||||
|                             newRange.min = Number(range.min); | ||||
| @@ -219,16 +225,18 @@ export default { | ||||
|             this.autoscale = this.yAxis.get('autoscale'); | ||||
|             this.logMode = this.yAxis.get('logMode'); | ||||
|             this.autoscalePadding = this.yAxis.get('autoscalePadding'); | ||||
|             const range = this.yAxis.get('range') ?? this.yAxis.get('displayRange'); | ||||
|             this.rangeMin = range?.min; | ||||
|             this.rangeMax = range?.max; | ||||
|             const range = this.yAxis.get('range'); | ||||
|             if (range && range.min !== undefined && range.max !== undefined) { | ||||
|                 this.rangeMin = range.min; | ||||
|                 this.rangeMax = range.max; | ||||
|             } | ||||
|         }, | ||||
|         getPrefix() { | ||||
|             let prefix = 'yAxis'; | ||||
|             if (this.isAdditionalYAxis) { | ||||
|                 let index = -1; | ||||
|                 if (this.additionalYAxes) { | ||||
|                     index = this.additionalYAxes.findIndex((yAxis) => { | ||||
|                 if (this.domainObject?.configuration?.additionalYAxes) { | ||||
|                     index = this.domainObject?.configuration?.additionalYAxes.findIndex((yAxis) => { | ||||
|                         return yAxis.id === this.id; | ||||
|                     }); | ||||
|                 } | ||||
| @@ -308,6 +316,15 @@ export default { | ||||
|                             }); | ||||
|                         } | ||||
|                     } | ||||
|  | ||||
|                     //If autoscale is turned off, we must know what the user defined min and max ranges are | ||||
|                     if (formKey === 'autoscale' && this.autoscale === false) { | ||||
|                         const rangeFormField = this.fields.range; | ||||
|                         this.validationErrors.range = rangeFormField.validate?.({ | ||||
|                             min: this.rangeMin, | ||||
|                             max: this.rangeMax | ||||
|                         }, this.yAxis); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|   | ||||
| @@ -49,10 +49,10 @@ | ||||
|                 title="Cursor is point locked. Click anywhere in the plot to unlock." | ||||
|             ></div> | ||||
|             <plot-legend-item-collapsed | ||||
|                 v-for="(seriesObject, seriesIndex) in series" | ||||
|                 :key="`${seriesObject.keyString}-${seriesIndex}`" | ||||
|                 v-for="(seriesObject, seriesIndex) in seriesModels" | ||||
|                 :key="`${seriesObject.keyString}-${seriesIndex}-collapsed`" | ||||
|                 :highlights="highlights" | ||||
|                 :value-to-show-when-collapsed="legend.get('valueToShowWhenCollapsed')" | ||||
|                 :value-to-show-when-collapsed="valueToShowWhenCollapsed" | ||||
|                 :series-object="seriesObject" | ||||
|                 @legendHoverChanged="legendHoverChanged" | ||||
|             /> | ||||
| @@ -95,11 +95,10 @@ | ||||
|                 </thead> | ||||
|                 <tbody> | ||||
|                     <plot-legend-item-expanded | ||||
|                         v-for="(seriesObject, seriesIndex) in series" | ||||
|                         v-for="(seriesObject, seriesIndex) in seriesModels" | ||||
|                         :key="`${seriesObject.keyString}-${seriesIndex}-expanded`" | ||||
|                         :series-object="seriesObject" | ||||
|                         :highlights="highlights" | ||||
|                         :legend="legend" | ||||
|                         @legendHoverChanged="legendHoverChanged" | ||||
|                     /> | ||||
|                 </tbody> | ||||
| @@ -111,6 +110,9 @@ | ||||
| <script> | ||||
| import PlotLegendItemCollapsed from "./PlotLegendItemCollapsed.vue"; | ||||
| import PlotLegendItemExpanded from "./PlotLegendItemExpanded.vue"; | ||||
| import configStore from "../configuration/ConfigStore"; | ||||
| import eventHelpers from "../lib/eventHelpers"; | ||||
|  | ||||
| export default { | ||||
|     components: { | ||||
|         PlotLegendItemExpanded, | ||||
| @@ -124,57 +126,120 @@ export default { | ||||
|                 return false; | ||||
|             } | ||||
|         }, | ||||
|         series: { | ||||
|             type: Array, | ||||
|             default() { | ||||
|                 return []; | ||||
|             } | ||||
|         }, | ||||
|         highlights: { | ||||
|             type: Array, | ||||
|             default() { | ||||
|                 return []; | ||||
|             } | ||||
|         }, | ||||
|         legend: { | ||||
|             type: Object, | ||||
|             default() { | ||||
|                 return {}; | ||||
|             } | ||||
|         } | ||||
|     }, | ||||
|     data() { | ||||
|         return { | ||||
|             isLegendExpanded: this.legend.get('expanded') === true | ||||
|             isLegendExpanded: false, | ||||
|             seriesModels: [], | ||||
|             loaded: false | ||||
|         }; | ||||
|     }, | ||||
|     computed: { | ||||
|         showUnitsWhenExpanded() { | ||||
|             return this.legend.get('showUnitsWhenExpanded') === true; | ||||
|             return this.loaded && this.legend.get('showUnitsWhenExpanded') === true; | ||||
|         }, | ||||
|         showMinimumWhenExpanded() { | ||||
|             return this.legend.get('showMinimumWhenExpanded') === true; | ||||
|             return this.loaded && this.legend.get('showMinimumWhenExpanded') === true; | ||||
|         }, | ||||
|         showMaximumWhenExpanded() { | ||||
|             return this.legend.get('showMaximumWhenExpanded') === true; | ||||
|             return this.loaded && this.legend.get('showMaximumWhenExpanded') === true; | ||||
|         }, | ||||
|         showValueWhenExpanded() { | ||||
|             return this.legend.get('showValueWhenExpanded') === true; | ||||
|             return this.loaded && this.legend.get('showValueWhenExpanded') === true; | ||||
|         }, | ||||
|         showTimestampWhenExpanded() { | ||||
|             return this.legend.get('showTimestampWhenExpanded') === true; | ||||
|             return this.loaded && this.legend.get('showTimestampWhenExpanded') === true; | ||||
|         }, | ||||
|         isLegendHidden() { | ||||
|             return this.legend.get('hideLegendWhenSmall') === true; | ||||
|             return this.loaded && this.legend.get('hideLegendWhenSmall') === true; | ||||
|         }, | ||||
|         valueToShowWhenCollapsed() { | ||||
|             return this.loaded && this.legend.get('valueToShowWhenCollapsed'); | ||||
|         } | ||||
|     }, | ||||
|     mounted() { | ||||
|         this.seriesModels = []; | ||||
|         eventHelpers.extend(this); | ||||
|         this.config = this.getConfig(); | ||||
|         this.legend = this.config.legend; | ||||
|         this.loaded = true; | ||||
|         this.isLegendExpanded = this.legend.get('expanded') === true; | ||||
|         this.listenTo(this.config.legend, 'change:position', this.updatePosition, this); | ||||
|         this.updatePosition(); | ||||
|  | ||||
|         this.initialize(); | ||||
|     }, | ||||
|     beforeDestroy() { | ||||
|         if (this.objectComposition) { | ||||
|             this.objectComposition.off('add', this.addTelemetryObject); | ||||
|             this.objectComposition.off('remove', this.removeTelemetryObject); | ||||
|         } | ||||
|  | ||||
|         this.stopListening(); | ||||
|     }, | ||||
|     methods: { | ||||
|         initialize() { | ||||
|             if (this.domainObject.type === 'telemetry.plot.stacked') { | ||||
|                 this.objectComposition = this.openmct.composition.get(this.domainObject); | ||||
|                 this.objectComposition.on('add', this.addTelemetryObject); | ||||
|                 this.objectComposition.on('remove', this.removeTelemetryObject); | ||||
|                 this.objectComposition.load(); | ||||
|             } else { | ||||
|                 this.registerListeners(this.config); | ||||
|             } | ||||
|         }, | ||||
|         getConfig() { | ||||
|             const configId = this.openmct.objects.makeKeyString(this.domainObject.identifier); | ||||
|  | ||||
|             return configStore.get(configId); | ||||
|         }, | ||||
|         addTelemetryObject(object) { | ||||
|             //get the config for each child | ||||
|             const configId = this.openmct.objects.makeKeyString(object.identifier); | ||||
|             const config = configStore.get(configId); | ||||
|             if (config) { | ||||
|                 this.registerListeners(config); | ||||
|             } | ||||
|         }, | ||||
|         removeTelemetryObject(identifier) { | ||||
|             const configId = this.openmct.objects.makeKeyString(identifier); | ||||
|             const config = configStore.get(configId); | ||||
|             if (config) { | ||||
|                 config.series.forEach(this.removeSeries, this); | ||||
|             } | ||||
|         }, | ||||
|         registerListeners(config) { | ||||
|         //listen to any changes to the telemetry endpoints that are associated with the child | ||||
|             this.listenTo(config.series, 'add', this.addSeries, this); | ||||
|             this.listenTo(config.series, 'remove', this.removeSeries, this); | ||||
|             config.series.forEach(this.addSeries, this); | ||||
|         }, | ||||
|         addSeries(series) { | ||||
|             this.$set(this.seriesModels, this.seriesModels.length, series); | ||||
|         }, | ||||
|  | ||||
|         removeSeries(plotSeries) { | ||||
|             this.stopListening(plotSeries); | ||||
|  | ||||
|             const seriesIndex = this.seriesModels.findIndex(series => series.keyString === plotSeries.keyString); | ||||
|             this.seriesModels.splice(seriesIndex, 1); | ||||
|         }, | ||||
|         expandLegend() { | ||||
|             this.isLegendExpanded = !this.isLegendExpanded; | ||||
|             this.legend.set('expanded', this.isLegendExpanded); | ||||
|             this.$emit('expanded', this.isLegendExpanded); | ||||
|         }, | ||||
|         legendHoverChanged(data) { | ||||
|             this.$emit('legendHoverChanged', data); | ||||
|         }, | ||||
|         updatePosition() { | ||||
|             this.$emit('position', this.legend.get('position')); | ||||
|         } | ||||
|     } | ||||
| }; | ||||
|   | ||||
| @@ -57,15 +57,12 @@ | ||||
| import {getLimitClass} from "@/plugins/plot/chart/limitUtil"; | ||||
| import eventHelpers from "../lib/eventHelpers"; | ||||
| import stalenessMixin from '@/ui/mixins/staleness-mixin'; | ||||
| import configStore from "../configuration/ConfigStore"; | ||||
|  | ||||
| export default { | ||||
|     mixins: [stalenessMixin], | ||||
|     inject: ['openmct', 'domainObject'], | ||||
|     props: { | ||||
|         valueToShowWhenCollapsed: { | ||||
|             type: String, | ||||
|             required: true | ||||
|         }, | ||||
|         seriesObject: { | ||||
|             type: Object, | ||||
|             required: true, | ||||
| @@ -88,10 +85,14 @@ export default { | ||||
|             formattedYValue: '', | ||||
|             formattedXValue: '', | ||||
|             mctLimitStateClass: '', | ||||
|             formattedYValueFromStats: '' | ||||
|             formattedYValueFromStats: '', | ||||
|             loaded: false | ||||
|         }; | ||||
|     }, | ||||
|     computed: { | ||||
|         valueToShowWhenCollapsed() { | ||||
|             return this.loaded ? this.legend.get('valueToShowWhenCollapsed') : []; | ||||
|         }, | ||||
|         valueToDisplayWhenCollapsedClass() { | ||||
|             return `value-to-display-${ this.valueToShowWhenCollapsed }`; | ||||
|         }, | ||||
| @@ -109,6 +110,9 @@ export default { | ||||
|     }, | ||||
|     mounted() { | ||||
|         eventHelpers.extend(this); | ||||
|         this.config = this.getConfig(); | ||||
|         this.legend = this.config.legend; | ||||
|         this.loaded = true; | ||||
|         this.listenTo(this.seriesObject, 'change:color', (newColor) => { | ||||
|             this.updateColor(newColor); | ||||
|         }, this); | ||||
| @@ -122,8 +126,13 @@ export default { | ||||
|         this.stopListening(); | ||||
|     }, | ||||
|     methods: { | ||||
|         getConfig() { | ||||
|             const configId = this.openmct.objects.makeKeyString(this.domainObject.identifier); | ||||
|  | ||||
|             return configStore.get(configId); | ||||
|         }, | ||||
|         initialize(highlightedObject) { | ||||
|             const seriesObject = highlightedObject ? highlightedObject.series : this.seriesObject; | ||||
|             const seriesObject = highlightedObject?.series || this.seriesObject; | ||||
|  | ||||
|             this.isMissing = seriesObject.domainObject.status === 'missing'; | ||||
|             this.colorAsHexString = seriesObject.get('color').asHexString(); | ||||
|   | ||||
| @@ -83,6 +83,7 @@ | ||||
| import {getLimitClass} from "@/plugins/plot/chart/limitUtil"; | ||||
| import eventHelpers from "@/plugins/plot/lib/eventHelpers"; | ||||
| import stalenessMixin from '@/ui/mixins/staleness-mixin'; | ||||
| import configStore from "../configuration/ConfigStore"; | ||||
|  | ||||
| export default { | ||||
|     mixins: [stalenessMixin], | ||||
| @@ -100,10 +101,6 @@ export default { | ||||
|             default() { | ||||
|                 return []; | ||||
|             } | ||||
|         }, | ||||
|         legend: { | ||||
|             type: Object, | ||||
|             required: true | ||||
|         } | ||||
|     }, | ||||
|     data() { | ||||
| @@ -116,24 +113,25 @@ export default { | ||||
|             formattedXValue: '', | ||||
|             formattedMinY: '', | ||||
|             formattedMaxY: '', | ||||
|             mctLimitStateClass: '' | ||||
|             mctLimitStateClass: '', | ||||
|             loaded: false | ||||
|         }; | ||||
|     }, | ||||
|     computed: { | ||||
|         showUnitsWhenExpanded() { | ||||
|             return this.legend.get('showUnitsWhenExpanded') === true; | ||||
|             return this.loaded && this.legend.get('showUnitsWhenExpanded') === true; | ||||
|         }, | ||||
|         showMinimumWhenExpanded() { | ||||
|             return this.legend.get('showMinimumWhenExpanded') === true; | ||||
|             return this.loaded && this.legend.get('showMinimumWhenExpanded') === true; | ||||
|         }, | ||||
|         showMaximumWhenExpanded() { | ||||
|             return this.legend.get('showMaximumWhenExpanded') === true; | ||||
|             return this.loaded && this.legend.get('showMaximumWhenExpanded') === true; | ||||
|         }, | ||||
|         showValueWhenExpanded() { | ||||
|             return this.legend.get('showValueWhenExpanded') === true; | ||||
|             return this.loaded && this.legend.get('showValueWhenExpanded') === true; | ||||
|         }, | ||||
|         showTimestampWhenExpanded() { | ||||
|             return this.legend.get('showTimestampWhenExpanded') === true; | ||||
|             return this.loaded && this.legend.get('showTimestampWhenExpanded') === true; | ||||
|         } | ||||
|     }, | ||||
|     watch: { | ||||
| @@ -146,6 +144,9 @@ export default { | ||||
|     }, | ||||
|     mounted() { | ||||
|         eventHelpers.extend(this); | ||||
|         this.config = this.getConfig(); | ||||
|         this.legend = this.config.legend; | ||||
|         this.loaded = true; | ||||
|         this.listenTo(this.seriesObject, 'change:color', (newColor) => { | ||||
|             this.updateColor(newColor); | ||||
|         }, this); | ||||
| @@ -159,8 +160,13 @@ export default { | ||||
|         this.stopListening(); | ||||
|     }, | ||||
|     methods: { | ||||
|         getConfig() { | ||||
|             const configId = this.openmct.objects.makeKeyString(this.domainObject.identifier); | ||||
|  | ||||
|             return configStore.get(configId); | ||||
|         }, | ||||
|         initialize(highlightedObject) { | ||||
|             const seriesObject = highlightedObject ? highlightedObject.series : this.seriesObject; | ||||
|             const seriesObject = highlightedObject?.series || this.seriesObject; | ||||
|  | ||||
|             this.isMissing = seriesObject.domainObject.status === 'missing'; | ||||
|             this.colorAsHexString = seriesObject.get('color').asHexString(); | ||||
|   | ||||
| @@ -28,7 +28,7 @@ import EventEmitter from "EventEmitter"; | ||||
| import PlotOptions from "./inspector/PlotOptions.vue"; | ||||
| import PlotConfigurationModel from "./configuration/PlotConfigurationModel"; | ||||
|  | ||||
| const TEST_KEY_ID = 'test-key'; | ||||
| const TEST_KEY_ID = 'some-other-key'; | ||||
|  | ||||
| describe("the plugin", function () { | ||||
|     let element; | ||||
| @@ -533,6 +533,30 @@ describe("the plugin", function () { | ||||
|                 expect(openmct.telemetry.request).toHaveBeenCalledTimes(2); | ||||
|  | ||||
|             }); | ||||
|  | ||||
|             describe('limits', () => { | ||||
|  | ||||
|                 it('lines are not displayed by default', () => { | ||||
|                     let limitEl = element.querySelectorAll(".js-limit-area .js-limit-line"); | ||||
|                     expect(limitEl.length).toBe(0); | ||||
|                 }); | ||||
|  | ||||
|                 it('lines are displayed when configuration is set to true', (done) => { | ||||
|                     const configId = openmct.objects.makeKeyString(testTelemetryObject.identifier); | ||||
|                     const config = configStore.get(configId); | ||||
|                     config.yAxis.set('displayRange', { | ||||
|                         min: 0, | ||||
|                         max: 4 | ||||
|                     }); | ||||
|                     config.series.models[0].set('limitLines', true); | ||||
|  | ||||
|                     Vue.nextTick(() => { | ||||
|                         let limitEl = element.querySelectorAll(".js-limit-area .js-limit-line"); | ||||
|                         expect(limitEl.length).toBe(4); | ||||
|                         done(); | ||||
|                     }); | ||||
|                 }); | ||||
|             }); | ||||
|         }); | ||||
|  | ||||
|         describe('controls in time strip view', () => { | ||||
| @@ -867,24 +891,5 @@ describe("the plugin", function () { | ||||
|                 expect(colorSwatch).toBeDefined(); | ||||
|             }); | ||||
|         }); | ||||
|  | ||||
|         describe('limits', () => { | ||||
|  | ||||
|             it('lines are not displayed by default', () => { | ||||
|                 let limitEl = element.querySelectorAll(".js-limit-area .js-limit-line"); | ||||
|                 expect(limitEl.length).toBe(0); | ||||
|             }); | ||||
|  | ||||
|             xit('lines are displayed when configuration is set to true', (done) => { | ||||
|                 config.series.models[0].set('limitLines', true); | ||||
|  | ||||
|                 Vue.nextTick(() => { | ||||
|                     let limitEl = element.querySelectorAll(".js-limit-area .js-limit-line"); | ||||
|                     expect(limitEl.length).toBe(4); | ||||
|                     done(); | ||||
|                 }); | ||||
|  | ||||
|             }); | ||||
|         }); | ||||
|     }); | ||||
| }); | ||||
|   | ||||
| @@ -27,31 +27,34 @@ | ||||
|     :class="[plotLegendExpandedStateClass, plotLegendPositionClass]" | ||||
| > | ||||
|     <plot-legend | ||||
|         v-if="compositionObjectsConfigLoaded" | ||||
|         :cursor-locked="!!lockHighlightPoint" | ||||
|         :series="seriesModels" | ||||
|         :highlights="highlights" | ||||
|         :legend="legend" | ||||
|         @legendHoverChanged="legendHoverChanged" | ||||
|         @expanded="updateExpanded" | ||||
|         @position="updatePosition" | ||||
|     /> | ||||
|     <div class="l-view-section"> | ||||
|     <div | ||||
|         class="l-view-section" | ||||
|     > | ||||
|         <stacked-plot-item | ||||
|             v-for="object in compositionObjects" | ||||
|             :key="object.id" | ||||
|             v-for="objectWrapper in compositionObjects" | ||||
|             :key="objectWrapper.keyString" | ||||
|             class="c-plot--stacked-container" | ||||
|             :child-object="object" | ||||
|             :child-object="objectWrapper.object" | ||||
|             :options="options" | ||||
|             :grid-lines="gridLines" | ||||
|             :color-palette="colorPalette" | ||||
|             :cursor-guide="cursorGuide" | ||||
|             :show-limit-line-labels="showLimitLineLabels" | ||||
|             :plot-tick-width="maxTickWidth" | ||||
|             @plotTickWidth="onTickWidthChange" | ||||
|             :parent-y-tick-width="maxTickWidth" | ||||
|             @plotYTickWidth="onYTickWidthChange" | ||||
|             @loadingUpdated="loadingUpdated" | ||||
|             @cursorGuide="onCursorGuideChange" | ||||
|             @gridLines="onGridLinesChange" | ||||
|             @lockHighlightPoint="lockHighlightPointUpdated" | ||||
|             @highlights="highlightsUpdated" | ||||
|             @configLoaded="registerSeriesListeners" | ||||
|             @configLoaded="configLoadedForObject(objectWrapper.keyString)" | ||||
|         /> | ||||
|     </div> | ||||
| </div> | ||||
| @@ -66,14 +69,13 @@ import ColorPalette from "@/ui/color/ColorPalette"; | ||||
| import PlotLegend from "../legend/PlotLegend.vue"; | ||||
| import StackedPlotItem from './StackedPlotItem.vue'; | ||||
| import ImageExporter from '../../../exporters/ImageExporter'; | ||||
| import eventHelpers from "@/plugins/plot/lib/eventHelpers"; | ||||
|  | ||||
| export default { | ||||
|     components: { | ||||
|         StackedPlotItem, | ||||
|         PlotLegend | ||||
|     }, | ||||
|     inject: ['openmct', 'domainObject', 'composition', 'path'], | ||||
|     inject: ['openmct', 'domainObject', 'path'], | ||||
|     props: { | ||||
|         options: { | ||||
|             type: Object, | ||||
| @@ -87,48 +89,59 @@ export default { | ||||
|             hideExportButtons: false, | ||||
|             cursorGuide: false, | ||||
|             gridLines: true, | ||||
|             loading: false, | ||||
|             configLoaded: {}, | ||||
|             compositionObjects: [], | ||||
|             tickWidthMap: {}, | ||||
|             legend: {}, | ||||
|             loaded: false, | ||||
|             lockHighlightPoint: false, | ||||
|             highlights: [], | ||||
|             seriesModels: [], | ||||
|             showLimitLineLabels: undefined, | ||||
|             colorPalette: new ColorPalette() | ||||
|             colorPalette: new ColorPalette(), | ||||
|             compositionObjectsConfigLoaded: false, | ||||
|             position: 'top', | ||||
|             expanded: false | ||||
|         }; | ||||
|     }, | ||||
|     computed: { | ||||
|         plotLegendPositionClass() { | ||||
|             return `plot-legend-${this.config.legend.get('position')}`; | ||||
|             return `plot-legend-${this.position}`; | ||||
|         }, | ||||
|         plotLegendExpandedStateClass() { | ||||
|             if (this.config.legend.get('expanded')) { | ||||
|             if (this.expanded) { | ||||
|                 return 'plot-legend-expanded'; | ||||
|             } else { | ||||
|                 return 'plot-legend-collapsed'; | ||||
|             } | ||||
|         }, | ||||
|         /** | ||||
|        * Returns the maximum width of the left and right y axes ticks of this stacked plots children | ||||
|        * @returns {{rightTickWidth: number, leftTickWidth: number, hasMultipleLeftAxes: boolean}} | ||||
|        */ | ||||
|         maxTickWidth() { | ||||
|             return Math.max(...Object.values(this.tickWidthMap)); | ||||
|             const tickWidthValues = Object.values(this.tickWidthMap); | ||||
|             const maxLeftTickWidth = Math.max(...tickWidthValues.map(tickWidthItem => tickWidthItem.leftTickWidth)); | ||||
|             const maxRightTickWidth = Math.max(...tickWidthValues.map(tickWidthItem => tickWidthItem.rightTickWidth)); | ||||
|             const hasMultipleLeftAxes = tickWidthValues.some(tickWidthItem => tickWidthItem.hasMultipleLeftAxes === true); | ||||
|  | ||||
|             return { | ||||
|                 leftTickWidth: maxLeftTickWidth, | ||||
|                 rightTickWidth: maxRightTickWidth, | ||||
|                 hasMultipleLeftAxes | ||||
|             }; | ||||
|         } | ||||
|     }, | ||||
|     beforeDestroy() { | ||||
|         this.destroy(); | ||||
|     }, | ||||
|     mounted() { | ||||
|         eventHelpers.extend(this); | ||||
|         this.seriesConfig = {}; | ||||
|  | ||||
|         //We only need to initialize the stacked plot config for legend properties | ||||
|         const configId = this.openmct.objects.makeKeyString(this.domainObject.identifier); | ||||
|         this.config = this.getConfig(configId); | ||||
|  | ||||
|         this.legend = this.config.legend; | ||||
|  | ||||
|         this.loaded = true; | ||||
|         this.imageExporter = new ImageExporter(this.openmct); | ||||
|  | ||||
|         this.composition = this.openmct.composition.get(this.domainObject); | ||||
|         this.composition.on('add', this.addChild); | ||||
|         this.composition.on('remove', this.removeChild); | ||||
|         this.composition.on('reorder', this.compositionReorder); | ||||
| @@ -142,7 +155,6 @@ export default { | ||||
|                     id: configId, | ||||
|                     domainObject: this.domainObject, | ||||
|                     openmct: this.openmct, | ||||
|                     palette: this.colorPalette, | ||||
|                     callback: (data) => { | ||||
|                         this.data = data; | ||||
|                     } | ||||
| @@ -155,10 +167,19 @@ export default { | ||||
|         loadingUpdated(loaded) { | ||||
|             this.loading = loaded; | ||||
|         }, | ||||
|         destroy() { | ||||
|             this.stopListening(); | ||||
|             configStore.deleteStore(this.config.id); | ||||
|         configLoadedForObject(childObjIdentifier) { | ||||
|             const childObjId = this.openmct.objects.makeKeyString(childObjIdentifier); | ||||
|             this.configLoaded[childObjId] = true; | ||||
|             this.setConfigLoadedForComposition(); | ||||
|         }, | ||||
|         setConfigLoadedForComposition() { | ||||
|             this.compositionObjectsConfigLoaded = this.compositionObjects.length && this.compositionObjects.every(childObject => { | ||||
|                 const id = childObject.keyString; | ||||
|  | ||||
|                 return this.configLoaded[id] === true; | ||||
|             }); | ||||
|         }, | ||||
|         destroy() { | ||||
|             this.composition.off('add', this.addChild); | ||||
|             this.composition.off('remove', this.removeChild); | ||||
|             this.composition.off('reorder', this.compositionReorder); | ||||
| @@ -167,9 +188,16 @@ export default { | ||||
|         addChild(child) { | ||||
|             const id = this.openmct.objects.makeKeyString(child.identifier); | ||||
|  | ||||
|             this.$set(this.tickWidthMap, id, 0); | ||||
|             this.$set(this.tickWidthMap, id, { | ||||
|                 leftTickWidth: 0, | ||||
|                 rightTickWidth: 0 | ||||
|             }); | ||||
|  | ||||
|             this.compositionObjects.push(child); | ||||
|             this.compositionObjects.push({ | ||||
|                 object: child, | ||||
|                 keyString: id | ||||
|             }); | ||||
|             this.setConfigLoadedForComposition(); | ||||
|         }, | ||||
|  | ||||
|         removeChild(childIdentifier) { | ||||
| @@ -177,26 +205,36 @@ export default { | ||||
|  | ||||
|             this.$delete(this.tickWidthMap, id); | ||||
|  | ||||
|             const childObj = this.compositionObjects.filter((c) => { | ||||
|                 const identifier = c.keyString; | ||||
|  | ||||
|                 return identifier === id; | ||||
|             })[0]; | ||||
|  | ||||
|             if (childObj) { | ||||
|                 if (childObj.object.type !== 'telemetry.plot.overlay') { | ||||
|                     const config = this.getConfig(childObj.keyString); | ||||
|                     if (config) { | ||||
|                         config.series.remove(config.series.at(0)); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             this.compositionObjects = this.compositionObjects.filter((c) => { | ||||
|                 const identifier = c.keyString; | ||||
|  | ||||
|                 return identifier !== id; | ||||
|             }); | ||||
|  | ||||
|             const configIndex = this.domainObject.configuration.series.findIndex((seriesConfig) => { | ||||
|                 return this.openmct.objects.areIdsEqual(seriesConfig.identifier, childIdentifier); | ||||
|             }); | ||||
|             if (configIndex > -1) { | ||||
|                 this.domainObject.configuration.series.splice(configIndex, 1); | ||||
|                 const cSeries = this.domainObject.configuration.series.slice(); | ||||
|                 this.openmct.objects.mutate(this.domainObject, 'configuration.series', cSeries); | ||||
|             } | ||||
|  | ||||
|             this.removeSeries({ | ||||
|                 keyString: id | ||||
|             }); | ||||
|  | ||||
|             const childObj = this.compositionObjects.filter((c) => { | ||||
|                 const identifier = this.openmct.objects.makeKeyString(c.identifier); | ||||
|  | ||||
|                 return identifier === id; | ||||
|             })[0]; | ||||
|             if (childObj) { | ||||
|                 const index = this.compositionObjects.indexOf(childObj); | ||||
|                 this.compositionObjects.splice(index, 1); | ||||
|             } | ||||
|             this.setConfigLoadedForComposition(); | ||||
|         }, | ||||
|  | ||||
|         compositionReorder(reorderPlan) { | ||||
| @@ -209,7 +247,10 @@ export default { | ||||
|  | ||||
|         resetTelemetryAndTicks(domainObject) { | ||||
|             this.compositionObjects = []; | ||||
|             this.tickWidthMap = {}; | ||||
|             this.tickWidthMap = { | ||||
|                 leftTickWidth: 0, | ||||
|                 rightTickWidth: 0 | ||||
|             }; | ||||
|         }, | ||||
|  | ||||
|         exportJPG() { | ||||
| @@ -232,12 +273,18 @@ export default { | ||||
|                     this.hideExportButtons = false; | ||||
|                 }.bind(this)); | ||||
|         }, | ||||
|         onTickWidthChange(width, plotId) { | ||||
|         /** | ||||
|          * @typedef {Object} PlotYTickData | ||||
|          * @property {Number} leftTickWidth the width of the ticks for all the y axes on the left of the plot. | ||||
|          * @property {Number} rightTickWidth the width of the ticks for all the y axes on the right of the plot. | ||||
|          * @property {Boolean} hasMultipleLeftAxes whether or not there is more than one left y axis. | ||||
|          */ | ||||
|         onYTickWidthChange(data, plotId) { | ||||
|             if (!Object.prototype.hasOwnProperty.call(this.tickWidthMap, plotId)) { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             this.$set(this.tickWidthMap, plotId, width); | ||||
|             this.$set(this.tickWidthMap, plotId, data); | ||||
|         }, | ||||
|         legendHoverChanged(data) { | ||||
|             this.showLimitLineLabels = data; | ||||
| @@ -245,39 +292,18 @@ export default { | ||||
|         lockHighlightPointUpdated(data) { | ||||
|             this.lockHighlightPoint = data; | ||||
|         }, | ||||
|         updateExpanded(expanded) { | ||||
|             this.expanded = expanded; | ||||
|         }, | ||||
|         updatePosition(position) { | ||||
|             this.position = position; | ||||
|         }, | ||||
|         updateReady(ready) { | ||||
|             this.configReady = ready; | ||||
|         }, | ||||
|         highlightsUpdated(data) { | ||||
|             this.highlights = data; | ||||
|         }, | ||||
|         registerSeriesListeners(configId) { | ||||
|             const config = this.getConfig(configId); | ||||
|             this.seriesConfig[configId] = config; | ||||
|             const childObject = config.get('domainObject'); | ||||
|  | ||||
|             //TODO differentiate between objects with composition and those without | ||||
|             if (childObject.type === 'telemetry.plot.overlay') { | ||||
|                 this.listenTo(config.series, 'add', this.addSeries, this); | ||||
|                 this.listenTo(config.series, 'remove', this.removeSeries, this); | ||||
|             } | ||||
|  | ||||
|             config.series.models.forEach(this.addSeries, this); | ||||
|         }, | ||||
|         addSeries(series) { | ||||
|             const childObject = series.domainObject; | ||||
|             //don't add the series if it can have child series this will happen in registerSeriesListeners | ||||
|             if (childObject.type !== 'telemetry.plot.overlay') { | ||||
|                 const index = this.seriesModels.length; | ||||
|                 this.$set(this.seriesModels, index, series); | ||||
|             } | ||||
|  | ||||
|         }, | ||||
|         removeSeries(plotSeries) { | ||||
|             const index = this.seriesModels.findIndex(seriesModel => seriesModel.keyString === plotSeries.keyString); | ||||
|             if (index > -1) { | ||||
|                 this.$delete(this.seriesModels, index); | ||||
|             } | ||||
|  | ||||
|             this.stopListening(plotSeries); | ||||
|         }, | ||||
|         onCursorGuideChange(cursorGuide) { | ||||
|             this.cursorGuide = cursorGuide === true; | ||||
|         }, | ||||
|   | ||||
| @@ -20,7 +20,9 @@ | ||||
|  at runtime from the About dialog for additional information. | ||||
| --> | ||||
| <template> | ||||
| <div></div> | ||||
| <div | ||||
|     :aria-label="`Stacked Plot Item ${childObject.name}`" | ||||
| ></div> | ||||
| </template> | ||||
| <script> | ||||
|  | ||||
| @@ -28,6 +30,7 @@ import MctPlot from '../MctPlot.vue'; | ||||
| import Vue from "vue"; | ||||
| import conditionalStylesMixin from "./mixins/objectStyles-mixin"; | ||||
| import stalenessMixin from '@/ui/mixins/staleness-mixin'; | ||||
| import StalenessUtils from '@/utils/staleness'; | ||||
| import configStore from "@/plugins/plot/configuration/ConfigStore"; | ||||
| import PlotConfigurationModel from "@/plugins/plot/configuration/PlotConfigurationModel"; | ||||
| import ProgressBar from "../../../ui/components/ProgressBar.vue"; | ||||
| @@ -72,13 +75,22 @@ export default { | ||||
|                 return undefined; | ||||
|             } | ||||
|         }, | ||||
|         plotTickWidth: { | ||||
|             type: Number, | ||||
|         parentYTickWidth: { | ||||
|             type: Object, | ||||
|             default() { | ||||
|                 return 0; | ||||
|                 return { | ||||
|                     leftTickWidth: 0, | ||||
|                     rightTickWidth: 0, | ||||
|                     hasMultipleLeftAxes: false | ||||
|                 }; | ||||
|             } | ||||
|         } | ||||
|     }, | ||||
|     data() { | ||||
|         return { | ||||
|             staleObjects: [] | ||||
|         }; | ||||
|     }, | ||||
|     watch: { | ||||
|         gridLines(newGridLines) { | ||||
|             this.updateComponentProp('gridLines', newGridLines); | ||||
| @@ -86,20 +98,29 @@ export default { | ||||
|         cursorGuide(newCursorGuide) { | ||||
|             this.updateComponentProp('cursorGuide', newCursorGuide); | ||||
|         }, | ||||
|         plotTickWidth(width) { | ||||
|             this.updateComponentProp('plotTickWidth', width); | ||||
|         parentYTickWidth(width) { | ||||
|             this.updateComponentProp('parentYTickWidth', width); | ||||
|         }, | ||||
|         showLimitLineLabels: { | ||||
|             handler(data) { | ||||
|                 this.updateComponentProp('limitLineLabels', data); | ||||
|             }, | ||||
|             deep: true | ||||
|         }, | ||||
|         staleObjects() { | ||||
|             this.isStale = this.staleObjects.length > 0; | ||||
|             this.updateComponentProp('isStale', this.isStale); | ||||
|         } | ||||
|     }, | ||||
|     mounted() { | ||||
|         this.stalenessSubscription = {}; | ||||
|         this.updateView(); | ||||
|         this.isEditing = this.openmct.editor.isEditing(); | ||||
|         this.openmct.editor.on('isEditing', this.setEditState); | ||||
|     }, | ||||
|     beforeDestroy() { | ||||
|         this.openmct.editor.off('isEditing', this.setEditState); | ||||
|  | ||||
|         if (this.removeSelectable) { | ||||
|             this.removeSelectable(); | ||||
|         } | ||||
| @@ -107,8 +128,22 @@ export default { | ||||
|         if (this.component) { | ||||
|             this.component.$destroy(); | ||||
|         } | ||||
|  | ||||
|         this.destroyStalenessListeners(); | ||||
|     }, | ||||
|     methods: { | ||||
|         setEditState(isEditing) { | ||||
|             this.isEditing = isEditing; | ||||
|  | ||||
|             if (this.isEditing) { | ||||
|                 this.setSelection(); | ||||
|             } else { | ||||
|                 if (this.removeSelectable) { | ||||
|                     this.removeSelectable(); | ||||
|                 } | ||||
|             } | ||||
|         }, | ||||
|  | ||||
|         updateComponentProp(prop, value) { | ||||
|             if (this.component) { | ||||
|                 this.component[prop] = value; | ||||
| @@ -117,15 +152,15 @@ export default { | ||||
|         updateView() { | ||||
|             this.isStale = false; | ||||
|  | ||||
|             this.triggerUnsubscribeFromStaleness(); | ||||
|             this.destroyStalenessListeners(); | ||||
|  | ||||
|             if (this.component) { | ||||
|                 this.component.$destroy(); | ||||
|                 this.component = undefined; | ||||
|                 this.component = null; | ||||
|                 this.$el.innerHTML = ''; | ||||
|             } | ||||
|  | ||||
|             const onTickWidthChange = this.onTickWidthChange; | ||||
|             const onYTickWidthChange = this.onYTickWidthChange; | ||||
|             const onLockHighlightPointUpdated = this.onLockHighlightPointUpdated; | ||||
|             const onHighlightsUpdated = this.onHighlightsUpdated; | ||||
|             const onConfigLoaded = this.onConfigLoaded; | ||||
| @@ -144,9 +179,18 @@ export default { | ||||
|             let viewContainer = document.createElement('div'); | ||||
|             this.$el.append(viewContainer); | ||||
|  | ||||
|             this.subscribeToStaleness(object, (isStale) => { | ||||
|                 this.updateComponentProp('isStale', isStale); | ||||
|             }); | ||||
|             if (this.openmct.telemetry.isTelemetryObject(object)) { | ||||
|                 this.subscribeToStaleness(object, (isStale) => { | ||||
|                     this.updateComponentProp('isStale', isStale); | ||||
|                 }); | ||||
|             } else { | ||||
|                 // possibly overlay or other composition based plot | ||||
|                 this.composition = this.openmct.composition.get(object); | ||||
|  | ||||
|                 this.composition.on('add', this.watchStaleness); | ||||
|                 this.composition.on('remove', this.unwatchStaleness); | ||||
|                 this.composition.load(); | ||||
|             } | ||||
|  | ||||
|             this.component = new Vue({ | ||||
|                 el: viewContainer, | ||||
| @@ -162,7 +206,7 @@ export default { | ||||
|                 data() { | ||||
|                     return { | ||||
|                         ...getProps(), | ||||
|                         onTickWidthChange, | ||||
|                         onYTickWidthChange, | ||||
|                         onLockHighlightPointUpdated, | ||||
|                         onHighlightsUpdated, | ||||
|                         onConfigLoaded, | ||||
| @@ -178,10 +222,72 @@ export default { | ||||
|                         this.loading = loaded; | ||||
|                     } | ||||
|                 }, | ||||
|                 template: '<div v-if="!isMissing" ref="plotWrapper" class="l-view-section u-style-receiver js-style-receiver" :class="{\'s-status-timeconductor-unsynced\': status && status === \'timeconductor-unsynced\', \'is-stale\': isStale}"><progress-bar v-show="loading !== false" class="c-telemetry-table__progress-bar" :model="{progressPerc: undefined}" /><mct-plot :init-grid-lines="gridLines" :init-cursor-guide="cursorGuide" :plot-tick-width="plotTickWidth" :limit-line-labels="limitLineLabels" :color-palette="colorPalette" :options="options" @plotTickWidth="onTickWidthChange" @lockHighlightPoint="onLockHighlightPointUpdated" @highlights="onHighlightsUpdated" @configLoaded="onConfigLoaded" @cursorGuide="onCursorGuideChange" @gridLines="onGridLinesChange" @statusUpdated="setStatus" @loadingUpdated="loadingUpdated"/></div>' | ||||
|                 template: ` | ||||
|                   <div v-if="!isMissing" ref="plotWrapper" | ||||
|                       class="l-view-section u-style-receiver js-style-receiver" | ||||
|                       :class="{'s-status-timeconductor-unsynced': status && status === 'timeconductor-unsynced', 'is-stale': isStale}"> | ||||
|                       <progress-bar | ||||
|                           v-show="loading !== false" | ||||
|                           class="c-telemetry-table__progress-bar" | ||||
|                           :model="{progressPerc: undefined}" /> | ||||
|                       <mct-plot | ||||
|                           :init-grid-lines="gridLines" | ||||
|                           :init-cursor-guide="cursorGuide" | ||||
|                           :parent-y-tick-width="parentYTickWidth" | ||||
|                           :limit-line-labels="limitLineLabels" | ||||
|                           :color-palette="colorPalette" | ||||
|                           :options="options" | ||||
|                           @plotYTickWidth="onYTickWidthChange" | ||||
|                           @lockHighlightPoint="onLockHighlightPointUpdated" | ||||
|                           @highlights="onHighlightsUpdated" | ||||
|                           @configLoaded="onConfigLoaded" | ||||
|                           @cursorGuide="onCursorGuideChange" | ||||
|                           @gridLines="onGridLinesChange" | ||||
|                           @statusUpdated="setStatus" | ||||
|                           @loadingUpdated="loadingUpdated"/> | ||||
|                   </div>` | ||||
|             }); | ||||
|  | ||||
|             this.setSelection(); | ||||
|             if (this.isEditing) { | ||||
|                 this.setSelection(); | ||||
|             } | ||||
|         }, | ||||
|         watchStaleness(domainObject) { | ||||
|             const keyString = this.openmct.objects.makeKeyString(domainObject.identifier); | ||||
|             this.stalenessSubscription[keyString] = {}; | ||||
|             this.stalenessSubscription[keyString].stalenessUtils = new StalenessUtils(this.openmct, domainObject); | ||||
|  | ||||
|             this.openmct.telemetry.isStale(domainObject).then((stalenessResponse) => { | ||||
|                 if (stalenessResponse !== undefined) { | ||||
|                     this.handleStaleness(keyString, stalenessResponse); | ||||
|                 } | ||||
|             }); | ||||
|             const stalenessSubscription = this.openmct.telemetry.subscribeToStaleness(domainObject, (stalenessResponse) => { | ||||
|                 this.handleStaleness(keyString, stalenessResponse); | ||||
|             }); | ||||
|  | ||||
|             this.stalenessSubscription[keyString].unsubscribe = stalenessSubscription; | ||||
|         }, | ||||
|         unwatchStaleness(domainObject) { | ||||
|             const SKIP_CHECK = true; | ||||
|             const keyString = this.openmct.objects.makeKeyString(domainObject.identifier); | ||||
|  | ||||
|             this.stalenessSubscription[keyString].unsubscribe(); | ||||
|             this.stalenessSubscription[keyString].stalenessUtils.destroy(); | ||||
|             this.handleStaleness(keyString, { isStale: false }, SKIP_CHECK); | ||||
|  | ||||
|             delete this.stalenessSubscription[keyString]; | ||||
|         }, | ||||
|         handleStaleness(keyString, stalenessResponse, skipCheck = false) { | ||||
|             if (skipCheck || this.stalenessSubscription[keyString].stalenessUtils.shouldUpdateStaleness(stalenessResponse)) { | ||||
|                 const index = this.staleObjects.indexOf(keyString); | ||||
|                 const foundStaleObject = index > -1; | ||||
|                 if (stalenessResponse.isStale && !foundStaleObject) { | ||||
|                     this.staleObjects.push(keyString); | ||||
|                 } else if (!stalenessResponse.isStale && foundStaleObject) { | ||||
|                     this.staleObjects.splice(index, 1); | ||||
|                 } | ||||
|             } | ||||
|         }, | ||||
|         onLockHighlightPointUpdated() { | ||||
|             this.$emit('lockHighlightPoint', ...arguments); | ||||
| @@ -192,8 +298,8 @@ export default { | ||||
|         onConfigLoaded() { | ||||
|             this.$emit('configLoaded', ...arguments); | ||||
|         }, | ||||
|         onTickWidthChange() { | ||||
|             this.$emit('plotTickWidth', ...arguments); | ||||
|         onYTickWidthChange() { | ||||
|             this.$emit('plotYTickWidth', ...arguments); | ||||
|         }, | ||||
|         onCursorGuideChange() { | ||||
|             this.$emit('cursorGuide', ...arguments); | ||||
| @@ -221,7 +327,7 @@ export default { | ||||
|                 limitLineLabels: this.showLimitLineLabels, | ||||
|                 gridLines: this.gridLines, | ||||
|                 cursorGuide: this.cursorGuide, | ||||
|                 plotTickWidth: this.plotTickWidth, | ||||
|                 parentYTickWidth: this.parentYTickWidth, | ||||
|                 options: this.options, | ||||
|                 status: this.status, | ||||
|                 colorPalette: this.colorPalette, | ||||
| @@ -230,7 +336,7 @@ export default { | ||||
|         }, | ||||
|         getPlotObject() { | ||||
|             if (this.childObject.configuration && this.childObject.configuration.series) { | ||||
|                 //If the object has a configuration, allow initialization of the config from it's persisted config | ||||
|                 //If the object has a configuration (like an overlay plot), allow initialization of the config from it's persisted config | ||||
|                 return this.childObject; | ||||
|             } else { | ||||
|                 //If object is missing, warn and return object | ||||
| @@ -281,6 +387,20 @@ export default { | ||||
|  | ||||
|                 return this.childObject; | ||||
|             } | ||||
|         }, | ||||
|         destroyStalenessListeners() { | ||||
|             this.triggerUnsubscribeFromStaleness(); | ||||
|  | ||||
|             if (this.composition) { | ||||
|                 this.composition.off('add', this.watchStaleness); | ||||
|                 this.composition.off('remove', this.unwatchStaleness); | ||||
|                 this.composition = null; | ||||
|             } | ||||
|  | ||||
|             Object.values(this.stalenessSubscription).forEach(stalenessSubscription => { | ||||
|                 stalenessSubscription.unsubscribe(); | ||||
|                 stalenessSubscription.stalenessUtils.destroy(); | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
| }; | ||||
|   | ||||
| @@ -57,7 +57,6 @@ export default function StackedPlotViewProvider(openmct) { | ||||
|                         provide: { | ||||
|                             openmct, | ||||
|                             domainObject, | ||||
|                             composition: openmct.composition.get(domainObject), | ||||
|                             path: objectPath | ||||
|                         }, | ||||
|                         data() { | ||||
|   | ||||
| @@ -173,7 +173,7 @@ describe("the plugin", function () { | ||||
|         let testTelemetryObject2; | ||||
|         let config; | ||||
|         let component; | ||||
|         let mockComposition; | ||||
|         let mockCompositionList = []; | ||||
|         let plotViewComponentObject; | ||||
|  | ||||
|         afterAll(() => { | ||||
| @@ -271,14 +271,34 @@ describe("the plugin", function () { | ||||
|                 } | ||||
|             }; | ||||
|  | ||||
|             mockComposition = new EventEmitter(); | ||||
|             mockComposition.load = () => { | ||||
|                 mockComposition.emit('add', testTelemetryObject); | ||||
|             stackedPlotObject.composition = [{ | ||||
|                 identifier: testTelemetryObject.identifier | ||||
|             }]; | ||||
|  | ||||
|                 return [testTelemetryObject]; | ||||
|             }; | ||||
|             mockCompositionList = []; | ||||
|             spyOn(openmct.composition, 'get').and.callFake((domainObject) => { | ||||
|                 //We need unique compositions here - one for the StackedPlot view and one for the PlotLegend view | ||||
|                 const numObjects = domainObject.composition.length; | ||||
|                 const mockComposition = new EventEmitter(); | ||||
|                 mockComposition.load = () => { | ||||
|                     if (numObjects === 1) { | ||||
|                         mockComposition.emit('add', testTelemetryObject); | ||||
|  | ||||
|             spyOn(openmct.composition, 'get').and.returnValue(mockComposition); | ||||
|                         return [testTelemetryObject]; | ||||
|                     } else if (numObjects === 2) { | ||||
|                         mockComposition.emit('add', testTelemetryObject); | ||||
|                         mockComposition.emit('add', testTelemetryObject2); | ||||
|  | ||||
|                         return [testTelemetryObject, testTelemetryObject2]; | ||||
|                     } else { | ||||
|                         return []; | ||||
|                     } | ||||
|                 }; | ||||
|  | ||||
|                 mockCompositionList.push(mockComposition); | ||||
|  | ||||
|                 return mockComposition; | ||||
|             }); | ||||
|  | ||||
|             let viewContainer = document.createElement("div"); | ||||
|             child.append(viewContainer); | ||||
| @@ -290,7 +310,6 @@ describe("the plugin", function () { | ||||
|                 provide: { | ||||
|                     openmct: openmct, | ||||
|                     domainObject: stackedPlotObject, | ||||
|                     composition: openmct.composition.get(stackedPlotObject), | ||||
|                     path: [stackedPlotObject] | ||||
|                 }, | ||||
|                 template: "<stacked-plot></stacked-plot>" | ||||
| @@ -321,7 +340,7 @@ describe("the plugin", function () { | ||||
|             expect(legend.length).toBe(6); | ||||
|         }); | ||||
|  | ||||
|         it("Renders X-axis ticks for the telemetry object", (done) => { | ||||
|         it("Renders X-axis ticks for the telemetry object", () => { | ||||
|             let xAxisElement = element.querySelectorAll(".gl-plot-axis-area.gl-plot-x .gl-plot-tick-wrapper"); | ||||
|             expect(xAxisElement.length).toBe(1); | ||||
|  | ||||
| @@ -329,13 +348,8 @@ describe("the plugin", function () { | ||||
|                 min: 0, | ||||
|                 max: 4 | ||||
|             }); | ||||
|  | ||||
|             Vue.nextTick(() => { | ||||
|                 let ticks = xAxisElement[0].querySelectorAll(".gl-plot-tick"); | ||||
|                 expect(ticks.length).toBe(9); | ||||
|  | ||||
|                 done(); | ||||
|             }); | ||||
|             let ticks = xAxisElement[0].querySelectorAll(".gl-plot-tick"); | ||||
|             expect(ticks.length).toBe(9); | ||||
|         }); | ||||
|  | ||||
|         it("Renders Y-axis ticks for the telemetry object", (done) => { | ||||
| @@ -401,17 +415,22 @@ describe("the plugin", function () { | ||||
|         }); | ||||
|  | ||||
|         it('plots a new series when a new telemetry object is added', (done) => { | ||||
|             mockComposition.emit('add', testTelemetryObject2); | ||||
|             //setting composition here so that any new triggers to composition.load with correctly load the mockComposition in the beforeEach | ||||
|             stackedPlotObject.composition = [testTelemetryObject, testTelemetryObject2]; | ||||
|             mockCompositionList[0].emit('add', testTelemetryObject2); | ||||
|  | ||||
|             Vue.nextTick(() => { | ||||
|                 let legend = element.querySelectorAll(".plot-wrapper-collapsed-legend .plot-series-name"); | ||||
|                 expect(legend.length).toBe(2); | ||||
|                 expect(legend[1].innerHTML).toEqual("Test Object2"); | ||||
|                 done(); | ||||
|             }); | ||||
|  | ||||
|         }); | ||||
|  | ||||
|         it('removes plots from series when a telemetry object is removed', (done) => { | ||||
|             mockComposition.emit('remove', testTelemetryObject.identifier); | ||||
|             stackedPlotObject.composition = []; | ||||
|             mockCompositionList[0].emit('remove', testTelemetryObject.identifier); | ||||
|             Vue.nextTick(() => { | ||||
|                 expect(plotViewComponentObject.compositionObjects.length).toBe(0); | ||||
|                 done(); | ||||
| @@ -429,16 +448,6 @@ describe("the plugin", function () { | ||||
|             }); | ||||
|         }); | ||||
|  | ||||
|         it("Renders a new series when added to one of the plots", (done) => { | ||||
|             mockComposition.emit('add', testTelemetryObject2); | ||||
|             Vue.nextTick(() => { | ||||
|                 let legend = element.querySelectorAll(".plot-wrapper-collapsed-legend .plot-series-name"); | ||||
|                 expect(legend.length).toBe(2); | ||||
|                 expect(legend[1].innerHTML).toEqual("Test Object2"); | ||||
|                 done(); | ||||
|             }); | ||||
|         }); | ||||
|  | ||||
|         it("Adds a new point to the plot", (done) => { | ||||
|             let originalLength = config.series.models[0].getSeriesData().length; | ||||
|             config.series.models[0].add({ | ||||
| @@ -459,7 +468,7 @@ describe("the plugin", function () { | ||||
|                 max: 10 | ||||
|             }); | ||||
|             Vue.nextTick(() => { | ||||
|                 expect(plotViewComponentObject.$children[1].component.$children[1].xScale.domain()).toEqual({ | ||||
|                 expect(plotViewComponentObject.$children[0].component.$children[1].xScale.domain()).toEqual({ | ||||
|                     min: 0, | ||||
|                     max: 10 | ||||
|                 }); | ||||
| @@ -476,7 +485,7 @@ describe("the plugin", function () { | ||||
|                 }); | ||||
|             }); | ||||
|             Vue.nextTick(() => { | ||||
|                 const yAxesScales = plotViewComponentObject.$children[1].component.$children[1].yScale; | ||||
|                 const yAxesScales = plotViewComponentObject.$children[0].component.$children[1].yScale; | ||||
|                 yAxesScales.forEach((yAxisScale) => { | ||||
|                     expect(yAxisScale.scale.domain()).toEqual({ | ||||
|                         min: 10, | ||||
|   | ||||
| @@ -293,6 +293,7 @@ define([ | ||||
|             this.stalenessSubscription[keyString].unsubscribe(); | ||||
|             this.stalenessSubscription[keyString].stalenessUtils.destroy(); | ||||
|             this.handleStaleness(keyString, { isStale: false }, SKIP_CHECK); | ||||
|             delete this.stalenessSubscription[keyString]; | ||||
|         } | ||||
|  | ||||
|         clearData() { | ||||
|   | ||||
| @@ -390,7 +390,7 @@ $colorItemTreeHoverBg: rgba(#fff, 0.1); | ||||
| $colorItemTreeHoverFg: #fff; | ||||
| $colorItemTreeIcon: $colorKey; // Used | ||||
| $colorItemTreeIconHover: $colorItemTreeIcon; // Used | ||||
| $colorItemTreeFg: $colorBodyFg; | ||||
| $colorItemTreeFg: #ccc; | ||||
| $colorItemTreeSelectedBg: $colorSelectedBg; | ||||
| $colorItemTreeSelectedFg: $colorItemTreeHoverFg; | ||||
| $filterItemTreeSelected: $filterHov; | ||||
|   | ||||
| @@ -394,7 +394,7 @@ $colorItemTreeHoverBg: rgba(#fff, 0.03); | ||||
| $colorItemTreeHoverFg: #fff; | ||||
| $colorItemTreeIcon: $colorKey; // Used | ||||
| $colorItemTreeIconHover: $colorItemTreeIcon; // Used | ||||
| $colorItemTreeFg: $colorBodyFg; | ||||
| $colorItemTreeFg: $colorA; | ||||
| $colorItemTreeSelectedBg: $colorSelectedBg; | ||||
| $colorItemTreeSelectedFg: $colorItemTreeHoverFg; | ||||
| $filterItemTreeSelected: $filterHov; | ||||
|   | ||||
| @@ -94,7 +94,7 @@ $messageListIconD: 32px; | ||||
| $tableResizeColHitareaD: 6px; | ||||
| /*************** Misc */ | ||||
| $drawingObjBorderW: 3px; | ||||
|  | ||||
| $tagBorderRadius: 3px; | ||||
| /************************** MOBILE */ | ||||
| $mobileMenuIconD: 24px; // Used | ||||
| $mobileTreeItemH: 35px; // Used | ||||
|   | ||||
| @@ -270,9 +270,11 @@ button { | ||||
|     flex: 0 0 auto; | ||||
|     width: $d; | ||||
|     position: relative; | ||||
|     visibility: hidden; | ||||
|  | ||||
|     &.is-enabled { | ||||
|         cursor: pointer; | ||||
|         visibility: visible; | ||||
|  | ||||
|         &:hover { | ||||
|             color: $colorDisclosureCtrlHov; | ||||
| @@ -403,16 +405,18 @@ textarea { | ||||
|  | ||||
|     &--autocomplete { | ||||
|         &__wrapper { | ||||
|             display: inline-flex; | ||||
|             display: flex; | ||||
|             flex-direction: row; | ||||
|             align-items: center; | ||||
|             overflow: hidden; | ||||
|             width: 100%; | ||||
|         } | ||||
|  | ||||
|         &__input { | ||||
|             min-width: 100px; | ||||
|             width: 100%; | ||||
|  | ||||
|             // Fend off from afford-arrow | ||||
|             min-height: 2em; | ||||
|             padding-right: 2.5em !important; | ||||
|         } | ||||
|  | ||||
| @@ -435,7 +439,10 @@ textarea { | ||||
|         } | ||||
|  | ||||
|         &__afford-arrow { | ||||
|             $p: 2px; | ||||
|             font-size: 0.8em; | ||||
|             padding-bottom: $p; | ||||
|             padding-top: $p; | ||||
|             position: absolute; | ||||
|             right: 2px; | ||||
|             z-index: 2; | ||||
|   | ||||
| @@ -664,7 +664,6 @@ mct-plot { | ||||
|             border-radius: $smallCr; | ||||
|             display: flex; | ||||
|             justify-content: stretch; | ||||
|             padding: 1px; | ||||
|  | ||||
|             .plot-series-swatch-and-name, | ||||
|             .plot-series-value { | ||||
|   | ||||
| @@ -42,7 +42,6 @@ | ||||
| @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"; | ||||
|   | ||||
| @@ -24,6 +24,8 @@ | ||||
| <ul | ||||
|     v-if="orderedPath.length" | ||||
|     class="c-location" | ||||
|     :aria-label="`${domainObject.name} Breadcrumb`" | ||||
|     role="navigation" | ||||
| > | ||||
|     <li | ||||
|         v-for="pathObject in orderedPath" | ||||
| @@ -34,6 +36,7 @@ | ||||
|             :domain-object="pathObject.domainObject" | ||||
|             :object-path="pathObject.objectPath" | ||||
|             :read-only="readOnly" | ||||
|             :navigate-to-path="navigateToPath(pathObject.objectPath)" | ||||
|         /> | ||||
|     </li> | ||||
| </ul> | ||||
| @@ -110,6 +113,18 @@ export default { | ||||
|                 this.orderedPath = pathWithDomainObject.slice(1, pathWithDomainObject.length - 1).reverse(); | ||||
|             } | ||||
|         } | ||||
|     }, | ||||
|     methods: { | ||||
|         /** | ||||
|          * Generate the hash url for the given object path, removing the '/ROOT' prefix if present. | ||||
|          * @param {import('../../api/objects/ObjectAPI').DomainObject[]} objectPath | ||||
|          */ | ||||
|         navigateToPath(objectPath) { | ||||
|             /** @type {String} */ | ||||
|             const path = `/browse/${this.openmct.objects.getRelativePath(objectPath)}`; | ||||
|  | ||||
|             return path.replace('ROOT/', ''); | ||||
|         } | ||||
|     } | ||||
| }; | ||||
| </script> | ||||
|   | ||||
| @@ -21,10 +21,11 @@ | ||||
|  *****************************************************************************/ | ||||
|  | ||||
| <template> | ||||
| <div class="c-tag-applier"> | ||||
| <div class="c-tag-applier has-tag-applier"> | ||||
|     <TagSelection | ||||
|         v-for="(addedTag, index) in addedTags" | ||||
|         :key="index" | ||||
|         :class="{ 'w-tag-wrapper--tag-selector' : addedTag.newTag }" | ||||
|         :selected-tag="addedTag.newTag ? null : addedTag" | ||||
|         :new-tag="addedTag.newTag" | ||||
|         :added-tags="addedTags" | ||||
|   | ||||
| @@ -21,8 +21,8 @@ | ||||
|  *****************************************************************************/ | ||||
|  | ||||
| <template> | ||||
| <div class="c-tag__parent"> | ||||
|     <div class="c-tag_selection"> | ||||
| <div class="w-tag-wrapper"> | ||||
|     <template v-if="newTag"> | ||||
|         <AutoCompleteField | ||||
|             v-if="newTag" | ||||
|             ref="tagSelection" | ||||
| @@ -32,8 +32,9 @@ | ||||
|             :item-css-class="'icon-circle'" | ||||
|             @onChange="tagSelected" | ||||
|         /> | ||||
|     </template> | ||||
|     <template v-else> | ||||
|         <div | ||||
|             v-else | ||||
|             class="c-tag" | ||||
|             :class="{'c-tag-edit': !readOnly}" | ||||
|             :style="{ background: selectedBackgroundColor, color: selectedForegroundColor }" | ||||
| @@ -48,7 +49,7 @@ | ||||
|                 @click="removeTag" | ||||
|             ></button> | ||||
|         </div> | ||||
|     </div> | ||||
|     </template> | ||||
| </div> | ||||
| </template> | ||||
|  | ||||
|   | ||||
| @@ -1,19 +1,30 @@ | ||||
| @mixin tagHolder() { | ||||
|   align-items: center; | ||||
|   display: flex; | ||||
|   flex-wrap: wrap; | ||||
|  | ||||
|   > * { | ||||
|     $m: $interiorMarginSm; | ||||
|  | ||||
|     margin: 0 $m $m 0; | ||||
|   } | ||||
| } | ||||
|  | ||||
|  | ||||
| /******************************* TAGS */ | ||||
| .c-tag { | ||||
|   border-radius: 10px; //TODO: convert to theme constant | ||||
|   border-radius: $tagBorderRadius; | ||||
|   display: inline-flex; | ||||
|   padding: 1px 10px; //TODO: convert to theme constant | ||||
|  | ||||
|   > * + * { | ||||
|     margin-left: $interiorMargin; | ||||
|   } | ||||
|   overflow: hidden; | ||||
|   padding: 1px 6px; //TODO: convert to theme constant | ||||
|   transition: $transIn; | ||||
|  | ||||
|   &__remove-btn { | ||||
|     color: inherit !important; | ||||
|     display: none; | ||||
|     opacity: 0; | ||||
|     overflow: hidden; | ||||
|     padding: 1px !important; | ||||
|     padding: 0; // Overrides default <button> padding | ||||
|     position: absolute; | ||||
|     right: 2px; | ||||
|     transition: $transIn; | ||||
|     width: 0; | ||||
|  | ||||
| @@ -28,28 +39,47 @@ | ||||
|   } | ||||
| } | ||||
|  | ||||
| /******************************* TAG EDITOR */ | ||||
| .c-tag-applier { | ||||
|   display: flex; | ||||
|   flex-direction: row; | ||||
|   flex-wrap: wrap; | ||||
|   align-items: center; | ||||
| .c-tag-holder { | ||||
|   @include tagHolder; | ||||
| } | ||||
|  | ||||
|   > * + * { | ||||
|     margin-left: $interiorMargin; | ||||
| .w-tag-wrapper { | ||||
|     $m: $interiorMarginSm; | ||||
|  | ||||
|     margin: 0 $m $m 0; | ||||
| } | ||||
|  | ||||
| /******************************* TAGS IN INSPECTOR / TAG SELECTION & APPLICATION */ | ||||
| .c-tag-applier { | ||||
|   $tagApplierPadding: 3px 6px; | ||||
|   @include tagHolder; | ||||
|   grid-column: 1 / 3; | ||||
|  | ||||
|   &__tags { | ||||
|     display: flex; | ||||
|     flex-wrap: wrap; | ||||
|     align-items: center; | ||||
|   } | ||||
|  | ||||
|   &__add-btn { | ||||
|     border-radius: $tagBorderRadius; | ||||
|     padding: 3px 10px 3px 4px; | ||||
|  | ||||
|     &:before { font-size: 0.9em; } | ||||
|   } | ||||
|  | ||||
|   .c-tag { | ||||
|     flex-direction: row; | ||||
|     align-items: center; | ||||
|     padding-right: 3px !important; | ||||
|     padding: $tagApplierPadding; | ||||
|  | ||||
|     &__remove-btn { | ||||
|       display: block; | ||||
|     > * + * { margin-left: $interiorMarginSm; } | ||||
|   } | ||||
|  | ||||
|   .c-tag-selection { | ||||
|     .c-input--autocomplete__input { | ||||
|       min-height: auto !important; | ||||
|       padding: $tagApplierPadding; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -62,10 +92,18 @@ | ||||
| .has-tag-applier { | ||||
|   // Apply this class to all components that should trigger tag removal btn on hover | ||||
|    &:hover { | ||||
|      .c-tag__remove-btn { | ||||
|        width: 1.1em; | ||||
|        opacity: 0.7; | ||||
|      .c-tag { | ||||
|        padding-right: 17px !important; | ||||
|        transition: $transOut; | ||||
|      } | ||||
|  | ||||
|      .c-tag__remove-btn { | ||||
|        //display: block; | ||||
|        //margin-left: $interiorMarginSm; | ||||
|        width: 1em; | ||||
|        opacity: 0.8; | ||||
|        transition: $transOut; | ||||
|        //transition-delay: 250ms; | ||||
|      } | ||||
|    } | ||||
|  } | ||||
|   | ||||
| @@ -30,7 +30,7 @@ | ||||
|     @drop="emitDropEvent" | ||||
| > | ||||
|     <div | ||||
|         class="c-tree__item c-elements-pool__item" | ||||
|         class="c-tree__item c-elements-pool__item js-elements-pool__item" | ||||
|         :class="{ | ||||
|             'is-context-clicked': contextClickActive, | ||||
|             'hover': hover, | ||||
|   | ||||
| @@ -109,6 +109,8 @@ import StylesInspectorView from "@/ui/inspector/styles/StylesInspectorView.vue"; | ||||
| import SavedStylesInspectorView from "@/ui/inspector/styles/SavedStylesInspectorView.vue"; | ||||
| import AnnotationsInspectorView from "./annotations/AnnotationsInspectorView.vue"; | ||||
|  | ||||
| const OVERLAY_PLOT_TYPE = "telemetry.plot.overlay"; | ||||
|  | ||||
| export default { | ||||
|     components: { | ||||
|         StylesInspectorView, | ||||
| @@ -189,12 +191,12 @@ export default { | ||||
|         }, | ||||
|         refreshComposition(selection) { | ||||
|             if (selection.length > 0 && selection[0].length > 0) { | ||||
|                 let parentObject = selection[0][0].context.item; | ||||
|                 const parentObject = selection[0][0].context.item; | ||||
|  | ||||
|                 this.hasComposition = Boolean( | ||||
|                     parentObject && this.openmct.composition.get(parentObject) | ||||
|                 ); | ||||
|                 this.isOverlayPlot = selection[0][0].context.item.type === 'telemetry.plot.overlay'; | ||||
|                 this.isOverlayPlot = parentObject?.type === OVERLAY_PLOT_TYPE; | ||||
|             } | ||||
|         }, | ||||
|         refreshTabs(selection) { | ||||
|   | ||||
| @@ -21,7 +21,7 @@ | ||||
|  *****************************************************************************/ | ||||
|  | ||||
| <template> | ||||
| <div></div> | ||||
| <div aria-label="Inspector Views"></div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
|   | ||||
| @@ -34,7 +34,7 @@ | ||||
|         <ul | ||||
|             v-if="hasElements" | ||||
|             id="inspector-elements-tree" | ||||
|             class="c-tree c-elements-pool__tree" | ||||
|             class="c-tree c-elements-pool__tree js-elements-pool__tree" | ||||
|         > | ||||
|             <div class="c-elements-pool__instructions"> Select and drag an element to move it into a different axis. </div> | ||||
|             <element-item-group | ||||
| @@ -145,7 +145,7 @@ export default { | ||||
|  | ||||
|             this.unlistenComposition(); | ||||
|  | ||||
|             if (this.parentObject) { | ||||
|             if (this.parentObject && this.parentObject.type === 'telemetry.plot.overlay') { | ||||
|                 this.setYAxisIds(); | ||||
|                 this.composition = this.openmct.composition.get(this.parentObject); | ||||
|  | ||||
| @@ -175,6 +175,7 @@ export default { | ||||
|         setYAxisIds() { | ||||
|             const configId = this.openmct.objects.makeKeyString(this.parentObject.identifier); | ||||
|             this.config = configStore.get(configId); | ||||
|             this.yAxes = []; | ||||
|             this.yAxes.push({ | ||||
|                 id: this.config.yAxis.id, | ||||
|                 elements: this.parentObject.configuration.series.filter( | ||||
|   | ||||
| @@ -1,83 +0,0 @@ | ||||
| /***************************************************************************** | ||||
|  * 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> | ||||
| @@ -22,7 +22,7 @@ | ||||
|  | ||||
| <template> | ||||
| <div | ||||
|     class="c-inspector__properties c-inspect-properties has-tag-applier" | ||||
|     class="c-inspector__properties c-inspect-properties" | ||||
|     aria-label="Tags Inspector" | ||||
| > | ||||
|     <div | ||||
| @@ -111,25 +111,31 @@ export default { | ||||
|             return this?.selection?.[0]?.[0]?.context?.item; | ||||
|         }, | ||||
|         targetDetails() { | ||||
|             return this?.selection?.[0]?.[1]?.context?.targetDetails ?? {}; | ||||
|             return this?.selection?.[0]?.[0]?.context?.targetDetails ?? {}; | ||||
|         }, | ||||
|         shouldShowTagsEditor() { | ||||
|             return Object.keys(this.targetDetails).length > 0; | ||||
|             const showingTagsEditor = Object.keys(this.targetDetails).length > 0; | ||||
|  | ||||
|             if (showingTagsEditor) { | ||||
|                 return true; | ||||
|             } | ||||
|  | ||||
|             return false; | ||||
|         }, | ||||
|         targetDomainObjects() { | ||||
|             return this?.selection?.[0]?.[1]?.context?.targetDomainObjects ?? {}; | ||||
|             return this?.selection?.[0]?.[0]?.context?.targetDomainObjects ?? {}; | ||||
|         }, | ||||
|         selectedAnnotations() { | ||||
|             return this?.selection?.[0]?.[1]?.context?.annotations; | ||||
|             return this?.selection?.[0]?.[0]?.context?.annotations; | ||||
|         }, | ||||
|         annotationType() { | ||||
|             return this?.selection?.[0]?.[1]?.context?.annotationType; | ||||
|             return this?.selection?.[0]?.[0]?.context?.annotationType; | ||||
|         }, | ||||
|         annotationFilter() { | ||||
|             return this?.selection?.[0]?.[1]?.context?.annotationFilter; | ||||
|             return this?.selection?.[0]?.[0]?.context?.annotationFilter; | ||||
|         }, | ||||
|         onAnnotationChange() { | ||||
|             return this?.selection?.[0]?.[1]?.context?.onAnnotationChange; | ||||
|             return this?.selection?.[0]?.[0]?.context?.onAnnotationChange; | ||||
|         } | ||||
|     }, | ||||
|     async mounted() { | ||||
| @@ -195,6 +201,7 @@ export default { | ||||
|             } | ||||
|         }, | ||||
|         async loadAnnotationForTargetObject(target) { | ||||
|             console.debug(`📝 Loading annotations for target`, target); | ||||
|             const targetID = this.openmct.objects.makeKeyString(target.identifier); | ||||
|             const allAnnotationsForTarget = await this.openmct.annotation.getAnnotations(target.identifier); | ||||
|             const filteredAnnotationsForSelection = allAnnotationsForTarget.filter(annotation => { | ||||
|   | ||||
| @@ -1,18 +0,0 @@ | ||||
| .c-inspect-annotations { | ||||
|     > * + * { | ||||
|         margin-top: $interiorMargin; | ||||
|     } | ||||
|  | ||||
|     &__content{ | ||||
|         > * + * { | ||||
|             margin-top: $interiorMargin; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     &__content { | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|     } | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -39,7 +39,6 @@ | ||||
|  | ||||
|     &__group { | ||||
|         flex: 1 1 auto; | ||||
|         overflow: auto; | ||||
|         margin-top: $interiorMarginLg; | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -14,7 +14,6 @@ | ||||
|  | ||||
| <script> | ||||
| import CreateAction from '@/plugins/formActions/CreateAction'; | ||||
| import objectUtils from 'objectUtils'; | ||||
|  | ||||
| export default { | ||||
|     inject: ['openmct'], | ||||
| @@ -74,23 +73,9 @@ export default { | ||||
|             this.openmct.menus.showSuperMenu(x, y, this.sortedItems, menuOptions); | ||||
|         }, | ||||
|         create(key) { | ||||
|             // Hack for support.  TODO: rewrite create action. | ||||
|             // 1. Get contextual object from navigation | ||||
|             // 2. Get legacy type from legacy api | ||||
|             // 3. Instantiate create action with type, parent, context | ||||
|             // 4. perform action. | ||||
|             return this.openmct.objects.get(this.openmct.router.path[0].identifier) | ||||
|                 .then((currentObject) => { | ||||
|                     const createAction = new CreateAction(this.openmct, key, currentObject); | ||||
|             const createAction = new CreateAction(this.openmct, key, this.openmct.router.path[0]); | ||||
|  | ||||
|                     createAction.invoke(); | ||||
|                 }); | ||||
|         }, | ||||
|         convertToLegacy(domainObject) { | ||||
|             let keyString = objectUtils.makeKeyString(domainObject.identifier); | ||||
|             let oldModel = objectUtils.toOldFormat(domainObject); | ||||
|  | ||||
|             return this.openmct.$injector.get('instantiate')(oldModel, keyString); | ||||
|             createAction.invoke(); | ||||
|         } | ||||
|     } | ||||
| }; | ||||
|   | ||||
| @@ -79,9 +79,7 @@ | ||||
|             <multipane | ||||
|                 type="vertical" | ||||
|             > | ||||
|                 <pane | ||||
|                     id="tree-pane" | ||||
|                 > | ||||
|                 <pane> | ||||
|                     <mct-tree | ||||
|                         ref="mctTree" | ||||
|                         :sync-tree-navigation="triggerSync" | ||||
|   | ||||
| @@ -41,6 +41,7 @@ | ||||
|         ref="mainTree" | ||||
|         class="c-tree-and-search__tree c-tree" | ||||
|         role="tree" | ||||
|         :aria-label="getAriaLabel" | ||||
|         aria-expanded="true" | ||||
|     > | ||||
|  | ||||
| @@ -192,6 +193,9 @@ export default { | ||||
|         focusedItems() { | ||||
|             return this.activeSearch ? this.searchResultItems : this.treeItems; | ||||
|         }, | ||||
|         getAriaLabel() { | ||||
|             return this.isSelectorTree ? "Create Modal Tree" : "Main Tree"; | ||||
|         }, | ||||
|         pageThreshold() { | ||||
|             return Math.ceil(this.mainTreeHeight / this.itemHeight) + ITEM_BUFFER; | ||||
|         }, | ||||
| @@ -311,7 +315,7 @@ export default { | ||||
|             } | ||||
|         }, | ||||
|         targetedPathAnimationEnd() { | ||||
|             this.targetedPath = undefined; | ||||
|             this.targetedPath = null; | ||||
|         }, | ||||
|         treeItemSelection(item) { | ||||
|             this.selectedItem = item; | ||||
|   | ||||
| @@ -26,7 +26,7 @@ | ||||
|   align-items: flex-start; | ||||
|  | ||||
|   > * + * { | ||||
|       margin-left: $interiorMargin; | ||||
|       margin-left: $interiorMarginSm; | ||||
|   } | ||||
|  | ||||
|   + .c-recentobjects-listitem { | ||||
| @@ -58,7 +58,7 @@ | ||||
|  | ||||
|   &__type-icon { | ||||
|       color: $colorItemTreeIcon; | ||||
|       font-size: 2.2em; | ||||
|       font-size: 1.25em; | ||||
|  | ||||
|       // TEMP: uses object-label component, hide label part | ||||
|       .c-object-label__name { | ||||
| @@ -72,6 +72,7 @@ | ||||
|  | ||||
|   &__body { | ||||
|       flex: 1 1 auto; | ||||
|       padding-top: 2px; // Align with type icon | ||||
|  | ||||
|       > * + * { | ||||
|           margin-top: $interiorMarginSm; | ||||
| @@ -89,7 +90,6 @@ | ||||
|             } | ||||
|           } | ||||
|       } | ||||
|  | ||||
|   } | ||||
|  | ||||
|   &__tags { | ||||
| @@ -102,7 +102,7 @@ | ||||
|  | ||||
|   &__title { | ||||
|       border-radius: $basicCr; | ||||
|       color: pullForward($colorBodyFg, 30%); | ||||
|       color: $colorItemTreeFg; | ||||
|       cursor: pointer; | ||||
|       padding: $interiorMarginSm; | ||||
|  | ||||
|   | ||||
| @@ -150,16 +150,11 @@ export default { | ||||
|             }); | ||||
|             const selection = | ||||
|                     [ | ||||
|                         { | ||||
|                             element: this.openmct.layout.$refs.browseObject.$el, | ||||
|                             context: { | ||||
|                                 item: this.result | ||||
|                             } | ||||
|                         }, | ||||
|                         { | ||||
|                             element: this.$el, | ||||
|                             context: { | ||||
|                                 type: 'plot-points-selection', | ||||
|                                 item: this.result.targetModels[0], | ||||
|                                 type: 'plot-annotation-search-result', | ||||
|                                 targetDetails, | ||||
|                                 targetDomainObjects, | ||||
|                                 annotations: [this.result], | ||||
|   | ||||
| @@ -29,7 +29,7 @@ import GrandSearch from './GrandSearch.vue'; | ||||
| import ExampleTagsPlugin from '../../../../example/exampleTags/plugin'; | ||||
| import DisplayLayoutPlugin from '../../../plugins/displayLayout/plugin'; | ||||
|  | ||||
| describe("GrandSearch", () => { | ||||
| xdescribe("GrandSearch", () => { | ||||
|     let openmct; | ||||
|     let grandSearchComponent; | ||||
|     let viewContainer; | ||||
|   | ||||
| @@ -62,7 +62,7 @@ export default { | ||||
|         }, | ||||
|         targetedPath: { | ||||
|             type: String, | ||||
|             required: true | ||||
|             default: null | ||||
|         }, | ||||
|         selectedItem: { | ||||
|             type: Object, | ||||
|   | ||||
| @@ -6,7 +6,7 @@ let child; | ||||
| let appHolder; | ||||
| let resolveFunction; | ||||
|  | ||||
| describe('Application router utility functions', () => { | ||||
| xdescribe('Application router utility functions', () => { | ||||
|     beforeEach(done => { | ||||
|         appHolder = document.createElement('div'); | ||||
|         appHolder.style.width = '640px'; | ||||
|   | ||||