Compare commits
	
		
			43 Commits
		
	
	
		
			test-metri
			...
			fix-plots-
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 4d2891c35b | ||
|   | fac2c233c1 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | bf48a6e306 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 00ad452930 | ||
|   | 8df1f6406b | ||
|   | a50960d66c | ||
|   | e3a69c8856 | ||
|   | 672cb7e621 | ||
|   | 7dcccee1ae | ||
|   | 302dbe7359 | ||
|   | b4df01965e | ||
|   | 5a8f1d542e | ||
|   | 10decda94e | ||
|   | 5b1f8d0eac | ||
|   | 2f6e1b703a | ||
|   | 5384022a59 | ||
|   | b57974b462 | ||
|   | 3c36ba9a71 | ||
|   | 2ac463de90 | ||
|   | be38c3e654 | ||
|   | 0f312a88bb | ||
|   | 422b7f3e09 | ||
|   | 800062d37e | ||
|   | c1e8c7915c | ||
|   | c1c1d87953 | ||
|   | 0382d22f7f | ||
|   | f570424357 | ||
|   | 393c801426 | ||
|   | 6d63339b23 | ||
|   | 66d7c626e1 | ||
|   | 2246f33023 | ||
|   | 871362d469 | ||
|   | cc1bf47f5a | ||
|   | 9c784398b3 | ||
|   | 21ce013df2 | ||
|   | d20c2a3e3c | ||
|   | 8d1a2e6716 | ||
|   | 01f724959d | ||
|   | 3ae6290ec3 | ||
|   | ba5ed27e74 | ||
|   | ca737d8afa | ||
|   | 33a275e8bc | ||
|   | 60e808689c | 
| @@ -118,6 +118,7 @@ jobs: | ||||
|       suite: #stable or full | ||||
|         type: string | ||||
|     executor: pw-focal-development | ||||
|     parallelism: 4 | ||||
|     steps: | ||||
|       - build_and_install: | ||||
|           node-version: <<parameters.node-version>> | ||||
| @@ -173,10 +174,16 @@ jobs: | ||||
| workflows: | ||||
|   overall-circleci-commit-status: #These jobs run on every commit | ||||
|     jobs: | ||||
|       - lint: | ||||
|           name: node14-lint | ||||
|           node-version: lts/fermium | ||||
|       - unit-test: | ||||
|           name: node18-chrome | ||||
|           node-version: "18" | ||||
|       - e2e-test: | ||||
|           name: e2e-full | ||||
|           name: e2e-stable | ||||
|           node-version: lts/gallium | ||||
|           suite: full | ||||
|           suite: stable | ||||
|       - perf-test: | ||||
|           node-version: lts/gallium | ||||
|       - visual-test: | ||||
|   | ||||
| @@ -92,7 +92,9 @@ Read more about [Playwright Snapshots](https://playwright.dev/docs/test-snapshot | ||||
| - 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 | ||||
| // 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 | ||||
| npx playwright test --config=e2e/playwright-ci.config.js --project=chrome --grep @snapshot | ||||
| ``` | ||||
| @@ -104,17 +106,20 @@ When the `@snapshot` tests fail, they will need to be evaluated to determine if | ||||
| 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 | ||||
|  | ||||
|   | ||||
| @@ -12,13 +12,14 @@ const config = { | ||||
|     retries: 2, //Retries 2 times for a total of 3 runs. When running sharded and with maxFailures = 5, this should ensure that flake is managed without failing the full suite | ||||
|     testDir: 'tests', | ||||
|     testIgnore: '**/*.perf.spec.js', //Ignore performance tests and define in playwright-perfromance.config.js | ||||
|     timeout: 60 * 1000, | ||||
|     webServer: { | ||||
|         command: 'npm run start:coverage', | ||||
|         url: 'http://localhost:8080/#', | ||||
|         timeout: 200 * 1000, | ||||
|         reuseExistingServer: false | ||||
|     }, | ||||
|     //maxFailures: MAX_FAILURES, //Limits failures to 5 to reduce CI Waste | ||||
|     maxFailures: MAX_FAILURES, //Limits failures to 5 to reduce CI Waste | ||||
|     workers: NUM_WORKERS, //Limit to 2 for CircleCI Agent | ||||
|     use: { | ||||
|         baseURL: 'http://localhost:8080/', | ||||
| @@ -72,7 +73,7 @@ const config = { | ||||
|             open: 'never', | ||||
|             outputFolder: '../html-test-results' //Must be in different location due to https://github.com/microsoft/playwright/issues/12840 | ||||
|         }], | ||||
|         ['junit', { outputFile: 'test-results/results.xml' }], | ||||
|         ['junit', { outputFile: '../test-results/results.xml' }], | ||||
|         ['github'] | ||||
|     ] | ||||
| }; | ||||
|   | ||||
| @@ -35,8 +35,8 @@ const config = { | ||||
|     ], | ||||
|     reporter: [ | ||||
|         ['list'], | ||||
|         ['junit', { outputFile: 'test-results/results.xml' }], | ||||
|         ['json', { outputFile: 'test-results/results.json' }] | ||||
|         ['junit', { outputFile: '../test-results/results.xml' }], | ||||
|         ['json', { outputFile: '../test-results/results.json' }] | ||||
|     ] | ||||
| }; | ||||
|  | ||||
|   | ||||
| @@ -40,7 +40,7 @@ const config = { | ||||
|     ], | ||||
|     reporter: [ | ||||
|         ['list'], | ||||
|         ['junit', { outputFile: 'test-results/results.xml' }], | ||||
|         ['junit', { outputFile: '../test-results/results.xml' }], | ||||
|         ['html', { | ||||
|             open: 'on-failure', | ||||
|             outputFolder: '../html-test-results' //Must be in different location due to https://github.com/microsoft/playwright/issues/12840 | ||||
|   | ||||
| @@ -24,18 +24,51 @@ | ||||
| This test suite is dedicated to tests which verify Open MCT's Notification functionality | ||||
| */ | ||||
|  | ||||
| // FIXME: Remove this eslint exception once tests are implemented | ||||
| // eslint-disable-next-line no-unused-vars | ||||
| const { createDomainObjectWithDefaults } = require('../../appActions'); | ||||
| const { createDomainObjectWithDefaults, createNotification } = require('../../appActions'); | ||||
| const { test, expect } = require('../../pluginFixtures'); | ||||
|  | ||||
| test.describe('Notifications List', () => { | ||||
|     test.fixme('Notifications can be dismissed individually', async ({ page }) => { | ||||
|         // Create some persistent notifications | ||||
|         // Verify that they are present in the notifications list | ||||
|         // Dismiss one of the notifications | ||||
|         // Verify that it is no longer present in the notifications list | ||||
|         // Verify that the other notifications are still present in the notifications list | ||||
|     test('Notifications can be dismissed individually', async ({ page }) => { | ||||
|         test.info().annotations.push({ | ||||
|             type: 'issue', | ||||
|             description: 'https://github.com/nasa/openmct/issues/6122' | ||||
|         }); | ||||
|  | ||||
|         // Go to baseURL | ||||
|         await page.goto('./', { waitUntil: 'networkidle' }); | ||||
|  | ||||
|         // Create an error notification with the message "Error message" | ||||
|         await createNotification(page, { | ||||
|             severity: 'error', | ||||
|             message: 'Error message' | ||||
|         }); | ||||
|  | ||||
|         // Create an alert notification with the message "Alert message" | ||||
|         await createNotification(page, { | ||||
|             severity: 'alert', | ||||
|             message: 'Alert message' | ||||
|         }); | ||||
|  | ||||
|         // Verify that there is a button with aria-label "Review 2 Notifications" | ||||
|         expect(await page.locator('button[aria-label="Review 2 Notifications"]').count()).toBe(1); | ||||
|  | ||||
|         // Click on button with aria-label "Review 2 Notifications" | ||||
|         await page.click('button[aria-label="Review 2 Notifications"]'); | ||||
|  | ||||
|         // Click on button with aria-label="Dismiss notification of Error message" | ||||
|         await page.click('button[aria-label="Dismiss notification of Error message"]'); | ||||
|  | ||||
|         // Verify there is no a notification (listitem) with the text "Error message" since it was dismissed | ||||
|         expect(await page.locator('div[role="dialog"] div[role="listitem"]').innerText()).not.toContain('Error message'); | ||||
|  | ||||
|         // Verify there is still a notification (listitem) with the text "Alert message" | ||||
|         expect(await page.locator('div[role="dialog"] div[role="listitem"]').innerText()).toContain('Alert message'); | ||||
|  | ||||
|         // Click on button with aria-label="Dismiss notification of Alert message" | ||||
|         await page.click('button[aria-label="Dismiss notification of Alert message"]'); | ||||
|  | ||||
|         // Verify that there is no dialog since the notification overlay was closed automatically after all notifications were dismissed | ||||
|         expect(await page.locator('div[role="dialog"]').count()).toBe(0); | ||||
|     }); | ||||
| }); | ||||
|  | ||||
|   | ||||
| @@ -231,4 +231,25 @@ test.describe('Tagging in Notebooks @addInit', () => { | ||||
|             await expect(page.locator(entryLocator)).toContainText("Driving"); | ||||
|         } | ||||
|     }); | ||||
|     test('Can cancel adding a tag', async ({ page }) => { | ||||
|         await createNotebookAndEntry(page); | ||||
|  | ||||
|         // Click on Annotations tab | ||||
|         await page.locator('.c-inspector__tab', { hasText: "Annotations" }).click(); | ||||
|  | ||||
|         // Click on the "Add Tag" button | ||||
|         await page.locator('button:has-text("Add Tag")').click(); | ||||
|  | ||||
|         // Click inside the AutoComplete field | ||||
|         await page.locator('[placeholder="Type to select tag"]').click(); | ||||
|  | ||||
|         // Click on the "Tags" header (simulating a click outside the autocomplete) | ||||
|         await page.locator('div.c-inspect-properties__header:has-text("Tags")').click(); | ||||
|  | ||||
|         // Verify there is a button with text "Add Tag" | ||||
|         await expect(page.locator('button:has-text("Add Tag")')).toBeVisible(); | ||||
|  | ||||
|         // Verify the AutoComplete field is hidden | ||||
|         await expect(page.locator('[placeholder="Type to select tag"]')).toBeHidden(); | ||||
|     }); | ||||
| }); | ||||
|   | ||||
| @@ -140,61 +140,4 @@ test.describe('Overlay Plot', () => { | ||||
|         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; | ||||
| } | ||||
|   | ||||
| @@ -22,10 +22,14 @@ | ||||
|  | ||||
| const { test, expect } = require('../../pluginFixtures.js'); | ||||
| const { createDomainObjectWithDefaults } = require('../../appActions.js'); | ||||
| const { waitForAnimations } = require('../../baseFixtures.js'); | ||||
|  | ||||
| test.describe('Recent Objects', () => { | ||||
|     /** @type {import('@playwright/test').Locator} */ | ||||
|     let recentObjectsList; | ||||
|     /** @type {import('@playwright/test').Locator} */ | ||||
|     let clock; | ||||
|     /** @type {import('@playwright/test').Locator} */ | ||||
|     let folderA; | ||||
|     test.beforeEach(async ({ page }) => { | ||||
|         await page.goto('./', { waitUntil: 'networkidle' }); | ||||
| @@ -45,19 +49,16 @@ test.describe('Recent Objects', () => { | ||||
|         }); | ||||
|  | ||||
|         // Drag the Recent Objects panel up a bit | ||||
|         await page.locator('div:nth-child(2) > .l-pane__handle').hover(); | ||||
|         await page.locator('.l-pane.l-pane--vertical-handle-before', { | ||||
|             hasText: 'Recently Viewed' | ||||
|         }).locator('.l-pane__handle').hover(); | ||||
|         await page.mouse.down(); | ||||
|         await page.mouse.move(0, 100); | ||||
|         await page.mouse.up(); | ||||
|     }); | ||||
|     test('Recent Objects CRUD operations', async ({ page }) => { | ||||
|     test('Navigated objects show up in recents, object renames and deletions are reflected', async ({ page }) => { | ||||
|         // Verify that both created objects appear in the list and are in the correct order | ||||
|         expect(recentObjectsList.getByRole('listitem', { name: clock.name })).toBeTruthy(); | ||||
|         expect(recentObjectsList.getByRole('listitem', { name: folderA.name })).toBeTruthy(); | ||||
|         expect(recentObjectsList.getByRole('listitem', { name: clock.name }).locator('a').getByText(folderA.name)).toBeTruthy(); | ||||
|         expect(recentObjectsList.getByRole('listitem').nth(0).getByText(clock.name)).toBeTruthy(); | ||||
|         expect(recentObjectsList.getByRole('listitem', { name: clock.name }).locator('a').getByText(folderA.name)).toBeTruthy(); | ||||
|         expect(recentObjectsList.getByRole('listitem').nth(1).getByText(folderA.name)).toBeTruthy(); | ||||
|         assertInitialRecentObjectsListState(); | ||||
|  | ||||
|         // Navigate to the folder by clicking on the main object name in the recent objects list item | ||||
|         await page.getByRole('listitem', { name: folderA.name }).getByText(folderA.name).click(); | ||||
| @@ -72,7 +73,7 @@ test.describe('Recent Objects', () => { | ||||
|  | ||||
|         // Verify rename has been applied in recent objects list item and objects paths | ||||
|         expect(await page.getByRole('navigation', { | ||||
|             name: `${clock.name} Breadcrumb` | ||||
|             name: clock.name | ||||
|         }).locator('a').filter({ | ||||
|             hasText: folderA.name | ||||
|         }).count()).toBeGreaterThan(0); | ||||
| @@ -102,31 +103,153 @@ test.describe('Recent Objects', () => { | ||||
|         // 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` | ||||
|             name: clock.name | ||||
|         }).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}\?.*`)); | ||||
|         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` | ||||
|             name: clock.name | ||||
|         }).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\?.*`)); | ||||
|         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("Clicking on the 'target button' scrolls the object into view in the tree and highlights it", async ({ page }) => { | ||||
|         const clockTreeItem = page.getByRole('tree', { name: 'Main Tree'}).getByRole('treeitem', { name: clock.name }); | ||||
|         const folderTreeItem = page.getByRole('tree', { name: 'Main Tree'}) | ||||
|             .getByRole('treeitem', { | ||||
|                 name: folderA.name, | ||||
|                 expanded: true | ||||
|             }); | ||||
|  | ||||
|         // Click the "Target" button for the Clock which is nested in a folder | ||||
|         await page.getByRole('button', { name: `Open and scroll to ${clock.name}`}).click(); | ||||
|  | ||||
|         // Assert that the Clock parent folder has expanded and the Clock is visible) | ||||
|         await expect(folderTreeItem.locator('.c-disclosure-triangle')).toHaveClass(/--expanded/); | ||||
|         await expect(clockTreeItem).toBeVisible(); | ||||
|  | ||||
|         // Assert that the Clock treeitem is highlighted | ||||
|         await expect(clockTreeItem.locator('.c-tree__item')).toHaveClass(/is-targeted-item/); | ||||
|  | ||||
|         // Wait for highlight animation to end | ||||
|         await waitForAnimations(clockTreeItem.locator('.c-tree__item')); | ||||
|  | ||||
|         // Assert that the Clock treeitem is no longer highlighted | ||||
|         await expect(clockTreeItem.locator('.c-tree__item')).not.toHaveClass(/is-targeted-item/); | ||||
|     }); | ||||
|     test.fixme("Tests for context menu actions from recent objects", async ({ page }) => { | ||||
|     test("Persists on refresh", async ({ page }) => { | ||||
|         assertInitialRecentObjectsListState(); | ||||
|         await page.reload(); | ||||
|         assertInitialRecentObjectsListState(); | ||||
|     }); | ||||
|     test("Displays objects and aliases uniquely", async ({ page }) => { | ||||
|         const mainTree = page.getByRole('tree', { name: 'Main Tree'}); | ||||
|  | ||||
|         // Navigate to the clock and reveal it in the tree | ||||
|         await page.goto(clock.url); | ||||
|         await page.getByTitle('Show selected item in tree').click(); | ||||
|  | ||||
|         // Right click the clock and create an alias using the "link" context menu action | ||||
|         const clockTreeItem = page.getByRole('tree', { | ||||
|             name: 'Main Tree' | ||||
|         }).getByRole('treeitem', { | ||||
|             name: clock.name | ||||
|         }); | ||||
|         await clockTreeItem.click({ | ||||
|             button: 'right' | ||||
|         }); | ||||
|         await page.getByRole('menuitem', { | ||||
|             name: /Create Link/ | ||||
|         }).click(); | ||||
|         await page.getByRole('tree', { name: 'Create Modal Tree'}).getByRole('treeitem').first().click(); | ||||
|         await page.getByRole('button', { name: 'Save' }).click(); | ||||
|  | ||||
|         // Click the newly created object alias in the tree | ||||
|         await mainTree.getByRole('treeitem', { | ||||
|             name: new RegExp(clock.name) | ||||
|         }).filter({ | ||||
|             has: page.locator('.is-alias') | ||||
|         }).click(); | ||||
|  | ||||
|         // Assert that two recent objects are displayed and one of them is an alias | ||||
|         expect(await recentObjectsList.getByRole('listitem', { name: clock.name }).count()).toBe(2); | ||||
|         expect(await recentObjectsList.locator('.is-alias').count()).toBe(1); | ||||
|  | ||||
|         // Assert that the alias and the original's breadcrumbs are different | ||||
|         const clockBreadcrumbs = recentObjectsList.getByRole('listitem', {name: clock.name}).getByRole('navigation'); | ||||
|         expect(await clockBreadcrumbs.count()).toBe(2); | ||||
|         expect(await clockBreadcrumbs.nth(0).innerText()).not.toEqual(await clockBreadcrumbs.nth(1).innerText()); | ||||
|     }); | ||||
|     test("Enforces a limit of 20 recent objects", async ({ page }) => { | ||||
|         // Creating 21 objects takes a while, so increase the timeout | ||||
|         test.slow(); | ||||
|  | ||||
|         // Assert that the list initially contains 3 objects (clock, folder, my items) | ||||
|         expect(await recentObjectsList.locator('.c-recentobjects-listitem').count()).toBe(3); | ||||
|  | ||||
|         let lastFolder; | ||||
|         let lastClock; | ||||
|         // Create 19 more objects (3 in beforeEach() + 18 new = 21 total) | ||||
|         for (let i = 0; i < 9; i++) { | ||||
|             lastFolder = await createDomainObjectWithDefaults(page, { | ||||
|                 type: "Folder", | ||||
|                 parent: lastFolder?.uuid | ||||
|             }); | ||||
|             lastClock = await createDomainObjectWithDefaults(page, { | ||||
|                 type: "Clock", | ||||
|                 parent: lastFolder?.uuid | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         // Assert that the list contains 20 objects | ||||
|         expect(await recentObjectsList.locator('.c-recentobjects-listitem').count()).toBe(20); | ||||
|  | ||||
|         // Collapse the tree | ||||
|         await page.getByTitle("Collapse all tree items").click(); | ||||
|         const lastFolderTreeItem = page.getByRole('tree', { name: 'Main Tree'}) | ||||
|             .getByRole('treeitem', { | ||||
|                 name: lastFolder.name, | ||||
|                 expanded: true | ||||
|             }); | ||||
|         const lastClockTreeItem = page.getByRole('tree', { name: 'Main Tree'}) | ||||
|             .getByRole('treeitem', { | ||||
|                 name: lastClock.name | ||||
|             }); | ||||
|  | ||||
|         // Test "Open and Scroll To" in a deeply nested tree, while we're here | ||||
|         await page.getByRole('button', { name: `Open and scroll to ${lastClock.name}`}).click(); | ||||
|  | ||||
|         // Assert that the Clock parent folder has expanded and the Clock is visible) | ||||
|         await expect(lastFolderTreeItem.locator('.c-disclosure-triangle')).toHaveClass(/--expanded/); | ||||
|         await expect(lastClockTreeItem).toBeVisible(); | ||||
|  | ||||
|         // Assert that the Clock treeitem is highlighted | ||||
|         await expect(lastClockTreeItem.locator('.c-tree__item')).toHaveClass(/is-targeted-item/); | ||||
|  | ||||
|         // Wait for highlight animation to end | ||||
|         await waitForAnimations(lastClockTreeItem.locator('.c-tree__item')); | ||||
|  | ||||
|         // Assert that the Clock treeitem is no longer highlighted | ||||
|         await expect(lastClockTreeItem.locator('.c-tree__item')).not.toHaveClass(/is-targeted-item/); | ||||
|     }); | ||||
|  | ||||
|     function assertInitialRecentObjectsListState() { | ||||
|         expect(recentObjectsList.getByRole('listitem', { name: clock.name })).toBeTruthy(); | ||||
|         expect(recentObjectsList.getByRole('listitem', { name: folderA.name })).toBeTruthy(); | ||||
|         expect(recentObjectsList.getByRole('listitem', { name: clock.name }).locator('a').getByText(folderA.name)).toBeTruthy(); | ||||
|         expect(recentObjectsList.getByRole('listitem').nth(0).getByText(clock.name)).toBeTruthy(); | ||||
|         expect(recentObjectsList.getByRole('listitem', { name: clock.name }).locator('a').getByText(folderA.name)).toBeTruthy(); | ||||
|         expect(recentObjectsList.getByRole('listitem').nth(1).getByText(folderA.name)).toBeTruthy(); | ||||
|     } | ||||
| }); | ||||
|   | ||||
| @@ -37,7 +37,7 @@ const { test, expect } = require('@playwright/test'); | ||||
| const filePath = 'e2e/test-data/PerformanceDisplayLayout.json'; | ||||
|  | ||||
| // eslint-disable-next-line playwright/no-skipped-test | ||||
| test.describe('Memory Performance tests', () => { | ||||
| test.describe.skip('Memory Performance tests', () => { | ||||
|     test.beforeEach(async ({ page, browser }, testInfo) => { | ||||
|         // Go to baseURL | ||||
|         await page.goto('./', { waitUntil: 'networkidle' }); | ||||
|   | ||||
							
								
								
									
										68
									
								
								e2e/tests/visual/addTag.visual.spec.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								e2e/tests/visual/addTag.visual.spec.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,68 @@ | ||||
| /***************************************************************************** | ||||
|  * Open MCT, Copyright (c) 2014-2023, United States Government | ||||
|  * as represented by the Administrator of the National Aeronautics and Space | ||||
|  * Administration. All rights reserved. | ||||
|  * | ||||
|  * Open MCT is licensed under the Apache License, Version 2.0 (the | ||||
|  * "License"); you may not use this file except in compliance with the License. | ||||
|  * You may obtain a copy of the License at | ||||
|  * http://www.apache.org/licenses/LICENSE-2.0. | ||||
|  * | ||||
|  * Unless required by applicable law or agreed to in writing, software | ||||
|  * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT | ||||
|  * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the | ||||
|  * License for the specific language governing permissions and limitations | ||||
|  * under the License. | ||||
|  * | ||||
|  * Open MCT includes source code licensed under additional open source | ||||
|  * licenses. See the Open Source Licenses file (LICENSES.md) included with | ||||
|  * this source code distribution or the Licensing information page available | ||||
|  * at runtime from the About dialog for additional information. | ||||
|  *****************************************************************************/ | ||||
|  | ||||
| /** | ||||
|  * This test is dedicated to test the blur behavior of the add tag button. | ||||
|  */ | ||||
|  | ||||
| const { test } = require("../../pluginFixtures"); | ||||
| const percySnapshot = require('@percy/playwright'); | ||||
| const { createDomainObjectWithDefaults } = require('../../appActions'); | ||||
|  | ||||
| test.describe("Visual - Check blur of Add Tag button", () => { | ||||
|  | ||||
|     test.beforeEach(async ({ page }) => { | ||||
|         // Open a browser, navigate to the main page, and wait until all network events to resolve | ||||
|         await page.goto('./', { waitUntil: 'networkidle' }); | ||||
|     }); | ||||
|  | ||||
|     test("Blur 'Add tag'", async ({ page, theme }) => { | ||||
|         createDomainObjectWithDefaults(page, { type: 'Notebook' }); | ||||
|  | ||||
|         await page.locator('text=To start a new entry, click here or drag and drop any object').click(); | ||||
|         const entryLocator = `[aria-label="Notebook Entry Input"] >> nth = 0`; | ||||
|         await page.locator(entryLocator).click(); | ||||
|         await page.locator(entryLocator).fill(`Entry 0`); | ||||
|         await page.locator(entryLocator).press('Enter'); | ||||
|  | ||||
|         // Click on Annotations tab | ||||
|         await page.locator('.c-inspector__tab', { hasText: "Annotations" }).click(); | ||||
|  | ||||
|         // Take snapshot of the notebook with the Annotations tab opened | ||||
|         await percySnapshot(page, `Notebook Annotation (theme: '${theme}')`); | ||||
|  | ||||
|         // Click on the "Add Tag" button | ||||
|         await page.locator('button:has-text("Add Tag")').click(); | ||||
|  | ||||
|         // Take snapshot of the notebook with the AutoComplete field visible | ||||
|         await percySnapshot(page, `Notebook Add Tag (theme: '${theme}')`); | ||||
|  | ||||
|         // Click inside the AutoComplete field | ||||
|         await page.locator('[placeholder="Type to select tag"]').click(); | ||||
|  | ||||
|         // Click on the "Tags" header (simulating a click outside the autocomplete field) | ||||
|         await page.locator('div.c-inspect-properties__header:has-text("Tags")').click(); | ||||
|  | ||||
|         // Take snapshot of the notebook with the AutoComplete field hidden and with the "Add Tag" button visible | ||||
|         await percySnapshot(page, `Notebook Annotation de-select blur (theme: '${theme}')`); | ||||
|     }); | ||||
| }); | ||||
| @@ -20,11 +20,23 @@ | ||||
|  * at runtime from the About dialog for additional information. | ||||
|  *****************************************************************************/ | ||||
| import availableTags from './tags.json'; | ||||
|  | ||||
| /** | ||||
| @typedef {{ | ||||
|     namespaceToSaveAnnotations: string | ||||
| }} TagsPluginOptions | ||||
| */ | ||||
|  | ||||
| /** | ||||
|  * @typedef {TagsPluginOptions} options | ||||
|  * @returns {function} The plugin install function | ||||
|  */ | ||||
| export default function exampleTagsPlugin() { | ||||
| export default function exampleTagsPlugin(options) { | ||||
|     return function install(openmct) { | ||||
|         if (options?.namespaceToSaveAnnotations) { | ||||
|             openmct.annotation.setNamespaceToSaveAnnotations(options?.namespaceToSaveAnnotations); | ||||
|         } | ||||
|  | ||||
|         Object.keys(availableTags.tags).forEach(tagKey => { | ||||
|             const tagDefinition = availableTags.tags[tagKey]; | ||||
|             openmct.annotation.defineTag(tagKey, tagDefinition); | ||||
|   | ||||
| @@ -275,7 +275,7 @@ function pointForTimestamp(timestamp, name, imageSamples, delay) { | ||||
|         local: Math.floor(timestamp / delay) * delay, | ||||
|         url, | ||||
|         sunOrientation: getCompassValues(0, 360), | ||||
|         cameraAzimuth: getCompassValues(0, 360), | ||||
|         cameraPan: getCompassValues(0, 360), | ||||
|         heading: getCompassValues(0, 360), | ||||
|         transformations: navCamTransformations, | ||||
|         imageDownloadName | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| { | ||||
|   "name": "openmct", | ||||
|   "version": "2.1.6", | ||||
|   "version": "2.1.6-SNAPSHOT", | ||||
|   "description": "The Open MCT core platform", | ||||
|   "devDependencies": { | ||||
|     "@babel/eslint-parser": "7.18.9", | ||||
| @@ -21,7 +21,7 @@ | ||||
|     "d3-scale": "3.3.0", | ||||
|     "d3-selection": "3.0.0", | ||||
|     "eslint": "8.32.0", | ||||
|     "eslint-plugin-compat": "4.0.2", | ||||
|     "eslint-plugin-compat": "4.1.1", | ||||
|     "eslint-plugin-playwright": "0.12.0", | ||||
|     "eslint-plugin-vue": "9.9.0", | ||||
|     "eslint-plugin-you-dont-need-lodash-underscore": "6.12.0", | ||||
| @@ -60,7 +60,7 @@ | ||||
|     "sass-loader": "13.2.0", | ||||
|     "sinon": "15.0.1", | ||||
|     "style-loader": "^3.3.1", | ||||
|     "typescript": "4.9.4", | ||||
|     "typescript": "4.9.5", | ||||
|     "uuid": "9.0.0", | ||||
|     "vue": "2.6.14", | ||||
|     "vue-eslint-parser": "9.1.0", | ||||
|   | ||||
| @@ -84,6 +84,7 @@ export default class AnnotationAPI extends EventEmitter { | ||||
|         super(); | ||||
|         this.openmct = openmct; | ||||
|         this.availableTags = {}; | ||||
|         this.namespaceToSaveAnnotations = ''; | ||||
|  | ||||
|         this.ANNOTATION_TYPES = ANNOTATION_TYPES; | ||||
|         this.ANNOTATION_TYPE = ANNOTATION_TYPE; | ||||
| @@ -139,7 +140,7 @@ export default class AnnotationAPI extends EventEmitter { | ||||
|         const domainObjectKeyString = this.openmct.objects.makeKeyString(domainObject.identifier); | ||||
|         const originalPathObjects = await this.openmct.objects.getOriginalPath(domainObjectKeyString); | ||||
|         const originalContextPath = this.openmct.objects.getRelativePath(originalPathObjects); | ||||
|         const namespace = domainObject.identifier.namespace; | ||||
|         const namespace = this.namespaceToSaveAnnotations; | ||||
|         const type = 'annotation'; | ||||
|         const typeDefinition = this.openmct.types.get(type); | ||||
|         const definition = typeDefinition.definition; | ||||
| @@ -198,6 +199,14 @@ export default class AnnotationAPI extends EventEmitter { | ||||
|         this.availableTags[tagKey] = tagsDefinition; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|     * @method setNamespaceToSaveAnnotations | ||||
|     * @param {String} namespace the namespace to save new annotations to | ||||
|     */ | ||||
|     setNamespaceToSaveAnnotations(namespace) { | ||||
|         this.namespaceToSaveAnnotations = namespace; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|     * @method isAnnotation | ||||
|     * @param {DomainObject} domainObject the domainObject in question | ||||
|   | ||||
| @@ -26,6 +26,7 @@ import ExampleTagsPlugin from "../../../example/exampleTags/plugin"; | ||||
| describe("The Annotation API", () => { | ||||
|     let openmct; | ||||
|     let mockObjectProvider; | ||||
|     let mockImmutableObjectProvider; | ||||
|     let mockDomainObject; | ||||
|     let mockFolderObject; | ||||
|     let mockAnnotationObject; | ||||
| @@ -89,6 +90,23 @@ describe("The Annotation API", () => { | ||||
|         mockObjectProvider.create.and.returnValue(Promise.resolve(true)); | ||||
|         mockObjectProvider.update.and.returnValue(Promise.resolve(true)); | ||||
|  | ||||
|         mockImmutableObjectProvider = jasmine.createSpyObj("mock immutable provider", [ | ||||
|             "get" | ||||
|         ]); | ||||
|         // eslint-disable-next-line require-await | ||||
|         mockImmutableObjectProvider.get = async (identifier) => { | ||||
|             if (identifier.key === mockDomainObject.identifier.key) { | ||||
|                 return mockDomainObject; | ||||
|             } else if (identifier.key === mockAnnotationObject.identifier.key) { | ||||
|                 return mockAnnotationObject; | ||||
|             } else if (identifier.key === mockFolderObject.identifier.key) { | ||||
|                 return mockFolderObject; | ||||
|             } else { | ||||
|                 return null; | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         openmct.objects.addProvider('immutableProvider', mockImmutableObjectProvider); | ||||
|         openmct.objects.addProvider('fooNameSpace', mockObjectProvider); | ||||
|         openmct.on('start', done); | ||||
|         openmct.startHeadless(); | ||||
| @@ -115,6 +133,22 @@ describe("The Annotation API", () => { | ||||
|             expect(annotationObject).toBeDefined(); | ||||
|             expect(annotationObject.type).toEqual('annotation'); | ||||
|         }); | ||||
|         it("can create annotations if domain object is immutable", async () => { | ||||
|             mockDomainObject.identifier.namespace = 'immutableProvider'; | ||||
|             const annotationCreationArguments = { | ||||
|                 name: 'Test Annotation', | ||||
|                 domainObject: mockDomainObject, | ||||
|                 annotationType: openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, | ||||
|                 tags: ['sometag'], | ||||
|                 contentText: "fooContext", | ||||
|                 targetDomainObjects: [mockDomainObject], | ||||
|                 targets: {'fooTarget': {}} | ||||
|             }; | ||||
|             openmct.annotation.setNamespaceToSaveAnnotations('fooNameSpace'); | ||||
|             const annotationObject = await openmct.annotation.create(annotationCreationArguments); | ||||
|             expect(annotationObject).toBeDefined(); | ||||
|             expect(annotationObject.type).toEqual('annotation'); | ||||
|         }); | ||||
|         it("fails if annotation is an unknown type", async () => { | ||||
|             try { | ||||
|                 await openmct.annotation.create('Garbage Annotation', mockDomainObject, 'garbageAnnotation', ['sometag'], "fooContext", {'fooTarget': {}}); | ||||
| @@ -122,6 +156,40 @@ describe("The Annotation API", () => { | ||||
|                 expect(error).toBeDefined(); | ||||
|             } | ||||
|         }); | ||||
|         it("fails if annotation if given an immutable namespace to save to", async () => { | ||||
|             try { | ||||
|                 const annotationCreationArguments = { | ||||
|                     name: 'Test Annotation', | ||||
|                     domainObject: mockDomainObject, | ||||
|                     annotationType: openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, | ||||
|                     tags: ['sometag'], | ||||
|                     contentText: "fooContext", | ||||
|                     targetDomainObjects: [mockDomainObject], | ||||
|                     targets: {'fooTarget': {}} | ||||
|                 }; | ||||
|                 openmct.annotation.setNamespaceToSaveAnnotations('nameespaceThatDoesNotExist'); | ||||
|                 await openmct.annotation.create(annotationCreationArguments); | ||||
|             } catch (error) { | ||||
|                 expect(error).toBeDefined(); | ||||
|             } | ||||
|         }); | ||||
|         it("fails if annotation if given an undefined namespace to save to", async () => { | ||||
|             try { | ||||
|                 const annotationCreationArguments = { | ||||
|                     name: 'Test Annotation', | ||||
|                     domainObject: mockDomainObject, | ||||
|                     annotationType: openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, | ||||
|                     tags: ['sometag'], | ||||
|                     contentText: "fooContext", | ||||
|                     targetDomainObjects: [mockDomainObject], | ||||
|                     targets: {'fooTarget': {}} | ||||
|                 }; | ||||
|                 openmct.annotation.setNamespaceToSaveAnnotations('immutableProvider'); | ||||
|                 await openmct.annotation.create(annotationCreationArguments); | ||||
|             } catch (error) { | ||||
|                 expect(error).toBeDefined(); | ||||
|             } | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe("Tagging", () => { | ||||
| @@ -149,13 +217,6 @@ describe("The Annotation API", () => { | ||||
|             openmct.annotation.deleteAnnotations([annotationObject]); | ||||
|             expect(annotationObject._deleted).toBeTrue(); | ||||
|         }); | ||||
|         it("throws an error if deleting non-existent tag", async () => { | ||||
|             const annotationObject = await openmct.annotation.create(tagCreationArguments); | ||||
|             expect(annotationObject).toBeDefined(); | ||||
|             expect(() => { | ||||
|                 openmct.annotation.removeAnnotationTag(annotationObject, 'ThisTagShouldNotExist'); | ||||
|             }).toThrow(); | ||||
|         }); | ||||
|         it("can remove all tags", async () => { | ||||
|             const annotationObject = await openmct.annotation.create(tagCreationArguments); | ||||
|             expect(annotationObject).toBeDefined(); | ||||
|   | ||||
| @@ -42,7 +42,7 @@ | ||||
|         ></div> | ||||
|     </div> | ||||
|     <div | ||||
|         v-if="!hideOptions" | ||||
|         v-if="!hideOptions && filteredOptions.length > 0" | ||||
|         class="c-menu c-input--autocomplete__options" | ||||
|         aria-label="Autocomplete Options" | ||||
|         @blur="hideOptions = true" | ||||
| @@ -230,10 +230,10 @@ export default { | ||||
|             this.showFilteredOptions = false; | ||||
|             this.autocompleteInputElement.select(); | ||||
|  | ||||
|             if (this.hideOptions) { | ||||
|                 this.showOptions(); | ||||
|             } else { | ||||
|             if (!this.hideOptions && this.filteredOptions.length > 0) { | ||||
|                 this.hideOptions = true; | ||||
|             } else { | ||||
|                 this.showOptions(); | ||||
|             } | ||||
|  | ||||
|         }, | ||||
| @@ -242,6 +242,7 @@ export default { | ||||
|             // dropdown is visible, this will collapse the dropdown. | ||||
|             const clickedInsideAutocomplete = this.autocompleteInputAndArrow.contains(event.target); | ||||
|             if (!clickedInsideAutocomplete && !this.hideOptions) { | ||||
|                 this.$emit('autoCompleteBlur'); | ||||
|                 this.hideOptions = true; | ||||
|             } | ||||
|         }, | ||||
|   | ||||
| @@ -180,7 +180,6 @@ export default class TelemetryCollection extends EventEmitter { | ||||
|         let beforeStartOfBounds; | ||||
|         let afterEndOfBounds; | ||||
|         let added = []; | ||||
|         let addedIndices = []; | ||||
|  | ||||
|         // loop through, sort and dedupe | ||||
|         for (let datum of data) { | ||||
| @@ -213,7 +212,6 @@ export default class TelemetryCollection extends EventEmitter { | ||||
|                     let index = endIndex || startIndex; | ||||
|  | ||||
|                     this.boundedTelemetry.splice(index, 0, datum); | ||||
|                     addedIndices.push(index); | ||||
|                     added.push(datum); | ||||
|                 } | ||||
|  | ||||
| @@ -232,7 +230,7 @@ export default class TelemetryCollection extends EventEmitter { | ||||
|                     this.emit('add', this.boundedTelemetry); | ||||
|                 } | ||||
|             } else { | ||||
|                 this.emit('add', added, addedIndices); | ||||
|                 this.emit('add', added); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| @@ -332,8 +330,7 @@ export default class TelemetryCollection extends EventEmitter { | ||||
|                     this.boundedTelemetry = added; | ||||
|                 } | ||||
|  | ||||
|                 // Assumption is that added will be of length 1 here, so just send the last index of the boundedTelemetry in the add event | ||||
|                 this.emit('add', added, [this.boundedTelemetry.length]); | ||||
|                 this.emit('add', added); | ||||
|             } | ||||
|         } else { | ||||
|             // user bounds change, reset | ||||
|   | ||||
| @@ -117,58 +117,22 @@ describe("The Independent Time API", function () { | ||||
|     }); | ||||
|  | ||||
|     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 timeContext2 = api.getContextForView([{ | ||||
|         }, { | ||||
|             identifier: { | ||||
|                 namespace: '', | ||||
|                 key: domainObjectKey2 | ||||
|                 key: 'blah' | ||||
|             } | ||||
|         }]); | ||||
|         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 () { | ||||
|   | ||||
| @@ -121,7 +121,8 @@ describe("The URLTimeSettingsSynchronizer", () => { | ||||
|         openmct.router.on('change:hash', resolveFunction); | ||||
|     }); | ||||
|  | ||||
|     it("reset hash", (done) => { | ||||
|     // disabling due to test flakiness | ||||
|     xit("reset hash", (done) => { | ||||
|         window.location.hash = oldHash; | ||||
|         resolveFunction = () => { | ||||
|             expect(window.location.hash).toBe(oldHash); | ||||
|   | ||||
| @@ -26,23 +26,18 @@ | ||||
|     :style="`width: 100%; height: 100%`" | ||||
| > | ||||
|     <CompassHUD | ||||
|         :camera-angle-of-view="cameraAngleOfView" | ||||
|         :heading="heading" | ||||
|         :camera-azimuth="cameraAzimuth" | ||||
|         :transformations="transformations" | ||||
|         :has-gimble="hasGimble" | ||||
|         :normalized-camera-azimuth="normalizedCameraAzimuth" | ||||
|         v-if="showCompassHUD" | ||||
|         :sun-heading="sunHeading" | ||||
|         :camera-angle-of-view="cameraAngleOfView" | ||||
|         :camera-pan="cameraPan" | ||||
|     /> | ||||
|     <CompassRose | ||||
|         :camera-angle-of-view="cameraAngleOfView" | ||||
|         v-if="showCompassRose" | ||||
|         :camera-pan="cameraPan" | ||||
|         :heading="heading" | ||||
|         :camera-azimuth="cameraAzimuth" | ||||
|         :transformations="transformations" | ||||
|         :has-gimble="hasGimble" | ||||
|         :normalized-camera-azimuth="normalizedCameraAzimuth" | ||||
|         :sun-heading="sunHeading" | ||||
|         :sized-image-dimensions="sizedImageDimensions" | ||||
|         :sun-heading="sunHeading" | ||||
|         :transformations="transformations" | ||||
|     /> | ||||
| </div> | ||||
| </template> | ||||
| @@ -50,7 +45,6 @@ | ||||
| <script> | ||||
| import CompassHUD from './CompassHUD.vue'; | ||||
| import CompassRose from './CompassRose.vue'; | ||||
| import { rotate } from './utils'; | ||||
|  | ||||
| export default { | ||||
|     components: { | ||||
| @@ -68,14 +62,11 @@ export default { | ||||
|         } | ||||
|     }, | ||||
|     computed: { | ||||
|         hasGimble() { | ||||
|             return this.cameraAzimuth !== undefined; | ||||
|         showCompassHUD() { | ||||
|             return this.hasCameraPan && this.cameraAngleOfView > 0; | ||||
|         }, | ||||
|         // compass ordinal orientation of camera | ||||
|         normalizedCameraAzimuth() { | ||||
|             return this.hasGimble | ||||
|                 ? rotate(this.cameraAzimuth) | ||||
|                 : rotate(this.heading, -this.transformations.rotation || 0); | ||||
|         showCompassRose() { | ||||
|             return (this.hasCameraPan || this.hasHeading) && this.cameraAngleOfView > 0; | ||||
|         }, | ||||
|         // horizontal rotation from north in degrees | ||||
|         heading() { | ||||
| @@ -89,11 +80,14 @@ export default { | ||||
|             return this.image.sunOrientation; | ||||
|         }, | ||||
|         // horizontal rotation from north in degrees | ||||
|         cameraAzimuth() { | ||||
|         cameraPan() { | ||||
|             return this.image.cameraPan; | ||||
|         }, | ||||
|         hasCameraPan() { | ||||
|             return this.cameraPan !== undefined; | ||||
|         }, | ||||
|         cameraAngleOfView() { | ||||
|             return this.transformations.cameraAngleOfView; | ||||
|             return this.transformations?.cameraAngleOfView; | ||||
|         }, | ||||
|         transformations() { | ||||
|             return this.image.transformations; | ||||
|   | ||||
| @@ -94,33 +94,17 @@ 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: { | ||||
| @@ -146,13 +130,10 @@ export default { | ||||
|                 left: `${ percentage * 100 }%` | ||||
|             }; | ||||
|         }, | ||||
|         cameraRotation() { | ||||
|             return this.transformations?.rotation; | ||||
|         }, | ||||
|         visibleRange() { | ||||
|             return [ | ||||
|                 rotate(this.normalizedCameraAzimuth, -this.cameraAngleOfView / 2), | ||||
|                 rotate(this.normalizedCameraAzimuth, this.cameraAngleOfView / 2) | ||||
|                 rotate(this.cameraPan, -this.cameraAngleOfView / 2), | ||||
|                 rotate(this.cameraPan, this.cameraAngleOfView / 2) | ||||
|             ]; | ||||
|         } | ||||
|     } | ||||
|   | ||||
| @@ -75,6 +75,7 @@ | ||||
|                     :style="sunHeadingStyle" | ||||
|                 /> | ||||
|  | ||||
|                 <!-- Camera FOV --> | ||||
|                 <mask | ||||
|                     id="mask2" | ||||
|                     class="c-cr__cam-fov-l-mask" | ||||
| @@ -116,10 +117,10 @@ | ||||
|                         class="cr-vrover" | ||||
|                         :style="camAngleAndPositionStyle" | ||||
|                     > | ||||
|                         <!-- Equipment body. Rotates relative to the camera pan value for cameras that gimble. --> | ||||
|                         <!-- Equipment body. Rotates relative to the camera pan value for cams that gimbal. --> | ||||
|                         <path | ||||
|                             class="cr-vrover__body" | ||||
|                             :style="gimbledCameraPanStyle" | ||||
|                             :style="camGimbalAngleStyle" | ||||
|                             x | ||||
|                             fill-rule="evenodd" | ||||
|                             clip-rule="evenodd" | ||||
| @@ -127,7 +128,6 @@ | ||||
|                         /> | ||||
|                     </g> | ||||
|  | ||||
|                     <!-- Camera FOV --> | ||||
|                     <g | ||||
|                         class="c-cr__cam-fov" | ||||
|                     > | ||||
| @@ -160,7 +160,7 @@ | ||||
|             <!-- NSEW and ticks --> | ||||
|             <g | ||||
|                 class="c-cr__nsew" | ||||
|                 :style="compassDialStyle" | ||||
|                 :style="compassRoseStyle" | ||||
|             > | ||||
|                 <g class="c-cr__ticks-major"> | ||||
|                     <path d="M50 3L43 10H57L50 3Z" /> | ||||
| @@ -259,32 +259,23 @@ import { throttle } from 'lodash'; | ||||
|  | ||||
| export default { | ||||
|     props: { | ||||
|         cameraAngleOfView: { | ||||
|             type: Number, | ||||
|             required: true | ||||
|         }, | ||||
|         heading: { | ||||
|             type: Number, | ||||
|             required: true | ||||
|             required: true, | ||||
|             default() { | ||||
|                 return 0; | ||||
|             } | ||||
|         }, | ||||
|         cameraAzimuth: { | ||||
|         sunHeading: { | ||||
|             type: Number, | ||||
|             default: undefined | ||||
|         }, | ||||
|         cameraPan: { | ||||
|             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: { | ||||
| @@ -298,6 +289,18 @@ 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; | ||||
| @@ -306,22 +309,18 @@ export default { | ||||
|  | ||||
|             return { transform: `translate(${translateX}%, ${translateY}%) rotate(${rotation}deg) scale(${scale})` }; | ||||
|         }, | ||||
|         gimbledCameraPanStyle() { | ||||
|             if (!this.hasGimble) { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             const gimbledCameraPan = rotate(this.normalizedCameraAzimuth, -this.heading); | ||||
|         camGimbalAngleStyle() { | ||||
|             const rotation = rotate(this.heading); | ||||
|  | ||||
|             return { | ||||
|                 transform: `rotate(${ -gimbledCameraPan }deg)` | ||||
|                 transform: `rotate(${ rotation }deg)` | ||||
|             }; | ||||
|         }, | ||||
|         compassDialStyle() { | ||||
|         compassRoseStyle() { | ||||
|             return { transform: `rotate(${ this.north }deg)` }; | ||||
|         }, | ||||
|         north() { | ||||
|             return this.lockCompass ? rotate(-this.normalizedCameraAzimuth) : 0; | ||||
|             return this.lockCompass ? rotate(-this.cameraHeading) : 0; | ||||
|         }, | ||||
|         cardinalTextRotateN() { | ||||
|             return { transform: `translateY(-27%) rotate(${ -this.north }deg)` }; | ||||
| @@ -349,7 +348,7 @@ export default { | ||||
|             }; | ||||
|         }, | ||||
|         cameraHeadingStyle() { | ||||
|             const rotation = rotate(this.north, this.normalizedCameraAzimuth); | ||||
|             const rotation = rotate(this.north, this.cameraHeading); | ||||
|  | ||||
|             return { | ||||
|                 transform: `rotate(${ rotation }deg)` | ||||
|   | ||||
| @@ -35,15 +35,8 @@ describe("The Compass component", () => { | ||||
|             roll: 90, | ||||
|             pitch: 90, | ||||
|             cameraTilt: 100, | ||||
|             cameraAzimuth: 90, | ||||
|             sunAngle: 30, | ||||
|             transformations: { | ||||
|                 translateX: 0, | ||||
|                 translateY: 18, | ||||
|                 rotation: 0, | ||||
|                 scale: 0.3, | ||||
|                 cameraAngleOfView: 70 | ||||
|             } | ||||
|             cameraPan: 90, | ||||
|             sunAngle: 30 | ||||
|         }; | ||||
|         let propsData = { | ||||
|             naturalAspectRatio: 0.9, | ||||
| @@ -51,7 +44,8 @@ describe("The Compass component", () => { | ||||
|             sizedImageDimensions: { | ||||
|                 width: 100, | ||||
|                 height: 100 | ||||
|             } | ||||
|             }, | ||||
|             compassRoseSizingClasses: '--rose-small --rose-min' | ||||
|         }; | ||||
|  | ||||
|         app = new Vue({ | ||||
| @@ -60,6 +54,7 @@ describe("The Compass component", () => { | ||||
|                 return propsData; | ||||
|             }, | ||||
|             template: `<Compass | ||||
|                 :compass-rose-sizing-classes="compassRoseSizingClasses" | ||||
|                 :image="image" | ||||
|                 :natural-aspect-ratio="naturalAspectRatio" | ||||
|                 :sized-image-dimensions="sizedImageDimensions" | ||||
| @@ -72,7 +67,7 @@ describe("The Compass component", () => { | ||||
|         app.$destroy(); | ||||
|     }); | ||||
|  | ||||
|     describe("when a heading value and cameraAngleOfView exists on the image", () => { | ||||
|     describe("when a heading value exists on the image", () => { | ||||
|  | ||||
|         it("should display a compass rose", () => { | ||||
|             let compassRoseElement = instance.$el.querySelector(COMPASS_ROSE_CLASS | ||||
|   | ||||
| @@ -94,6 +94,7 @@ | ||||
|                 <Compass | ||||
|                     v-if="shouldDisplayCompass" | ||||
|                     :image="focusedImage" | ||||
|                     :natural-aspect-ratio="focusedImageNaturalAspectRatio" | ||||
|                     :sized-image-dimensions="sizedImageDimensions" | ||||
|                 /> | ||||
|             </div> | ||||
| @@ -170,7 +171,7 @@ | ||||
|         > | ||||
|             <ImageThumbnail | ||||
|                 v-for="(image, index) in imageHistory" | ||||
|                 :key="`${image.thumbnailUrl || image.url}-${image.time}-${index}`" | ||||
|                 :key="`${image.thumbnailUrl || image.url}${image.time}`" | ||||
|                 :image="image" | ||||
|                 :active="focusedImageIndex === index" | ||||
|                 :selected="focusedImageIndex === index && isPaused" | ||||
| @@ -429,12 +430,9 @@ export default { | ||||
|                 && imageHeightAndWidth | ||||
|                 && this.zoomFactor === 1 | ||||
|                 && this.imagePanned !== true; | ||||
|             const hasHeading = this.focusedImage?.heading !== undefined; | ||||
|             const hasCameraAngleOfView = this.focusedImage?.transformations?.cameraAngleOfView > 0; | ||||
|             const hasCameraConfigurations = this.focusedImage?.transformations !== undefined; | ||||
|  | ||||
|             return display | ||||
|                 && hasCameraAngleOfView | ||||
|                 && hasHeading; | ||||
|             return display && hasCameraConfigurations; | ||||
|         }, | ||||
|         isSpacecraftPositionFresh() { | ||||
|             let isFresh = undefined; | ||||
| @@ -584,34 +582,11 @@ export default { | ||||
|             }, | ||||
|             deep: true | ||||
|         }, | ||||
|         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(); | ||||
|             } | ||||
|         focusedImageIndex() { | ||||
|             this.trackDuration(); | ||||
|             this.resetAgeCSS(); | ||||
|             this.updateRelatedTelemetryForFocusedImage(); | ||||
|             this.getImageNaturalDimensions(); | ||||
|         }, | ||||
|         bounds() { | ||||
|             this.scrollHandler(); | ||||
| @@ -796,10 +771,6 @@ 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) { | ||||
|   | ||||
| @@ -76,14 +76,9 @@ export default { | ||||
|         this.telemetryCollection.destroy(); | ||||
|     }, | ||||
|     methods: { | ||||
|         dataAdded(addedItems, addedItemIndices) { | ||||
|             const normalizedDataToAdd = addedItems.map(datum => this.normalizeDatum(datum)); | ||||
|             let newImageHistory = this.imageHistory.slice(); | ||||
|             normalizedDataToAdd.forEach(((datum, index) => { | ||||
|                 newImageHistory.splice(addedItemIndices[index] ?? -1, 0, datum); | ||||
|             })); | ||||
|             //Assign just once so imageHistory watchers don't get called too often | ||||
|             this.imageHistory = newImageHistory; | ||||
|         dataAdded(dataToAdd) { | ||||
|             const normalizedDataToAdd = dataToAdd.map(datum => this.normalizeDatum(datum)); | ||||
|             this.imageHistory = this.imageHistory.concat(normalizedDataToAdd); | ||||
|         }, | ||||
|         dataCleared() { | ||||
|             this.imageHistory = []; | ||||
| @@ -158,6 +153,9 @@ 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() { | ||||
|   | ||||
| @@ -64,7 +64,7 @@ | ||||
|                     tabindex="0" | ||||
|                 > | ||||
|                     <TextHighlight | ||||
|                         :text="formatValidUrls(entry.text)" | ||||
|                         :text="entryText" | ||||
|                         :highlight="highlightText" | ||||
|                         :highlight-class="'search-highlight'" | ||||
|                     /> | ||||
| @@ -75,7 +75,7 @@ | ||||
|                     :id="entry.id" | ||||
|                     class="c-ne__text c-ne__input" | ||||
|                     aria-label="Notebook Entry Input" | ||||
|                     tabindex="0" | ||||
|                     tabindex="-1" | ||||
|                     :contenteditable="canEdit" | ||||
|                     v-bind.prop="formattedText" | ||||
|                     @mouseover="checkEditability($event)" | ||||
| @@ -94,7 +94,7 @@ | ||||
|                     class="c-ne__text" | ||||
|                     contenteditable="false" | ||||
|                     tabindex="0" | ||||
|                     v-bind.prop="formattedText" | ||||
|                     v-html="formattedText" | ||||
|                 > | ||||
|                 </div> | ||||
|             </template> | ||||
| @@ -228,9 +228,7 @@ export default { | ||||
|         }, | ||||
|         selectedEntryId: { | ||||
|             type: String, | ||||
|             default() { | ||||
|                 return ''; | ||||
|             } | ||||
|             required: true | ||||
|         } | ||||
|     }, | ||||
|     data() { | ||||
| @@ -238,7 +236,7 @@ export default { | ||||
|             editMode: false, | ||||
|             canEdit: true, | ||||
|             enableEmbedsWrapperScroll: false, | ||||
|             urlWhitelist: [] | ||||
|             urlWhitelist: null | ||||
|         }; | ||||
|     }, | ||||
|     computed: { | ||||
| @@ -250,15 +248,28 @@ export default { | ||||
|         }, | ||||
|         formattedText() { | ||||
|             // remove ANY tags | ||||
|             const text = sanitizeHtml(this.entry.text, SANITIZATION_SCHEMA); | ||||
|             let text = sanitizeHtml(this.entry.text, SANITIZATION_SCHEMA); | ||||
|  | ||||
|             if (this.editMode || this.urlWhitelist.length === 0) { | ||||
|             if (this.editMode || !this.urlWhitelist) { | ||||
|                 return { innerText: text }; | ||||
|             } | ||||
|  | ||||
|             const html = this.formatValidUrls(text); | ||||
|             text = text.replace(URL_REGEX, (match) => { | ||||
|                 const url = new URL(match); | ||||
|                 const domain = url.hostname; | ||||
|                 let result = match; | ||||
|                 let isMatch = this.urlWhitelist.find((partialDomain) => { | ||||
|                     return domain.endsWith(partialDomain); | ||||
|                 }); | ||||
|  | ||||
|             return { innerHTML: html }; | ||||
|                 if (isMatch) { | ||||
|                     result = `<a class="c-hyperlink" target="_blank" href="${match}">${match}</a>`; | ||||
|                 } | ||||
|  | ||||
|                 return result; | ||||
|             }); | ||||
|  | ||||
|             return { innerHTML: text }; | ||||
|         }, | ||||
|         isSelectedEntry() { | ||||
|             return this.selectedEntryId === this.entry.id; | ||||
| @@ -344,22 +355,6 @@ export default { | ||||
|         deleteEntry() { | ||||
|             this.$emit('deleteEntry', this.entry.id); | ||||
|         }, | ||||
|         formatValidUrls(text) { | ||||
|             return text.replace(URL_REGEX, (match) => { | ||||
|                 const url = new URL(match); | ||||
|                 const domain = url.hostname; | ||||
|                 let result = match; | ||||
|                 let isMatch = this.urlWhitelist.find((partialDomain) => { | ||||
|                     return domain.endsWith(partialDomain); | ||||
|                 }); | ||||
|  | ||||
|                 if (isMatch) { | ||||
|                     result = `<a class="c-hyperlink" target="_blank" href="${match}">${match}</a>`; | ||||
|                 } | ||||
|  | ||||
|                 return result; | ||||
|             }); | ||||
|         }, | ||||
|         manageEmbedLayout() { | ||||
|             if (this.$refs.embeds) { | ||||
|                 const embedsWrapperLength = this.$refs.embedsWrapper.clientWidth; | ||||
|   | ||||
| @@ -11,8 +11,8 @@ | ||||
|         class="w-messages c-overlay__messages" | ||||
|     > | ||||
|         <notification-message | ||||
|             v-for="notification in notifications" | ||||
|             :key="notification.model.timestamp" | ||||
|             v-for="(notification, notificationIndex) in notifications" | ||||
|             :key="notificationIndex" | ||||
|             :close-overlay="closeOverlay" | ||||
|             :notification="notification" | ||||
|             :notifications-count="notifications.length" | ||||
|   | ||||
| @@ -340,7 +340,8 @@ describe("the plugin", function () { | ||||
|             expect(legend.length).toBe(6); | ||||
|         }); | ||||
|  | ||||
|         it("Renders X-axis ticks for the telemetry object", () => { | ||||
|         // disable due to flakiness | ||||
|         xit("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); | ||||
|  | ||||
|   | ||||
| @@ -24,7 +24,7 @@ | ||||
| <ul | ||||
|     v-if="orderedPath.length" | ||||
|     class="c-location" | ||||
|     :aria-label="`${domainObject.name} Breadcrumb`" | ||||
|     :aria-label="`${domainObject.name}`" | ||||
|     role="navigation" | ||||
| > | ||||
|     <li | ||||
|   | ||||
| @@ -31,6 +31,7 @@ | ||||
|         :added-tags="addedTags" | ||||
|         @tagRemoved="tagRemoved" | ||||
|         @tagAdded="tagAdded" | ||||
|         @tagBlurred="tagBlurred" | ||||
|     /> | ||||
|     <button | ||||
|         v-show="!userAddingTag && !maxTagsAdded" | ||||
| @@ -165,6 +166,12 @@ export default { | ||||
|                 } | ||||
|             } | ||||
|         }, | ||||
|         tagBlurred() { | ||||
|             // Remove last tag when user clicks outside of TagSelection | ||||
|             this.addedTags.pop(); | ||||
|             // Hide TagSelection and show "Add Tag" button | ||||
|             this.userAddingTag = false; | ||||
|         }, | ||||
|         async tagAdded(newTag) { | ||||
|             // Either undelete an annotation, or create one (1) new annotation | ||||
|             let existingAnnotation = this.annotations.find((annotation) => { | ||||
|   | ||||
| @@ -31,6 +31,7 @@ | ||||
|             class="c-tag-selection" | ||||
|             :item-css-class="'icon-circle'" | ||||
|             @onChange="tagSelected" | ||||
|             @autoCompleteBlur="autoCompleteBlur" | ||||
|         /> | ||||
|     </template> | ||||
|     <template v-else> | ||||
| @@ -158,6 +159,9 @@ export default { | ||||
|             if (tagAdded) { | ||||
|                 this.$emit('tagAdded', tagAdded.id); | ||||
|             } | ||||
|         }, | ||||
|         autoCompleteBlur() { | ||||
|             this.$emit('tagBlurred'); | ||||
|         } | ||||
|     } | ||||
| }; | ||||
|   | ||||
| @@ -201,7 +201,6 @@ 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 => { | ||||
|   | ||||
| @@ -4,6 +4,7 @@ | ||||
| > | ||||
|     <ul | ||||
|         class="c-tree-and-search__tree c-tree c-tree__scrollable" | ||||
|         aria-label="Recent Objects" | ||||
|     > | ||||
|         <recent-objects-list-item | ||||
|             v-for="(recentObject) in recentObjects" | ||||
|   | ||||
| @@ -54,6 +54,7 @@ | ||||
|     <div class="c-recentobjects-listitem__target-button"> | ||||
|         <button | ||||
|             class="c-icon-button icon-target" | ||||
|             :aria-label="`Open and scroll to ${domainObject.name}`" | ||||
|             @click="openAndScrollTo(navigationPath)" | ||||
|         ></button> | ||||
|     </div> | ||||
|   | ||||
| @@ -21,13 +21,24 @@ | ||||
|  *****************************************************************************/ | ||||
|  | ||||
| <template> | ||||
| <!-- eslint-disable-next-line vue/no-v-html --> | ||||
| <span v-html="highlightedText"></span> | ||||
|  | ||||
| <span> | ||||
|     <span | ||||
|         v-for="segment in segments" | ||||
|         :key="segment.id" | ||||
|         :style="getStyles(segment)" | ||||
|         :class="{ [highlightClass] : segment.type === 'highlight' }" | ||||
|     > | ||||
|         {{ segment.text }} | ||||
|     </span> | ||||
| </span> | ||||
|  | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
|  | ||||
| import { v4 as uuid } from 'uuid'; | ||||
|  | ||||
| export default { | ||||
|     props: { | ||||
|         text: { | ||||
| @@ -47,11 +58,68 @@ export default { | ||||
|             } | ||||
|         } | ||||
|     }, | ||||
|     computed: { | ||||
|         highlightedText() { | ||||
|             let regex = new RegExp(`(?<!<[^>]*)(${this.highlight})`, 'gi'); | ||||
|     data() { | ||||
|         return { | ||||
|             segments: [] | ||||
|         }; | ||||
|     }, | ||||
|     watch: { | ||||
|         highlight() { | ||||
|             this.highlightText(); | ||||
|         }, | ||||
|         text() { | ||||
|             this.highlightText(); | ||||
|         } | ||||
|     }, | ||||
|     mounted() { | ||||
|         this.highlightText(); | ||||
|     }, | ||||
|     methods: { | ||||
|         addHighlightSegment(segment) { | ||||
|             this.segments.push({ | ||||
|                 id: uuid(), | ||||
|                 text: segment, | ||||
|                 type: 'highlight', | ||||
|                 spaceBefore: segment.startsWith(' '), | ||||
|                 spaceAfter: segment.endsWith(' ') | ||||
|             }); | ||||
|         }, | ||||
|         addTextSegment(segment) { | ||||
|             this.segments.push({ | ||||
|                 id: uuid(), | ||||
|                 text: segment, | ||||
|                 type: 'text', | ||||
|                 spaceBefore: segment.startsWith(' '), | ||||
|                 spaceAfter: segment.endsWith(' ') | ||||
|             }); | ||||
|         }, | ||||
|         getStyles(segment) { | ||||
|             let styles = { | ||||
|                 display: 'inline-block' | ||||
|             }; | ||||
|  | ||||
|             return this.text.replace(regex, `<span class="${this.highlightClass}">${this.highlight}</span>`); | ||||
|             if (segment.spaceBefore) { | ||||
|                 styles.paddingLeft = '.33em'; | ||||
|             } | ||||
|  | ||||
|             if (segment.spaceAfter) { | ||||
|                 styles.paddingRight = '.33em'; | ||||
|             } | ||||
|  | ||||
|             return styles; | ||||
|         }, | ||||
|         highlightText() { | ||||
|             this.segments = []; | ||||
|             let regex = new RegExp('(' + this.highlight + ')', 'gi'); | ||||
|             let textSegments = this.text.split(regex); | ||||
|  | ||||
|             for (let i = 0; i < textSegments.length; i++) { | ||||
|                 if (textSegments[i].toLowerCase() === this.highlight.toLowerCase()) { | ||||
|                     this.addHighlightSegment(textSegments[i]); | ||||
|                 } else { | ||||
|                     this.addTextSegment(textSegments[i]); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| }; | ||||
|   | ||||
		Reference in New Issue
	
	Block a user