Compare commits
	
		
			21 Commits
		
	
	
		
			mct5867-re
			...
			mct6053-re
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					510215e2da | ||
| 
						 | 
					0b2dc1e8ab | ||
| 
						 | 
					bb516d668b | ||
| 
						 | 
					eb150a892c | ||
| 
						 | 
					b8b6b0f792 | ||
| 
						 | 
					83723065e5 | ||
| 
						 | 
					39e9d2a9c4 | ||
| 
						 | 
					b5a2194c36 | ||
| 
						 | 
					298e9eb361 | ||
| 
						 | 
					5424a62db5 | ||
| 
						 | 
					9ed9e62202 | ||
| 
						 | 
					327fc826c1 | ||
| 
						 | 
					a9e3eca35c | ||
| 
						 | 
					cbecd79f71 | ||
| 
						 | 
					3deb2e3dc2 | ||
| 
						 | 
					d6e80447ab | ||
| 
						 | 
					1a4bd0fb55 | ||
| 
						 | 
					80f89c7609 | ||
| 
						 | 
					b82649772f | ||
| 
						 | 
					7f2ed27106 | ||
| 
						 | 
					57e02db6b5 | 
@@ -2,7 +2,7 @@ version: 2.1
 | 
			
		||||
executors:
 | 
			
		||||
  pw-focal-development:
 | 
			
		||||
    docker:
 | 
			
		||||
      - image: mcr.microsoft.com/playwright:v1.25.2-focal
 | 
			
		||||
      - image: mcr.microsoft.com/playwright:v1.29.0-focal
 | 
			
		||||
    environment:
 | 
			
		||||
      NODE_ENV: development # Needed to ensure 'dist' folder created and devDependencies installed
 | 
			
		||||
      PERCY_POSTINSTALL_BROWSER: 'true' # Needed to store the percy browser in cache deps
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										2
									
								
								.github/workflows/e2e-couchdb.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/e2e-couchdb.yml
									
									
									
									
										vendored
									
									
								
							@@ -23,7 +23,7 @@ jobs:
 | 
			
		||||
      - uses: actions/setup-node@v3
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: '16'
 | 
			
		||||
      - run: npx playwright@1.25.2 install
 | 
			
		||||
      - run: npx playwright@1.29.0 install
 | 
			
		||||
      - run: npm install
 | 
			
		||||
      - run: sh src/plugins/persistence/couch/replace-localstorage-with-couchdb-indexhtml.sh
 | 
			
		||||
      - run: npm run test:e2e:couchdb
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										2
									
								
								.github/workflows/e2e-pr.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/e2e-pr.yml
									
									
									
									
										vendored
									
									
								
							@@ -30,7 +30,7 @@ jobs:
 | 
			
		||||
      - uses: actions/setup-node@v3
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: '16'
 | 
			
		||||
      - run: npx playwright@1.25.2 install
 | 
			
		||||
      - run: npx playwright@1.29.0 install
 | 
			
		||||
      - run: npx playwright install chrome-beta
 | 
			
		||||
      - run: npm install
 | 
			
		||||
      - run: npm run test:e2e:full
 | 
			
		||||
 
 | 
			
		||||
@@ -8,7 +8,7 @@ This document is designed to capture on the What, Why, and How's of writing and
 | 
			
		||||
 | 
			
		||||
1. [Getting Started](#getting-started)
 | 
			
		||||
2. [Types of Testing](#types-of-e2e-testing)
 | 
			
		||||
3. [Architecture](#architecture)
 | 
			
		||||
3. [Architecture](#test-architecture-and-ci)
 | 
			
		||||
 | 
			
		||||
## Getting Started
 | 
			
		||||
 | 
			
		||||
@@ -276,14 +276,36 @@ Skipping based on browser version (Rarely used): <https://github.com/microsoft/p
 | 
			
		||||
  - Leverage `await page.goto('./', { waitUntil: 'networkidle' });`
 | 
			
		||||
  - Avoid repeated setup to test to test a single assertion. Write longer tests with multiple soft assertions.
 | 
			
		||||
 | 
			
		||||
### How to write a great test (TODO)
 | 
			
		||||
### How to write a great test (WIP)
 | 
			
		||||
 | 
			
		||||
- Use our [App Actions](./appActions.js) for performing common actions whenever applicable.
 | 
			
		||||
- If you create an object outside of using the `createDomainObjectWithDefaults` App Action, make sure to fill in the 'Notes' section of your object with `page.testNotes`:
 | 
			
		||||
 | 
			
		||||
  ```js
 | 
			
		||||
  // Fill the "Notes" section with information about the
 | 
			
		||||
  // currently running test and its project.
 | 
			
		||||
  const { testNotes } = page;
 | 
			
		||||
  const notesInput = page.locator('form[name="mctForm"] #notes-textarea');
 | 
			
		||||
  await notesInput.fill(testNotes);
 | 
			
		||||
  ```
 | 
			
		||||
 | 
			
		||||
#### How to write a great visual test (TODO)
 | 
			
		||||
 | 
			
		||||
#### How to write a great network test
 | 
			
		||||
 | 
			
		||||
- Where possible, it is best to mock out third-party network activity to ensure we are testing application behavior of Open MCT.
 | 
			
		||||
- It is best to be as specific as possible about the expected network request/response structures in creating your mocks.
 | 
			
		||||
- Make sure to only mock requests which are relevant to the specific behavior being tested.
 | 
			
		||||
- Where possible, network requests and responses should be treated in an order-agnostic manner, as the order in which certain requests/responses happen is dynamic and subject to change.
 | 
			
		||||
 | 
			
		||||
Some examples of mocking network responses in regards to CouchDB can be found in our [couchdb.e2e.spec.js](./tests/functional/couchdb.e2e.spec.js) test file.
 | 
			
		||||
 | 
			
		||||
### Best Practices
 | 
			
		||||
 | 
			
		||||
For now, our best practices exist as self-tested, living documentation in our [exampleTemplate.e2e.spec.js](./tests/framework/exampleTemplate.e2e.spec.js) file.
 | 
			
		||||
 | 
			
		||||
For best practices with regards to mocking network responses, see our [couchdb.e2e.spec.js](./tests/functional/couchdb.e2e.spec.js) file.
 | 
			
		||||
 | 
			
		||||
### Tips & Tricks (TODO)
 | 
			
		||||
 | 
			
		||||
The following contains a list of tips and tricks which don't exactly fit into a FAQ or Best Practices doc.
 | 
			
		||||
@@ -378,3 +400,23 @@ A single e2e test in Open MCT is extended to run:
 | 
			
		||||
- Tests won't start because 'Error: <http://localhost:8080/># is already used...'
 | 
			
		||||
This error will appear when running the tests locally. Sometimes, the webserver is left in an orphaned state and needs to be cleaned up. To clear up the orphaned webserver, execute the following from your Terminal:
 | 
			
		||||
```lsof -n -i4TCP:8080 | awk '{print$2}' | tail -1 | xargs kill -9```
 | 
			
		||||
 | 
			
		||||
### Upgrading Playwright
 | 
			
		||||
 | 
			
		||||
In order to upgrade from one version of Playwright to another, the version should be updated in several places in both `openmct` and `openmct-yamcs` repos. An easy way to identify these locations is to search for the current version in all files and find/replace.
 | 
			
		||||
 | 
			
		||||
For reference, all of the locations where the version should be updated are listed below:
 | 
			
		||||
 | 
			
		||||
#### **In `openmct`:**
 | 
			
		||||
 | 
			
		||||
- `package.json`
 | 
			
		||||
  - Both packages `@playwright/test` and `playwright-core` should be updated to the same target version.
 | 
			
		||||
- `.circleci/config.yml`
 | 
			
		||||
- `.github/workflows/e2e-couchdb.yml`
 | 
			
		||||
- `.github/workflows/e2e-pr.yml`
 | 
			
		||||
 | 
			
		||||
#### **In `openmct-yamcs`:**
 | 
			
		||||
 | 
			
		||||
- `package.json`
 | 
			
		||||
  - `@playwright/test` should be updated to the target version.
 | 
			
		||||
- `.github/workflows/yamcs-quickstart-e2e.yml`
 | 
			
		||||
 
 | 
			
		||||
@@ -27,7 +27,7 @@
 | 
			
		||||
 | 
			
		||||
const { test, expect } = require('../../pluginFixtures');
 | 
			
		||||
 | 
			
		||||
test.describe("CouchDB Status Indicator @couchdb", () => {
 | 
			
		||||
test.describe("CouchDB Status Indicator with mocked responses @couchdb", () => {
 | 
			
		||||
    test.use({ failOnConsoleError: false });
 | 
			
		||||
    //TODO BeforeAll Verify CouchDB Connectivity with APIContext
 | 
			
		||||
    test('Shows green if connected', async ({ page }) => {
 | 
			
		||||
@@ -71,38 +71,41 @@ test.describe("CouchDB Status Indicator @couchdb", () => {
 | 
			
		||||
    });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
test.describe("CouchDB initialization @couchdb", () => {
 | 
			
		||||
test.describe("CouchDB initialization with mocked responses @couchdb", () => {
 | 
			
		||||
    test.use({ failOnConsoleError: false });
 | 
			
		||||
    test("'My Items' folder is created if it doesn't exist", async ({ page }) => {
 | 
			
		||||
        // Store any relevant PUT requests that happen on the page
 | 
			
		||||
        const createMineFolderRequests = [];
 | 
			
		||||
        page.on('request', req => {
 | 
			
		||||
            // eslint-disable-next-line playwright/no-conditional-in-test
 | 
			
		||||
            if (req.method() === 'PUT' && req.url().endsWith('openmct/mine')) {
 | 
			
		||||
                createMineFolderRequests.push(req);
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
        const mockedMissingObjectResponsefromCouchDB = {
 | 
			
		||||
            status: 404,
 | 
			
		||||
            contentType: 'application/json',
 | 
			
		||||
            body: JSON.stringify({})
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        // Override the first request to GET openmct/mine to return a 404
 | 
			
		||||
        await page.route('**/openmct/mine', route => {
 | 
			
		||||
            route.fulfill({
 | 
			
		||||
                status: 404,
 | 
			
		||||
                contentType: 'application/json',
 | 
			
		||||
                body: JSON.stringify({})
 | 
			
		||||
            });
 | 
			
		||||
        // Override the first request to GET openmct/mine to return a 404.
 | 
			
		||||
        // This simulates the case of starting Open MCT with a fresh database
 | 
			
		||||
        // and no "My Items" folder created yet.
 | 
			
		||||
        await page.route('**/mine', route => {
 | 
			
		||||
            route.fulfill(mockedMissingObjectResponsefromCouchDB);
 | 
			
		||||
        }, { times: 1 });
 | 
			
		||||
 | 
			
		||||
        // Go to baseURL
 | 
			
		||||
        // Set up promise to verify that a PUT request to create "My Items"
 | 
			
		||||
        // folder was made.
 | 
			
		||||
        const putMineFolderRequest = page.waitForRequest(req =>
 | 
			
		||||
            req.url().endsWith('/mine')
 | 
			
		||||
            && req.method() === 'PUT');
 | 
			
		||||
 | 
			
		||||
        // Set up promise to verify that a GET request to retrieve "My Items"
 | 
			
		||||
        // folder was made.
 | 
			
		||||
        const getMineFolderRequest = page.waitForRequest(req =>
 | 
			
		||||
            req.url().endsWith('/mine')
 | 
			
		||||
            && req.method() === 'GET');
 | 
			
		||||
 | 
			
		||||
        // Go to baseURL.
 | 
			
		||||
        await page.goto('./', { waitUntil: 'networkidle' });
 | 
			
		||||
 | 
			
		||||
        // Verify that error banner is displayed
 | 
			
		||||
        const bannerMessage = await page.locator('.c-message-banner__message').innerText();
 | 
			
		||||
        expect(bannerMessage).toEqual('Failed to retrieve object mine');
 | 
			
		||||
 | 
			
		||||
        // Verify that a PUT request to create "My Items" folder was made
 | 
			
		||||
        await expect.poll(() => createMineFolderRequests.length, {
 | 
			
		||||
            message: 'Verify that PUT request to create "mine" folder was made',
 | 
			
		||||
            timeout: 1000
 | 
			
		||||
        }).toBeGreaterThanOrEqual(1);
 | 
			
		||||
        // Wait for both requests to resolve.
 | 
			
		||||
        await Promise.all([
 | 
			
		||||
            putMineFolderRequest,
 | 
			
		||||
            getMineFolderRequest
 | 
			
		||||
        ]);
 | 
			
		||||
    });
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -24,8 +24,9 @@
 | 
			
		||||
This test suite is dedicated to tests which verify form functionality in isolation
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
const { test, expect } = require('../../baseFixtures');
 | 
			
		||||
const { test, expect } = require('../../pluginFixtures');
 | 
			
		||||
const { createDomainObjectWithDefaults } = require('../../appActions');
 | 
			
		||||
const genUuid = require('uuid').v4;
 | 
			
		||||
const path = require('path');
 | 
			
		||||
 | 
			
		||||
const TEST_FOLDER = 'test folder';
 | 
			
		||||
@@ -128,6 +129,108 @@ test.describe('Persistence operations @couchdb', () => {
 | 
			
		||||
            timeout: 1000
 | 
			
		||||
        }).toEqual(1);
 | 
			
		||||
    });
 | 
			
		||||
    test('Can create an object after a conflict error @couchdb @2p', async ({ page }) => {
 | 
			
		||||
        test.info().annotations.push({
 | 
			
		||||
            type: 'issue',
 | 
			
		||||
            description: 'https://github.com/nasa/openmct/issues/5982'
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        const page2 = await page.context().newPage();
 | 
			
		||||
 | 
			
		||||
        // Both pages: Go to baseURL
 | 
			
		||||
        await Promise.all([
 | 
			
		||||
            page.goto('./', { waitUntil: 'networkidle' }),
 | 
			
		||||
            page2.goto('./', { waitUntil: 'networkidle' })
 | 
			
		||||
        ]);
 | 
			
		||||
 | 
			
		||||
        // Both pages: Click the Create button
 | 
			
		||||
        await Promise.all([
 | 
			
		||||
            page.click('button:has-text("Create")'),
 | 
			
		||||
            page2.click('button:has-text("Create")')
 | 
			
		||||
        ]);
 | 
			
		||||
 | 
			
		||||
        // Both pages: Click "Clock" in the Create menu
 | 
			
		||||
        await Promise.all([
 | 
			
		||||
            page.click(`li[role='menuitem']:text("Clock")`),
 | 
			
		||||
            page2.click(`li[role='menuitem']:text("Clock")`)
 | 
			
		||||
        ]);
 | 
			
		||||
 | 
			
		||||
        // Generate unique names for both objects
 | 
			
		||||
        const nameInput = page.locator('form[name="mctForm"] .first input[type="text"]');
 | 
			
		||||
        const nameInput2 = page2.locator('form[name="mctForm"] .first input[type="text"]');
 | 
			
		||||
 | 
			
		||||
        // Both pages: Fill in the 'Name' form field.
 | 
			
		||||
        await Promise.all([
 | 
			
		||||
            nameInput.fill(""),
 | 
			
		||||
            nameInput.fill(`Clock:${genUuid()}`),
 | 
			
		||||
            nameInput2.fill(""),
 | 
			
		||||
            nameInput2.fill(`Clock:${genUuid()}`)
 | 
			
		||||
        ]);
 | 
			
		||||
 | 
			
		||||
        // Both pages: Fill the "Notes" section with information about the
 | 
			
		||||
        // currently running test and its project.
 | 
			
		||||
        const testNotes = page.testNotes;
 | 
			
		||||
        const notesInput = page.locator('form[name="mctForm"] #notes-textarea');
 | 
			
		||||
        const notesInput2 = page2.locator('form[name="mctForm"] #notes-textarea');
 | 
			
		||||
        await Promise.all([
 | 
			
		||||
            notesInput.fill(testNotes),
 | 
			
		||||
            notesInput2.fill(testNotes)
 | 
			
		||||
        ]);
 | 
			
		||||
 | 
			
		||||
        // Page 2: Click "OK" to create the domain object and wait for navigation.
 | 
			
		||||
        // This will update the composition of the parent folder, setting the
 | 
			
		||||
        // conditions for a conflict error from the first page.
 | 
			
		||||
        await Promise.all([
 | 
			
		||||
            page2.waitForLoadState(),
 | 
			
		||||
            page2.click('[aria-label="Save"]'),
 | 
			
		||||
            // Wait for Save Banner to appear
 | 
			
		||||
            page2.waitForSelector('.c-message-banner__message')
 | 
			
		||||
        ]);
 | 
			
		||||
 | 
			
		||||
        // Close Page 2, we're done with it.
 | 
			
		||||
        await page2.close();
 | 
			
		||||
 | 
			
		||||
        // Page 1: Click "OK" to create the domain object and wait for navigation.
 | 
			
		||||
        // This will trigger a conflict error upon attempting to update
 | 
			
		||||
        // the composition of the parent folder.
 | 
			
		||||
        await Promise.all([
 | 
			
		||||
            page.waitForLoadState(),
 | 
			
		||||
            page.click('[aria-label="Save"]'),
 | 
			
		||||
            // Wait for Save Banner to appear
 | 
			
		||||
            page.waitForSelector('.c-message-banner__message')
 | 
			
		||||
        ]);
 | 
			
		||||
 | 
			
		||||
        // Page 1: Verify that the conflict has occurred and an error notification is displayed.
 | 
			
		||||
        await expect(page.locator('.c-message-banner__message', {
 | 
			
		||||
            hasText: "Conflict detected while saving mine"
 | 
			
		||||
        })).toBeVisible();
 | 
			
		||||
 | 
			
		||||
        // Page 1: Start logging console errors from this point on
 | 
			
		||||
        let errors = [];
 | 
			
		||||
        page.on('console', (msg) => {
 | 
			
		||||
            if (msg.type() === 'error') {
 | 
			
		||||
                errors.push(msg.text());
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        // Page 1: Try to create a clock with the page that received the conflict.
 | 
			
		||||
        const clockAfterConflict = await createDomainObjectWithDefaults(page, {
 | 
			
		||||
            type: 'Clock'
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        // Page 1: Wait for save progress dialog to appear/disappear
 | 
			
		||||
        await page.locator('.c-message-banner__message', {
 | 
			
		||||
            hasText: 'Do not navigate away from this page or close this browser tab while this message is displayed.',
 | 
			
		||||
            state: 'visible'
 | 
			
		||||
        }).waitFor({ state: 'hidden' });
 | 
			
		||||
 | 
			
		||||
        // Page 1: Navigate to 'My Items' and verify that the second clock was created
 | 
			
		||||
        await page.goto('./#/browse/mine');
 | 
			
		||||
        await expect(page.locator(`.c-grid-item__name[title="${clockAfterConflict.name}"]`)).toBeVisible();
 | 
			
		||||
 | 
			
		||||
        // Verify no console errors occurred
 | 
			
		||||
        expect(errors).toHaveLength(0);
 | 
			
		||||
    });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
test.describe('Form Correctness by Object Type', () => {
 | 
			
		||||
 
 | 
			
		||||
@@ -44,6 +44,7 @@ async function createNotebookAndEntry(page, iterations = 1) {
 | 
			
		||||
        const entryLocator = `[aria-label="Notebook Entry Input"] >> nth = ${iteration}`;
 | 
			
		||||
        await page.locator(entryLocator).click();
 | 
			
		||||
        await page.locator(entryLocator).fill(`Entry ${iteration}`);
 | 
			
		||||
        await page.locator(entryLocator).press('Enter');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return notebook;
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										10
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								package.json
									
									
									
									
									
								
							@@ -7,7 +7,7 @@
 | 
			
		||||
    "@braintree/sanitize-url": "6.0.2",
 | 
			
		||||
    "@percy/cli": "1.16.0",
 | 
			
		||||
    "@percy/playwright": "1.0.4",
 | 
			
		||||
    "@playwright/test": "1.25.2",
 | 
			
		||||
    "@playwright/test": "1.29.0",
 | 
			
		||||
    "@types/jasmine": "4.3.1",
 | 
			
		||||
    "@types/lodash": "4.14.191",
 | 
			
		||||
    "babel-loader": "9.1.0",
 | 
			
		||||
@@ -19,7 +19,7 @@
 | 
			
		||||
    "d3-axis": "3.0.0",
 | 
			
		||||
    "d3-scale": "3.3.0",
 | 
			
		||||
    "d3-selection": "3.0.0",
 | 
			
		||||
    "eslint": "8.29.0",
 | 
			
		||||
    "eslint": "8.30.0",
 | 
			
		||||
    "eslint-plugin-compat": "4.0.2",
 | 
			
		||||
    "eslint-plugin-playwright": "0.11.2",
 | 
			
		||||
    "eslint-plugin-vue": "9.8.0",
 | 
			
		||||
@@ -45,17 +45,17 @@
 | 
			
		||||
    "mini-css-extract-plugin": "2.7.2",
 | 
			
		||||
    "moment": "2.29.4",
 | 
			
		||||
    "moment-duration-format": "2.3.2",
 | 
			
		||||
    "moment-timezone": "0.5.38",
 | 
			
		||||
    "moment-timezone": "0.5.40",
 | 
			
		||||
    "nyc": "15.1.0",
 | 
			
		||||
    "painterro": "1.2.78",
 | 
			
		||||
    "playwright-core": "1.25.2",
 | 
			
		||||
    "playwright-core": "1.29.0",
 | 
			
		||||
    "plotly.js-basic-dist": "2.14.0",
 | 
			
		||||
    "plotly.js-gl2d-dist": "2.14.0",
 | 
			
		||||
    "printj": "1.3.1",
 | 
			
		||||
    "resolve-url-loader": "5.0.0",
 | 
			
		||||
    "sass": "1.56.1",
 | 
			
		||||
    "sass-loader": "13.0.2",
 | 
			
		||||
    "sinon": "14.0.1",
 | 
			
		||||
    "sinon": "15.0.1",
 | 
			
		||||
    "style-loader": "^3.3.1",
 | 
			
		||||
    "typescript": "4.9.4",
 | 
			
		||||
    "uuid": "9.0.0",
 | 
			
		||||
 
 | 
			
		||||
@@ -73,6 +73,10 @@ export default class Editor extends EventEmitter {
 | 
			
		||||
 | 
			
		||||
        return new Promise((resolve, reject) => {
 | 
			
		||||
            const transaction = this.openmct.objects.getActiveTransaction();
 | 
			
		||||
            if (!transaction) {
 | 
			
		||||
                return resolve();
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            transaction.cancel()
 | 
			
		||||
                .then(resolve)
 | 
			
		||||
                .catch(reject)
 | 
			
		||||
 
 | 
			
		||||
@@ -193,23 +193,27 @@ export default class ObjectAPI {
 | 
			
		||||
     * @memberof module:openmct.ObjectProvider#
 | 
			
		||||
     * @param {string} key the key for the domain object to load
 | 
			
		||||
     * @param {AbortController.signal} abortSignal (optional) signal to abort fetch requests
 | 
			
		||||
     * @param {boolean} forceRemote defaults to false. If true, will skip cached and
 | 
			
		||||
     *          dirty/in-transaction objects use and the provider.get method
 | 
			
		||||
     * @returns {Promise} a promise which will resolve when the domain object
 | 
			
		||||
     *          has been saved, or be rejected if it cannot be saved
 | 
			
		||||
     */
 | 
			
		||||
    get(identifier, abortSignal) {
 | 
			
		||||
    get(identifier, abortSignal, forceRemote = false) {
 | 
			
		||||
        let keystring = this.makeKeyString(identifier);
 | 
			
		||||
 | 
			
		||||
        if (this.cache[keystring] !== undefined) {
 | 
			
		||||
            return this.cache[keystring];
 | 
			
		||||
        }
 | 
			
		||||
        if (!forceRemote) {
 | 
			
		||||
            if (this.cache[keystring] !== undefined) {
 | 
			
		||||
                return this.cache[keystring];
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
        identifier = utils.parseKeyString(identifier);
 | 
			
		||||
            identifier = utils.parseKeyString(identifier);
 | 
			
		||||
 | 
			
		||||
        if (this.isTransactionActive()) {
 | 
			
		||||
            let dirtyObject = this.transaction.getDirtyObject(identifier);
 | 
			
		||||
            if (this.isTransactionActive()) {
 | 
			
		||||
                let dirtyObject = this.transaction.getDirtyObject(identifier);
 | 
			
		||||
 | 
			
		||||
            if (dirtyObject) {
 | 
			
		||||
                return Promise.resolve(dirtyObject);
 | 
			
		||||
                if (dirtyObject) {
 | 
			
		||||
                    return Promise.resolve(dirtyObject);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
@@ -391,7 +395,6 @@ export default class ObjectAPI {
 | 
			
		||||
                lastPersistedTime = domainObject.persisted;
 | 
			
		||||
                const persistedTime = Date.now();
 | 
			
		||||
                this.#mutate(domainObject, 'persisted', persistedTime);
 | 
			
		||||
 | 
			
		||||
                savedObjectPromise = provider.update(domainObject);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
@@ -399,7 +402,7 @@ export default class ObjectAPI {
 | 
			
		||||
                savedObjectPromise.then(response => {
 | 
			
		||||
                    savedResolve(response);
 | 
			
		||||
                }).catch((error) => {
 | 
			
		||||
                    if (lastPersistedTime !== undefined) {
 | 
			
		||||
                    if (!isNewObject) {
 | 
			
		||||
                        this.#mutate(domainObject, 'persisted', lastPersistedTime);
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
@@ -410,9 +413,20 @@ export default class ObjectAPI {
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return result.catch((error) => {
 | 
			
		||||
        return result.catch(async (error) => {
 | 
			
		||||
            if (error instanceof this.errors.Conflict) {
 | 
			
		||||
                this.openmct.notifications.error(`Conflict detected while saving ${this.makeKeyString(domainObject.identifier)}`);
 | 
			
		||||
                // Synchronized objects will resolve their own conflicts
 | 
			
		||||
                if (this.SYNCHRONIZED_OBJECT_TYPES.includes(domainObject.type)) {
 | 
			
		||||
                    this.openmct.notifications.info(`Conflict detected while saving "${this.makeKeyString(domainObject.name)}", attempting to resolve`);
 | 
			
		||||
                } else {
 | 
			
		||||
                    this.openmct.notifications.error(`Conflict detected while saving ${this.makeKeyString(domainObject.identifier)}`);
 | 
			
		||||
 | 
			
		||||
                    if (this.isTransactionActive()) {
 | 
			
		||||
                        this.endTransaction();
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    await this.refresh(domainObject);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            throw error;
 | 
			
		||||
@@ -726,6 +740,30 @@ export default class ObjectAPI {
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Parse and construct an objectPath from the object's navigation path.
 | 
			
		||||
     *
 | 
			
		||||
     * @param {string} navigationPath
 | 
			
		||||
     * @returns {DomainObject[]} objectPath
 | 
			
		||||
     */
 | 
			
		||||
    async getRelativeObjectPath(navigationPath) {
 | 
			
		||||
        const identifierRegexp = /mine|[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi;
 | 
			
		||||
        const identifiers = navigationPath.split('?')[0].match(identifierRegexp);
 | 
			
		||||
        if (!identifiers) {
 | 
			
		||||
            return [];
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        identifiers.unshift('ROOT');
 | 
			
		||||
 | 
			
		||||
        const objectPath = (await Promise.all(
 | 
			
		||||
            identifiers.map(
 | 
			
		||||
                identifier => this.get(utils.parseKeyString(identifier))
 | 
			
		||||
            )
 | 
			
		||||
        )).reverse();
 | 
			
		||||
 | 
			
		||||
        return objectPath;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    isObjectPathToALink(domainObject, objectPath) {
 | 
			
		||||
        return objectPath !== undefined
 | 
			
		||||
            && objectPath.length > 1
 | 
			
		||||
 
 | 
			
		||||
@@ -73,19 +73,21 @@ export default class CreateAction extends PropertiesAction {
 | 
			
		||||
            title: 'Saving'
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        const success = await this.openmct.objects.save(this.domainObject);
 | 
			
		||||
        if (success) {
 | 
			
		||||
        try {
 | 
			
		||||
            await this.openmct.objects.save(this.domainObject);
 | 
			
		||||
            const compositionCollection = await this.openmct.composition.get(parentDomainObject);
 | 
			
		||||
            compositionCollection.add(this.domainObject);
 | 
			
		||||
 | 
			
		||||
            this._navigateAndEdit(this.domainObject, parentDomainObjectPath);
 | 
			
		||||
 | 
			
		||||
            this.openmct.notifications.info('Save successful');
 | 
			
		||||
        } else {
 | 
			
		||||
            this.openmct.notifications.error('Error saving objects');
 | 
			
		||||
        } catch (err) {
 | 
			
		||||
            console.error(err);
 | 
			
		||||
            this.openmct.notifications.error(`Error saving objects: ${err}`);
 | 
			
		||||
        } finally {
 | 
			
		||||
            dialog.dismiss();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        dialog.dismiss();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
 
 | 
			
		||||
@@ -553,7 +553,7 @@ export default {
 | 
			
		||||
    },
 | 
			
		||||
    watch: {
 | 
			
		||||
        imageHistory: {
 | 
			
		||||
            handler(newHistory, _oldHistory) {
 | 
			
		||||
            async handler(newHistory, oldHistory) {
 | 
			
		||||
                const newSize = newHistory.length;
 | 
			
		||||
                let imageIndex = newSize > 0 ? newSize - 1 : undefined;
 | 
			
		||||
                if (this.focusedImageTimestamp !== undefined) {
 | 
			
		||||
@@ -583,7 +583,10 @@ export default {
 | 
			
		||||
                    this.setFocusedImage(imageIndex);
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                this.scrollHandler();
 | 
			
		||||
                await this.scrollHandler();
 | 
			
		||||
                if (oldHistory?.length > 0) {
 | 
			
		||||
                    this.animateThumbScroll = true;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
            },
 | 
			
		||||
            deep: true
 | 
			
		||||
@@ -653,8 +656,6 @@ export default {
 | 
			
		||||
 | 
			
		||||
        this.listenTo(this.focusedImageWrapper, 'wheel', this.wheelZoom, this);
 | 
			
		||||
        this.loadVisibleLayers();
 | 
			
		||||
        // // set after render so initial scroll event is skipped
 | 
			
		||||
        setTimeout(this.setScrollBehavior, 3 * 1000);
 | 
			
		||||
    },
 | 
			
		||||
    beforeDestroy() {
 | 
			
		||||
        this.persistVisibleLayers();
 | 
			
		||||
@@ -787,7 +788,7 @@ export default {
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        persistVisibleLayers() {
 | 
			
		||||
            if (this.domainObject.configuration) {
 | 
			
		||||
            if (this.domainObject.configuration && this.openmct.objects.supportsMutation(this.domainObject.identifier)) {
 | 
			
		||||
                this.openmct.objects.mutate(this.domainObject, 'configuration.layers', this.layers);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
@@ -878,7 +879,7 @@ export default {
 | 
			
		||||
                this.scrollHandler();
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        async scrollToFocused() {
 | 
			
		||||
        scrollToFocused() {
 | 
			
		||||
            const thumbsWrapper = this.$refs.thumbsWrapper;
 | 
			
		||||
            if (!thumbsWrapper) {
 | 
			
		||||
                return;
 | 
			
		||||
@@ -893,7 +894,6 @@ export default {
 | 
			
		||||
            // caused undesirable behavior in layouts
 | 
			
		||||
            // and could not simply be scoped to the parent element
 | 
			
		||||
            if (this.isComposedInLayout) {
 | 
			
		||||
                await Vue.nextTick();
 | 
			
		||||
                const wrapperWidth = this.$refs.thumbsWrapper.clientWidth ?? 0;
 | 
			
		||||
                this.$refs.thumbsWrapper.scrollLeft = (
 | 
			
		||||
                    domThumb.offsetLeft - (wrapperWidth - domThumb.clientWidth) / 2);
 | 
			
		||||
@@ -917,21 +917,12 @@ export default {
 | 
			
		||||
            await Vue.nextTick();
 | 
			
		||||
            this.$refs.thumbsWrapper.scrollLeft = scrollWidth;
 | 
			
		||||
        },
 | 
			
		||||
        async scrollHandler() {
 | 
			
		||||
        scrollHandler() {
 | 
			
		||||
            if (this.isPaused) {
 | 
			
		||||
                await this.scrollToFocused();
 | 
			
		||||
 | 
			
		||||
                return;
 | 
			
		||||
                return this.scrollToFocused();
 | 
			
		||||
            } else if (this.autoScroll) {
 | 
			
		||||
                return this.scrollToRight();
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (this.autoScroll) {
 | 
			
		||||
 | 
			
		||||
                this.scrollToRight();
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
        },
 | 
			
		||||
        setScrollBehavior(value = true) {
 | 
			
		||||
            this.animateThumbScroll = value;
 | 
			
		||||
        },
 | 
			
		||||
        matchIndexOfPreviousImage(previous, imageHistory) {
 | 
			
		||||
            // match logic uses a composite of url and time to account
 | 
			
		||||
 
 | 
			
		||||
@@ -28,6 +28,7 @@ function copyRelatedMetadata(metadata) {
 | 
			
		||||
    return copiedMetadata;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
import IndependentTimeContext from "@/api/time/IndependentTimeContext";
 | 
			
		||||
export default class RelatedTelemetry {
 | 
			
		||||
 | 
			
		||||
    constructor(openmct, domainObject, telemetryKeys) {
 | 
			
		||||
@@ -88,9 +89,31 @@ export default class RelatedTelemetry {
 | 
			
		||||
            this[key].historicalDomainObject = await this._openmct.objects.get(this[key].historical.telemetryObjectId);
 | 
			
		||||
 | 
			
		||||
            this[key].requestLatestFor = async (datum) => {
 | 
			
		||||
                const options = {
 | 
			
		||||
                // We need to create a throwaway time context and pass it along
 | 
			
		||||
                // as a request option. We do this to "trick" the Time API
 | 
			
		||||
                // into thinking we are in fixed time mode in order to bypass this logic:
 | 
			
		||||
                // https://github.com/akhenry/openmct-yamcs/blob/1060d42ebe43bf346dac0f6a8068cb288ade4ba4/src/providers/historical-telemetry-provider.js#L59
 | 
			
		||||
                // Context: https://github.com/akhenry/openmct-yamcs/pull/217
 | 
			
		||||
                const ephemeralContext = new IndependentTimeContext(
 | 
			
		||||
                    this._openmct,
 | 
			
		||||
                    this._openmct.time,
 | 
			
		||||
                    [this[key].historicalDomainObject]
 | 
			
		||||
                );
 | 
			
		||||
 | 
			
		||||
                // Stop following the global context, stop the clock,
 | 
			
		||||
                // and set bounds.
 | 
			
		||||
                ephemeralContext.resetContext();
 | 
			
		||||
                const newBounds = {
 | 
			
		||||
                    start: this._openmct.time.bounds().start,
 | 
			
		||||
                    end: this._parseTime(datum),
 | 
			
		||||
                    end: this._parseTime(datum)
 | 
			
		||||
                };
 | 
			
		||||
                ephemeralContext.stopClock();
 | 
			
		||||
                ephemeralContext.bounds(newBounds);
 | 
			
		||||
 | 
			
		||||
                const options = {
 | 
			
		||||
                    start: newBounds.start,
 | 
			
		||||
                    end: newBounds.end,
 | 
			
		||||
                    timeContext: ephemeralContext,
 | 
			
		||||
                    strategy: 'latest'
 | 
			
		||||
                };
 | 
			
		||||
                let results = await this._openmct.telemetry
 | 
			
		||||
 
 | 
			
		||||
@@ -50,7 +50,7 @@
 | 
			
		||||
        <Sidebar
 | 
			
		||||
            ref="sidebar"
 | 
			
		||||
            class="c-notebook__nav c-sidebar c-drawer c-drawer--align-left"
 | 
			
		||||
            :class="[{'is-expanded': showNav}, {'c-drawer--push': !sidebarCoversEntries}, {'c-drawer--overlays': sidebarCoversEntries}]"
 | 
			
		||||
            :class="sidebarClasses"
 | 
			
		||||
            :default-page-id="defaultPageId"
 | 
			
		||||
            :selected-page-id="getSelectedPageId()"
 | 
			
		||||
            :default-section-id="defaultSectionId"
 | 
			
		||||
@@ -123,6 +123,7 @@
 | 
			
		||||
            </div>
 | 
			
		||||
            <div
 | 
			
		||||
                v-if="selectedPage && !selectedPage.isLocked"
 | 
			
		||||
                :class="{ 'disabled': activeTransaction }"
 | 
			
		||||
                class="c-notebook__drag-area icon-plus"
 | 
			
		||||
                @click="newEntry()"
 | 
			
		||||
                @dragover="dragOver"
 | 
			
		||||
@@ -133,6 +134,11 @@
 | 
			
		||||
                    To start a new entry, click here or drag and drop any object
 | 
			
		||||
                </span>
 | 
			
		||||
            </div>
 | 
			
		||||
            <progress-bar
 | 
			
		||||
                v-if="savingTransaction"
 | 
			
		||||
                class="c-telemetry-table__progress-bar"
 | 
			
		||||
                :model="{ progressPerc: undefined }"
 | 
			
		||||
            />
 | 
			
		||||
            <div
 | 
			
		||||
                v-if="selectedPage && selectedPage.isLocked"
 | 
			
		||||
                class="c-notebook__page-locked"
 | 
			
		||||
@@ -183,6 +189,7 @@ import NotebookEntry from './NotebookEntry.vue';
 | 
			
		||||
import Search from '@/ui/components/search.vue';
 | 
			
		||||
import SearchResults from './SearchResults.vue';
 | 
			
		||||
import Sidebar from './Sidebar.vue';
 | 
			
		||||
import ProgressBar from '../../../ui/components/ProgressBar.vue';
 | 
			
		||||
import { clearDefaultNotebook, getDefaultNotebook, setDefaultNotebook, setDefaultNotebookSectionId, setDefaultNotebookPageId } from '../utils/notebook-storage';
 | 
			
		||||
import { addNotebookEntry, createNewEmbed, getEntryPosById, getNotebookEntries, mutateObject } from '../utils/notebook-entries';
 | 
			
		||||
import { saveNotebookImageDomainObject, updateNamespaceOfDomainObject } from '../utils/notebook-image';
 | 
			
		||||
@@ -200,7 +207,8 @@ export default {
 | 
			
		||||
        NotebookEntry,
 | 
			
		||||
        Search,
 | 
			
		||||
        SearchResults,
 | 
			
		||||
        Sidebar
 | 
			
		||||
        Sidebar,
 | 
			
		||||
        ProgressBar
 | 
			
		||||
    },
 | 
			
		||||
    inject: ['agent', 'openmct', 'snapshotContainer'],
 | 
			
		||||
    props: {
 | 
			
		||||
@@ -225,7 +233,9 @@ export default {
 | 
			
		||||
            showNav: false,
 | 
			
		||||
            sidebarCoversEntries: false,
 | 
			
		||||
            filteredAndSortedEntries: [],
 | 
			
		||||
            notebookAnnotations: {}
 | 
			
		||||
            notebookAnnotations: {},
 | 
			
		||||
            activeTransaction: false,
 | 
			
		||||
            savingTransaction: false
 | 
			
		||||
        };
 | 
			
		||||
    },
 | 
			
		||||
    computed: {
 | 
			
		||||
@@ -270,6 +280,20 @@ export default {
 | 
			
		||||
 | 
			
		||||
            return this.sections[0];
 | 
			
		||||
        },
 | 
			
		||||
        sidebarClasses() {
 | 
			
		||||
            let sidebarClasses = [];
 | 
			
		||||
            if (this.showNav) {
 | 
			
		||||
                sidebarClasses.push('is-expanded');
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (this.sidebarCoversEntries) {
 | 
			
		||||
                sidebarClasses.push('c-drawer--overlays');
 | 
			
		||||
            } else {
 | 
			
		||||
                sidebarClasses.push('c-drawer--push');
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return sidebarClasses;
 | 
			
		||||
        },
 | 
			
		||||
        showLockButton() {
 | 
			
		||||
            const entries = getNotebookEntries(this.domainObject, this.selectedSection, this.selectedPage);
 | 
			
		||||
 | 
			
		||||
@@ -297,6 +321,8 @@ export default {
 | 
			
		||||
        this.formatSidebar();
 | 
			
		||||
        this.setSectionAndPageFromUrl();
 | 
			
		||||
 | 
			
		||||
        this.transaction = null;
 | 
			
		||||
 | 
			
		||||
        window.addEventListener('orientationchange', this.formatSidebar);
 | 
			
		||||
        window.addEventListener('hashchange', this.setSectionAndPageFromUrl);
 | 
			
		||||
        this.filterAndSortEntries();
 | 
			
		||||
@@ -749,6 +775,7 @@ export default {
 | 
			
		||||
            return section.id;
 | 
			
		||||
        },
 | 
			
		||||
        async newEntry(embed = null) {
 | 
			
		||||
            this.startTransaction();
 | 
			
		||||
            this.resetSearch();
 | 
			
		||||
            const notebookStorage = this.createNotebookStorageObject();
 | 
			
		||||
            this.updateDefaultNotebook(notebookStorage);
 | 
			
		||||
@@ -891,20 +918,34 @@ export default {
 | 
			
		||||
        },
 | 
			
		||||
        startTransaction() {
 | 
			
		||||
            if (!this.openmct.objects.isTransactionActive()) {
 | 
			
		||||
                this.activeTransaction = true;
 | 
			
		||||
                this.transaction = this.openmct.objects.startTransaction();
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        async saveTransaction() {
 | 
			
		||||
            if (this.transaction !== undefined) {
 | 
			
		||||
                await this.transaction.commit();
 | 
			
		||||
                this.openmct.objects.endTransaction();
 | 
			
		||||
            if (this.transaction !== null) {
 | 
			
		||||
                this.savingTransaction = true;
 | 
			
		||||
                try {
 | 
			
		||||
                    await this.transaction.commit();
 | 
			
		||||
                } finally {
 | 
			
		||||
                    this.endTransaction();
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        async cancelTransaction() {
 | 
			
		||||
            if (this.transaction !== undefined) {
 | 
			
		||||
                await this.transaction.cancel();
 | 
			
		||||
                this.openmct.objects.endTransaction();
 | 
			
		||||
            if (this.transaction !== null) {
 | 
			
		||||
                try {
 | 
			
		||||
                    await this.transaction.cancel();
 | 
			
		||||
                } finally {
 | 
			
		||||
                    this.endTransaction();
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        endTransaction() {
 | 
			
		||||
            this.openmct.objects.endTransaction();
 | 
			
		||||
            this.transaction = null;
 | 
			
		||||
            this.savingTransaction = false;
 | 
			
		||||
            this.activeTransaction = false;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -74,19 +74,22 @@ async function resolveNotebookTagConflicts(localAnnotation, openmct) {
 | 
			
		||||
 | 
			
		||||
async function resolveNotebookEntryConflicts(localMutable, openmct) {
 | 
			
		||||
    if (localMutable.configuration.entries) {
 | 
			
		||||
        const FORCE_REMOTE = true;
 | 
			
		||||
        const localEntries = structuredClone(localMutable.configuration.entries);
 | 
			
		||||
        const remoteMutable = await openmct.objects.getMutable(localMutable.identifier);
 | 
			
		||||
        applyLocalEntries(remoteMutable, localEntries, openmct);
 | 
			
		||||
        openmct.objects.destroyMutable(remoteMutable);
 | 
			
		||||
        const remoteObject = await openmct.objects.get(localMutable.identifier, undefined, FORCE_REMOTE);
 | 
			
		||||
 | 
			
		||||
        return applyLocalEntries(remoteObject, localEntries, openmct);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return true;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function applyLocalEntries(mutable, entries, openmct) {
 | 
			
		||||
function applyLocalEntries(remoteObject, entries, openmct) {
 | 
			
		||||
    let shouldSave = false;
 | 
			
		||||
 | 
			
		||||
    Object.entries(entries).forEach(([sectionKey, pagesInSection]) => {
 | 
			
		||||
        Object.entries(pagesInSection).forEach(([pageKey, localEntries]) => {
 | 
			
		||||
            const remoteEntries = mutable.configuration.entries[sectionKey][pageKey];
 | 
			
		||||
            const remoteEntries = remoteObject.configuration.entries[sectionKey][pageKey];
 | 
			
		||||
            const mergedEntries = [].concat(remoteEntries);
 | 
			
		||||
            let shouldMutate = false;
 | 
			
		||||
 | 
			
		||||
@@ -110,8 +113,13 @@ function applyLocalEntries(mutable, entries, openmct) {
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            if (shouldMutate) {
 | 
			
		||||
                openmct.objects.mutate(mutable, `configuration.entries.${sectionKey}.${pageKey}`, mergedEntries);
 | 
			
		||||
                shouldSave = true;
 | 
			
		||||
                openmct.objects.mutate(remoteObject, `configuration.entries.${sectionKey}.${pageKey}`, mergedEntries);
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    if (shouldSave) {
 | 
			
		||||
        return openmct.objects.save(remoteObject);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -36,8 +36,8 @@ export default function () {
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let wrappedFunction = openmct.objects.get;
 | 
			
		||||
        openmct.objects.get = function migrate(identifier) {
 | 
			
		||||
            return wrappedFunction.apply(openmct.objects, [identifier])
 | 
			
		||||
        openmct.objects.get = function migrate() {
 | 
			
		||||
            return wrappedFunction.apply(openmct.objects, [...arguments])
 | 
			
		||||
                .then(function (object) {
 | 
			
		||||
                    if (needsMigration(object)) {
 | 
			
		||||
                        migrateObject(object)
 | 
			
		||||
 
 | 
			
		||||
@@ -28,6 +28,7 @@
 | 
			
		||||
                connected = false;
 | 
			
		||||
                // stop listening for events
 | 
			
		||||
                couchEventSource.removeEventListener('message', self.onCouchMessage);
 | 
			
		||||
                couchEventSource.close();
 | 
			
		||||
                console.debug('🚪 Closed couch connection 🚪');
 | 
			
		||||
 | 
			
		||||
                return;
 | 
			
		||||
 
 | 
			
		||||
@@ -96,8 +96,13 @@ class CouchObjectProvider {
 | 
			
		||||
            let keyString = this.openmct.objects.makeKeyString(objectIdentifier);
 | 
			
		||||
            //TODO: Optimize this so that we don't 'get' the object if it's current revision (from this.objectQueue) is the same as the one we already have.
 | 
			
		||||
            let observersForObject = this.observers[keyString];
 | 
			
		||||
            let isInTransaction = false;
 | 
			
		||||
 | 
			
		||||
            if (observersForObject) {
 | 
			
		||||
            if (this.openmct.objects.isTransactionActive()) {
 | 
			
		||||
                isInTransaction = this.openmct.objects.transaction.getDirtyObject(objectIdentifier);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (observersForObject && !isInTransaction) {
 | 
			
		||||
                observersForObject.forEach(async (observer) => {
 | 
			
		||||
                    const updatedObject = await this.get(objectIdentifier);
 | 
			
		||||
                    if (this.isSynchronizedObject(updatedObject)) {
 | 
			
		||||
@@ -219,7 +224,12 @@ class CouchObjectProvider {
 | 
			
		||||
                console.error(error.message);
 | 
			
		||||
                throw new Error(`CouchDB Error - No response"`);
 | 
			
		||||
            } else {
 | 
			
		||||
                console.error(error.message);
 | 
			
		||||
                if (body?.model && isNotebookOrAnnotationType(body.model)) {
 | 
			
		||||
                    // warn since we handle conflicts for notebooks
 | 
			
		||||
                    console.warn(error.message);
 | 
			
		||||
                } else {
 | 
			
		||||
                    console.error(error.message);
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                throw error;
 | 
			
		||||
            }
 | 
			
		||||
@@ -234,7 +244,8 @@ class CouchObjectProvider {
 | 
			
		||||
    #handleResponseCode(status, json, fetchOptions) {
 | 
			
		||||
        this.indicator.setIndicatorToState(this.#statusCodeToIndicatorState(status));
 | 
			
		||||
        if (status === CouchObjectProvider.HTTP_CONFLICT) {
 | 
			
		||||
            throw new this.openmct.objects.errors.Conflict(`Conflict persisting ${fetchOptions.body.name}`);
 | 
			
		||||
            const objectName = JSON.parse(fetchOptions.body)?.model?.name;
 | 
			
		||||
            throw new this.openmct.objects.errors.Conflict(`Conflict persisting "${objectName}"`);
 | 
			
		||||
        } else if (status >= CouchObjectProvider.HTTP_BAD_REQUEST) {
 | 
			
		||||
            if (!json.error || !json.reason) {
 | 
			
		||||
                throw new Error(`CouchDB Error ${status}`);
 | 
			
		||||
 
 | 
			
		||||
@@ -62,8 +62,8 @@ export default class RemoteClock extends DefaultClock {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    start() {
 | 
			
		||||
        this.openmct.time.on('timeSystem', this._timeSystemChange);
 | 
			
		||||
        this.openmct.objects.get(this.identifier).then((domainObject) => {
 | 
			
		||||
            this.openmct.time.on('timeSystem', this._timeSystemChange);
 | 
			
		||||
            this.timeTelemetryObject = domainObject;
 | 
			
		||||
            this.metadata = this.openmct.telemetry.getMetadata(domainObject);
 | 
			
		||||
            this._timeSystemChange();
 | 
			
		||||
 
 | 
			
		||||
@@ -71,7 +71,10 @@ describe("the RemoteClock plugin", () => {
 | 
			
		||||
            parse: (datum) => datum.key
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        beforeEach(async () => {
 | 
			
		||||
        let objectPromise;
 | 
			
		||||
        let requestPromise;
 | 
			
		||||
 | 
			
		||||
        beforeEach(() => {
 | 
			
		||||
            openmct.install(openmct.plugins.RemoteClock(TIME_TELEMETRY_ID));
 | 
			
		||||
 | 
			
		||||
            let clocks = openmct.time.getAllClocks();
 | 
			
		||||
@@ -89,7 +92,9 @@ describe("the RemoteClock plugin", () => {
 | 
			
		||||
            spyOn(metadata, 'value').and.callThrough();
 | 
			
		||||
 | 
			
		||||
            let requestPromiseResolve;
 | 
			
		||||
            let requestPromise = new Promise((resolve) => {
 | 
			
		||||
            let objectPromiseResolve;
 | 
			
		||||
 | 
			
		||||
            requestPromise = new Promise((resolve) => {
 | 
			
		||||
                requestPromiseResolve = resolve;
 | 
			
		||||
            });
 | 
			
		||||
            spyOn(openmct.telemetry, 'request').and.callFake(() => {
 | 
			
		||||
@@ -98,8 +103,7 @@ describe("the RemoteClock plugin", () => {
 | 
			
		||||
                return requestPromise;
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            let objectPromiseResolve;
 | 
			
		||||
            let objectPromise = new Promise((resolve) => {
 | 
			
		||||
            objectPromise = new Promise((resolve) => {
 | 
			
		||||
                objectPromiseResolve = resolve;
 | 
			
		||||
            });
 | 
			
		||||
            spyOn(openmct.objects, 'get').and.callFake(() => {
 | 
			
		||||
@@ -112,39 +116,48 @@ describe("the RemoteClock plugin", () => {
 | 
			
		||||
                start: OFFSET_START,
 | 
			
		||||
                end: OFFSET_END
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            await Promise.all([objectPromiseResolve, requestPromise]);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        it('is available and sets up initial values and listeners', () => {
 | 
			
		||||
            expect(remoteClock.key).toEqual(REMOTE_CLOCK_KEY);
 | 
			
		||||
            expect(remoteClock.identifier).toEqual(TIME_TELEMETRY_ID);
 | 
			
		||||
            expect(openmct.time.on).toHaveBeenCalledWith('timeSystem', remoteClock._timeSystemChange);
 | 
			
		||||
            expect(remoteClock._timeSystemChange).toHaveBeenCalled();
 | 
			
		||||
        it("Does not throw error if time system is changed before remote clock initialized", () => {
 | 
			
		||||
            expect(() => openmct.time.timeSystem('utc')).not.toThrow();
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        it('will request/store the object based on the identifier passed in', () => {
 | 
			
		||||
            expect(remoteClock.timeTelemetryObject).toEqual(object);
 | 
			
		||||
        describe('once resolved', () => {
 | 
			
		||||
            beforeEach(async () => {
 | 
			
		||||
                await Promise.all([objectPromise, requestPromise]);
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            it('is available and sets up initial values and listeners', () => {
 | 
			
		||||
                expect(remoteClock.key).toEqual(REMOTE_CLOCK_KEY);
 | 
			
		||||
                expect(remoteClock.identifier).toEqual(TIME_TELEMETRY_ID);
 | 
			
		||||
                expect(openmct.time.on).toHaveBeenCalledWith('timeSystem', remoteClock._timeSystemChange);
 | 
			
		||||
                expect(remoteClock._timeSystemChange).toHaveBeenCalled();
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            it('will request/store the object based on the identifier passed in', () => {
 | 
			
		||||
                expect(remoteClock.timeTelemetryObject).toEqual(object);
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            it('will request metadata and set up formatters', () => {
 | 
			
		||||
                expect(remoteClock.metadata).toEqual(metadata);
 | 
			
		||||
                expect(metadata.value).toHaveBeenCalled();
 | 
			
		||||
                expect(openmct.telemetry.getValueFormatter).toHaveBeenCalledWith(metadataValue);
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            it('will request the latest datum for the object it received and process the datum returned', () => {
 | 
			
		||||
                expect(openmct.telemetry.request).toHaveBeenCalledWith(remoteClock.timeTelemetryObject, REQ_OPTIONS);
 | 
			
		||||
                expect(boundsCallback).toHaveBeenCalledWith({
 | 
			
		||||
                    start: TIME_VALUE + OFFSET_START,
 | 
			
		||||
                    end: TIME_VALUE + OFFSET_END
 | 
			
		||||
                }, true);
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            it('will set up subscriptions correctly', () => {
 | 
			
		||||
                expect(remoteClock._unsubscribe).toBeDefined();
 | 
			
		||||
                expect(openmct.telemetry.subscribe).toHaveBeenCalledWith(remoteClock.timeTelemetryObject, remoteClock._processDatum);
 | 
			
		||||
            });
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        it('will request metadata and set up formatters', () => {
 | 
			
		||||
            expect(remoteClock.metadata).toEqual(metadata);
 | 
			
		||||
            expect(metadata.value).toHaveBeenCalled();
 | 
			
		||||
            expect(openmct.telemetry.getValueFormatter).toHaveBeenCalledWith(metadataValue);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        it('will request the latest datum for the object it received and process the datum returned', () => {
 | 
			
		||||
            expect(openmct.telemetry.request).toHaveBeenCalledWith(remoteClock.timeTelemetryObject, REQ_OPTIONS);
 | 
			
		||||
            expect(boundsCallback).toHaveBeenCalledWith({
 | 
			
		||||
                start: TIME_VALUE + OFFSET_START,
 | 
			
		||||
                end: TIME_VALUE + OFFSET_END
 | 
			
		||||
            }, true);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        it('will set up subscriptions correctly', () => {
 | 
			
		||||
            expect(remoteClock._unsubscribe).toBeDefined();
 | 
			
		||||
            expect(openmct.telemetry.subscribe).toHaveBeenCalledWith(remoteClock.timeTelemetryObject, remoteClock._processDatum);
 | 
			
		||||
        });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -178,7 +178,7 @@ define([
 | 
			
		||||
                if (this.paused) {
 | 
			
		||||
                    this.delayedActions.push(this.tableRows.addRows.bind(this, telemetryRows, 'add'));
 | 
			
		||||
                } else {
 | 
			
		||||
                    this.tableRows.addRows(telemetryRows, 'add');
 | 
			
		||||
                    this.tableRows.addRows(telemetryRows);
 | 
			
		||||
                }
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
@@ -229,7 +229,7 @@ define([
 | 
			
		||||
                });
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            this.tableRows.addRows(allRows, 'filter');
 | 
			
		||||
            this.tableRows.clearRowsFromTableAndFilter(allRows);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        updateFilters(updatedFilters) {
 | 
			
		||||
 
 | 
			
		||||
@@ -61,30 +61,39 @@ define(
 | 
			
		||||
                this.emit('remove', removed);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            addRows(rows, type = 'add') {
 | 
			
		||||
                if (this.sortOptions === undefined) {
 | 
			
		||||
                    throw 'Please specify sort options';
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                let isFilterTriggeredReset = type === 'filter';
 | 
			
		||||
                let anyActiveFilters = Object.keys(this.columnFilters).length > 0;
 | 
			
		||||
                let rowsToAdd = !anyActiveFilters ? rows : rows.filter(this.matchesFilters, this);
 | 
			
		||||
 | 
			
		||||
                // if type is filter, then it's a reset of all rows,
 | 
			
		||||
                // need to wipe current rows
 | 
			
		||||
                if (isFilterTriggeredReset) {
 | 
			
		||||
                    this.rows = [];
 | 
			
		||||
                }
 | 
			
		||||
            addRows(rows) {
 | 
			
		||||
                let rowsToAdd = this.filterRows(rows);
 | 
			
		||||
 | 
			
		||||
                this.sortAndMergeRows(rowsToAdd);
 | 
			
		||||
 | 
			
		||||
                // we emit filter no matter what to trigger
 | 
			
		||||
                // an update of visible rows
 | 
			
		||||
                if (rowsToAdd.length > 0 || isFilterTriggeredReset) {
 | 
			
		||||
                    this.emit(type, rowsToAdd);
 | 
			
		||||
                if (rowsToAdd.length > 0) {
 | 
			
		||||
                    this.emit('add', rowsToAdd);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            clearRowsFromTableAndFilter(rows) {
 | 
			
		||||
 | 
			
		||||
                let rowsToAdd = this.filterRows(rows);
 | 
			
		||||
                // Reset of all rows, need to wipe current rows
 | 
			
		||||
                this.rows = [];
 | 
			
		||||
 | 
			
		||||
                this.sortAndMergeRows(rowsToAdd);
 | 
			
		||||
 | 
			
		||||
                // We emit filter and update of visible rows
 | 
			
		||||
                this.emit('filter', rowsToAdd);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            filterRows(rows) {
 | 
			
		||||
 | 
			
		||||
                if (Object.keys(this.columnFilters).length > 0) {
 | 
			
		||||
                    return rows.filter(this.matchesFilters, this);
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                return rows;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            sortAndMergeRows(rows) {
 | 
			
		||||
                const sortedRowsToAdd = this.sortCollection(rows);
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -101,7 +101,8 @@ export default {
 | 
			
		||||
                if (nowMarker) {
 | 
			
		||||
                    nowMarker.classList.remove('hidden');
 | 
			
		||||
                    nowMarker.style.height = this.contentHeight + 'px';
 | 
			
		||||
                    const now = this.xScale(Date.now());
 | 
			
		||||
                    const nowTimeStamp = this.openmct.time.clock().currentValue();
 | 
			
		||||
                    const now = this.xScale(nowTimeStamp);
 | 
			
		||||
                    nowMarker.style.left = now + this.offset + 'px';
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 
 | 
			
		||||
@@ -335,6 +335,7 @@ export default {
 | 
			
		||||
                dialog.dismiss();
 | 
			
		||||
                this.openmct.notifications.error('Error saving objects');
 | 
			
		||||
                console.error(error);
 | 
			
		||||
                this.openmct.editor.cancel();
 | 
			
		||||
            });
 | 
			
		||||
        },
 | 
			
		||||
        saveAndContinueEditing() {
 | 
			
		||||
 
 | 
			
		||||
@@ -53,8 +53,8 @@
 | 
			
		||||
        type="horizontal"
 | 
			
		||||
    >
 | 
			
		||||
        <pane
 | 
			
		||||
            id="tree-pane"
 | 
			
		||||
            class="l-shell__pane-tree"
 | 
			
		||||
            style="width: 300px;"
 | 
			
		||||
            handle="after"
 | 
			
		||||
            label="Browse"
 | 
			
		||||
            hide-param="hideTree"
 | 
			
		||||
@@ -75,11 +75,27 @@
 | 
			
		||||
                @click="handleSyncTreeNavigation"
 | 
			
		||||
            >
 | 
			
		||||
            </button>
 | 
			
		||||
            <mct-tree
 | 
			
		||||
                :sync-tree-navigation="triggerSync"
 | 
			
		||||
                :reset-tree-navigation="triggerReset"
 | 
			
		||||
                class="l-shell__tree"
 | 
			
		||||
            />
 | 
			
		||||
            <multipane
 | 
			
		||||
                type="vertical"
 | 
			
		||||
            >
 | 
			
		||||
                <pane
 | 
			
		||||
                    id="tree-pane"
 | 
			
		||||
                >
 | 
			
		||||
                    <mct-tree
 | 
			
		||||
                        :sync-tree-navigation="triggerSync"
 | 
			
		||||
                        :reset-tree-navigation="triggerReset"
 | 
			
		||||
                        class="l-shell__tree"
 | 
			
		||||
                    />
 | 
			
		||||
                </pane>
 | 
			
		||||
                <pane
 | 
			
		||||
                    handle="before"
 | 
			
		||||
                    label="Recently Viewed"
 | 
			
		||||
                >
 | 
			
		||||
                    <RecentObjects
 | 
			
		||||
                        class="l-shell__tree"
 | 
			
		||||
                    />
 | 
			
		||||
                </pane>
 | 
			
		||||
            </multipane>
 | 
			
		||||
        </pane>
 | 
			
		||||
        <pane class="l-shell__pane-main">
 | 
			
		||||
            <browse-bar
 | 
			
		||||
@@ -134,6 +150,7 @@ import Toolbar from '../toolbar/Toolbar.vue';
 | 
			
		||||
import AppLogo from './AppLogo.vue';
 | 
			
		||||
import Indicators from './status-bar/Indicators.vue';
 | 
			
		||||
import NotificationBanner from './status-bar/NotificationBanner.vue';
 | 
			
		||||
import RecentObjects from './RecentObjects.vue';
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
    components: {
 | 
			
		||||
@@ -148,7 +165,8 @@ export default {
 | 
			
		||||
        Toolbar,
 | 
			
		||||
        AppLogo,
 | 
			
		||||
        Indicators,
 | 
			
		||||
        NotificationBanner
 | 
			
		||||
        NotificationBanner,
 | 
			
		||||
        RecentObjects
 | 
			
		||||
    },
 | 
			
		||||
    inject: ['openmct'],
 | 
			
		||||
    data: function () {
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										105
									
								
								src/ui/layout/RecentObjects.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								src/ui/layout/RecentObjects.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,105 @@
 | 
			
		||||
<template>
 | 
			
		||||
<div
 | 
			
		||||
    class="c-tree-and-search l-shell__tree"
 | 
			
		||||
>
 | 
			
		||||
    <div
 | 
			
		||||
        class="c-tree-and-search__tree c-tree c-tree__scrollable"
 | 
			
		||||
    >
 | 
			
		||||
 | 
			
		||||
        <tree-item
 | 
			
		||||
            v-for="(recentItem, index) in treeItems"
 | 
			
		||||
            :key="`${recentItem.navigationPath}-recent-${index}`"
 | 
			
		||||
            :node="recentItem"
 | 
			
		||||
            :is-selector-tree="false"
 | 
			
		||||
            :selected-item="selectedItem"
 | 
			
		||||
            :left-offset="recentItem.leftOffset"
 | 
			
		||||
            :is-new="recentItem.isNew"
 | 
			
		||||
            :item-offset="itemOffset"
 | 
			
		||||
            :item-index="index"
 | 
			
		||||
            :item-height="itemHeight"
 | 
			
		||||
            :open-items="openTreeItems"
 | 
			
		||||
            :loading-items="treeItemLoading"
 | 
			
		||||
        />
 | 
			
		||||
        <!-- @tree-item-mounted="scrollToCheck($event)"
 | 
			
		||||
            @tree-item-action="treeItemAction(recentItem, $event)"
 | 
			
		||||
            @tree-item-destroyed="removeCompositionListenerFor($event)"
 | 
			
		||||
            @tree-item-selection="recentItemSelection(recentItem)" -->
 | 
			
		||||
    </div>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script>
 | 
			
		||||
import treeItem from './tree-item.vue';
 | 
			
		||||
import treeMixin from '../mixins/tree-mixin.js';
 | 
			
		||||
const MAX_RECENT_ITEMS = 20;
 | 
			
		||||
const LOCAL_STORAGE_KEY__RECENT_OBJECTS = 'mct-recent-objects';
 | 
			
		||||
export default {
 | 
			
		||||
    name: 'RecentObjects',
 | 
			
		||||
    components: {
 | 
			
		||||
        treeItem
 | 
			
		||||
    },
 | 
			
		||||
    mixins: [treeMixin],
 | 
			
		||||
    inject: ['openmct'],
 | 
			
		||||
    props: {
 | 
			
		||||
 | 
			
		||||
    },
 | 
			
		||||
    data() {
 | 
			
		||||
        return {
 | 
			
		||||
        };
 | 
			
		||||
    },
 | 
			
		||||
    async mounted() {
 | 
			
		||||
        this.openmct.router.on('change:hash', this.onHashChange);
 | 
			
		||||
 | 
			
		||||
        this.treeResizeObserver = new ResizeObserver(this.handleTreeResize);
 | 
			
		||||
        this.treeResizeObserver.observe(this.$el);
 | 
			
		||||
        await this.calculateHeights();
 | 
			
		||||
    },
 | 
			
		||||
    created() {
 | 
			
		||||
        this.handleTreeResize = _.debounce(this.handleTreeResize, 300);
 | 
			
		||||
    },
 | 
			
		||||
    destroyed() {
 | 
			
		||||
        this.openmct.router.off('change:hash', this.onHashChange);
 | 
			
		||||
    },
 | 
			
		||||
    methods: {
 | 
			
		||||
        async onHashChange(hash) {
 | 
			
		||||
            const objectPath = await this.openmct.objects.getRelativeObjectPath(hash);
 | 
			
		||||
            if (!objectPath.length) {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const navigationPath = `/browse/${this.openmct.objects.getRelativePath(objectPath.slice(0, -1))}`;
 | 
			
		||||
            const foundIndex = this.treeItems.findIndex((item) => {
 | 
			
		||||
                return navigationPath === item.navigationPath;
 | 
			
		||||
            });
 | 
			
		||||
            if (foundIndex > -1) {
 | 
			
		||||
                const removedItem = this.treeItems.splice(foundIndex, 1);
 | 
			
		||||
                this.selectedItem = removedItem[0];
 | 
			
		||||
            } else {
 | 
			
		||||
                this.selectedItem = {
 | 
			
		||||
                    id: objectPath[0].identifier,
 | 
			
		||||
                    object: objectPath[0],
 | 
			
		||||
                    objectPath,
 | 
			
		||||
                    navigationPath
 | 
			
		||||
                };
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            this.treeItems.unshift(this.selectedItem);
 | 
			
		||||
            while (this.treeItems.length > MAX_RECENT_ITEMS) {
 | 
			
		||||
                this.treeItems.pop();
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        async loadAndBuildTreeItemsFor(domainObject, parentObjectPath, abortSignal) {
 | 
			
		||||
            let collection = this.openmct.composition.get(domainObject);
 | 
			
		||||
            let composition = await collection.load(abortSignal);
 | 
			
		||||
 | 
			
		||||
            return composition.map((object) => {
 | 
			
		||||
                return this.buildTreeItem(object, parentObjectPath);
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style>
 | 
			
		||||
 | 
			
		||||
</style>
 | 
			
		||||
@@ -289,7 +289,7 @@
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        &__pane-tree {
 | 
			
		||||
            width: 300px;
 | 
			
		||||
            width: 100%;
 | 
			
		||||
            padding-left: nth($shellPanePad, 2);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -118,12 +118,13 @@
 | 
			
		||||
<script>
 | 
			
		||||
import _ from 'lodash';
 | 
			
		||||
import treeItem from './tree-item.vue';
 | 
			
		||||
import treeMixin from '../mixins/tree-mixin.js';
 | 
			
		||||
import search from '../components/search.vue';
 | 
			
		||||
 | 
			
		||||
const ITEM_BUFFER = 25;
 | 
			
		||||
const LOCAL_STORAGE_KEY__TREE_EXPANDED = 'mct-tree-expanded';
 | 
			
		||||
const SORT_MY_ITEMS_ALPH_ASC = true;
 | 
			
		||||
const TREE_ITEM_INDENT_PX = 18;
 | 
			
		||||
const SORT_MY_ITEMS_ALPH_ASC = true;
 | 
			
		||||
const LOCATOR_ITEM_COUNT_HEIGHT = 10; // how many tree items to make the locator selection box show
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
@@ -132,6 +133,7 @@ export default {
 | 
			
		||||
        search,
 | 
			
		||||
        treeItem
 | 
			
		||||
    },
 | 
			
		||||
    mixins: [treeMixin],
 | 
			
		||||
    inject: ['openmct'],
 | 
			
		||||
    props: {
 | 
			
		||||
        isSelectorTree: {
 | 
			
		||||
@@ -174,8 +176,7 @@ export default {
 | 
			
		||||
            itemOffset: 0,
 | 
			
		||||
            activeSearch: false,
 | 
			
		||||
            mainTreeTopMargin: undefined,
 | 
			
		||||
            selectedItem: {},
 | 
			
		||||
            observers: {}
 | 
			
		||||
            selectedItem: {}
 | 
			
		||||
        };
 | 
			
		||||
    },
 | 
			
		||||
    computed: {
 | 
			
		||||
@@ -215,21 +216,6 @@ export default {
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
    watch: {
 | 
			
		||||
        syncTreeNavigation() {
 | 
			
		||||
            this.searchValue = '';
 | 
			
		||||
 | 
			
		||||
            // if there is an abort controller, then a search is in progress and will need to be canceled
 | 
			
		||||
            if (this.abortSearchController) {
 | 
			
		||||
                this.abortSearchController.abort();
 | 
			
		||||
                delete this.abortSearchController;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (!this.openmct.router.path) {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            this.$nextTick(this.showCurrentPathInTree);
 | 
			
		||||
        },
 | 
			
		||||
        resetTreeNavigation() {
 | 
			
		||||
            [...this.openTreeItems].reverse().map(this.closeTreeItemByPath);
 | 
			
		||||
        },
 | 
			
		||||
@@ -277,10 +263,13 @@ export default {
 | 
			
		||||
            this.treeResizeObserver.disconnect();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.destroyObservers(this.observers);
 | 
			
		||||
        this.destroyObservers();
 | 
			
		||||
        this.destroyMutables();
 | 
			
		||||
    },
 | 
			
		||||
    methods: {
 | 
			
		||||
        async initialize() {
 | 
			
		||||
            this.observers = {};
 | 
			
		||||
            this.mutables = {};
 | 
			
		||||
            this.isLoading = true;
 | 
			
		||||
            this.getSavedOpenItems();
 | 
			
		||||
            this.treeResizeObserver = new ResizeObserver(this.handleTreeResize);
 | 
			
		||||
@@ -342,51 +331,6 @@ export default {
 | 
			
		||||
 | 
			
		||||
            return;
 | 
			
		||||
        },
 | 
			
		||||
        closeTreeItemByPath(path) {
 | 
			
		||||
            // if actively loading, abort
 | 
			
		||||
            if (this.isItemLoading(path)) {
 | 
			
		||||
                this.abortItemLoad(path);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            let pathIndex = this.openTreeItems.indexOf(path);
 | 
			
		||||
 | 
			
		||||
            if (pathIndex === -1) {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            this.treeItems = this.treeItems.filter((checkItem) => {
 | 
			
		||||
                return checkItem.navigationPath === path
 | 
			
		||||
                    || !checkItem.navigationPath.includes(path);
 | 
			
		||||
            });
 | 
			
		||||
            this.openTreeItems.splice(pathIndex, 1);
 | 
			
		||||
            this.removeCompositionListenerFor(path);
 | 
			
		||||
        },
 | 
			
		||||
        closeTreeItem(item) {
 | 
			
		||||
            this.closeTreeItemByPath(item.navigationPath);
 | 
			
		||||
        },
 | 
			
		||||
        // returns an AbortController signal to be passed on to requests
 | 
			
		||||
        startItemLoad(path) {
 | 
			
		||||
            if (this.isItemLoading(path)) {
 | 
			
		||||
                this.abortItemLoad(path);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            this.$set(this.treeItemLoading, path, new AbortController());
 | 
			
		||||
 | 
			
		||||
            return this.treeItemLoading[path].signal;
 | 
			
		||||
        },
 | 
			
		||||
        endItemLoad(path) {
 | 
			
		||||
            this.$set(this.treeItemLoading, path, undefined);
 | 
			
		||||
            delete this.treeItemLoading[path];
 | 
			
		||||
        },
 | 
			
		||||
        abortItemLoad(path) {
 | 
			
		||||
            if (this.treeItemLoading[path]) {
 | 
			
		||||
                this.treeItemLoading[path].abort();
 | 
			
		||||
                this.endItemLoad(path);
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        isItemLoading(path) {
 | 
			
		||||
            return this.treeItemLoading[path] instanceof AbortController;
 | 
			
		||||
        },
 | 
			
		||||
        showCurrentPathInTree() {
 | 
			
		||||
            const currentPath = this.buildNavigationPath(this.openmct.router.path);
 | 
			
		||||
 | 
			
		||||
@@ -436,7 +380,17 @@ export default {
 | 
			
		||||
 | 
			
		||||
            }, Promise.resolve()).then(() => {
 | 
			
		||||
                if (this.isSelectorTree) {
 | 
			
		||||
                    this.treeItemSelection(this.getTreeItemByPath(navigationPath));
 | 
			
		||||
                    // If item is missing due to error in object creation,
 | 
			
		||||
                    // walk up the navigationPath until we find an item
 | 
			
		||||
                    let item = this.getTreeItemByPath(navigationPath);
 | 
			
		||||
                    while (!item) {
 | 
			
		||||
                        const startIndex = 0;
 | 
			
		||||
                        const endIndex = navigationPath.lastIndexOf('/');
 | 
			
		||||
                        navigationPath = navigationPath.substring(startIndex, endIndex);
 | 
			
		||||
                        item = this.getTreeItemByPath(navigationPath);
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    this.treeItemSelection(item);
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        },
 | 
			
		||||
@@ -528,52 +482,6 @@ export default {
 | 
			
		||||
            // determine if any part of the parent's path includes a key value of mine; aka My Items
 | 
			
		||||
            return Boolean(parentObjectPath.find(path => path.identifier.key === 'mine'));
 | 
			
		||||
        },
 | 
			
		||||
        async loadAndBuildTreeItemsFor(domainObject, parentObjectPath, abortSignal) {
 | 
			
		||||
            let collection = this.openmct.composition.get(domainObject);
 | 
			
		||||
            let composition = await collection.load(abortSignal);
 | 
			
		||||
 | 
			
		||||
            if (SORT_MY_ITEMS_ALPH_ASC && this.isSortable(parentObjectPath)) {
 | 
			
		||||
                const sortedComposition = composition.slice().sort(this.sortNameAscending);
 | 
			
		||||
                composition = sortedComposition;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (parentObjectPath.length) {
 | 
			
		||||
                let navigationPath = this.buildNavigationPath(parentObjectPath);
 | 
			
		||||
 | 
			
		||||
                if (this.compositionCollections[navigationPath]) {
 | 
			
		||||
                    this.removeCompositionListenerFor(navigationPath);
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                this.compositionCollections[navigationPath] = {};
 | 
			
		||||
                this.compositionCollections[navigationPath].collection = collection;
 | 
			
		||||
                this.compositionCollections[navigationPath].addHandler = this.compositionAddHandler(navigationPath);
 | 
			
		||||
                this.compositionCollections[navigationPath].removeHandler = this.compositionRemoveHandler(navigationPath);
 | 
			
		||||
 | 
			
		||||
                this.compositionCollections[navigationPath].collection.on('add',
 | 
			
		||||
                    this.compositionCollections[navigationPath].addHandler);
 | 
			
		||||
                this.compositionCollections[navigationPath].collection.on('remove',
 | 
			
		||||
                    this.compositionCollections[navigationPath].removeHandler);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return composition.map((object) => {
 | 
			
		||||
                this.addTreeItemObserver(object, parentObjectPath);
 | 
			
		||||
 | 
			
		||||
                return this.buildTreeItem(object, parentObjectPath);
 | 
			
		||||
            });
 | 
			
		||||
        },
 | 
			
		||||
        buildTreeItem(domainObject, parentObjectPath, isNew = false) {
 | 
			
		||||
            let objectPath = [domainObject].concat(parentObjectPath);
 | 
			
		||||
            let navigationPath = this.buildNavigationPath(objectPath);
 | 
			
		||||
 | 
			
		||||
            return {
 | 
			
		||||
                id: this.openmct.objects.makeKeyString(domainObject.identifier),
 | 
			
		||||
                object: domainObject,
 | 
			
		||||
                leftOffset: ((objectPath.length - 1) * TREE_ITEM_INDENT_PX) + 'px',
 | 
			
		||||
                isNew,
 | 
			
		||||
                objectPath,
 | 
			
		||||
                navigationPath
 | 
			
		||||
            };
 | 
			
		||||
        },
 | 
			
		||||
        addTreeItemObserver(domainObject, parentObjectPath) {
 | 
			
		||||
            const objectPath = [domainObject].concat(parentObjectPath);
 | 
			
		||||
            const navigationPath = this.buildNavigationPath(objectPath);
 | 
			
		||||
@@ -588,30 +496,6 @@ export default {
 | 
			
		||||
                this.sortTreeItems.bind(this, parentObjectPath)
 | 
			
		||||
            );
 | 
			
		||||
        },
 | 
			
		||||
        async updateTreeItems(parentObjectPath) {
 | 
			
		||||
            let children;
 | 
			
		||||
 | 
			
		||||
            if (parentObjectPath.length) {
 | 
			
		||||
                const parentItem = this.treeItems.find(item => item.objectPath === parentObjectPath);
 | 
			
		||||
                const descendants = this.getChildrenInTreeFor(parentItem, true);
 | 
			
		||||
                const parentIndex = this.treeItems.map(e => e.object).indexOf(parentObjectPath[0]);
 | 
			
		||||
 | 
			
		||||
                children = await this.loadAndBuildTreeItemsFor(parentItem.object, parentItem.objectPath);
 | 
			
		||||
 | 
			
		||||
                this.treeItems.splice(parentIndex + 1, descendants.length, ...children);
 | 
			
		||||
            } else {
 | 
			
		||||
                const root = await this.openmct.objects.get('ROOT');
 | 
			
		||||
                children = await this.loadAndBuildTreeItemsFor(root, []);
 | 
			
		||||
 | 
			
		||||
                this.treeItems = [...children];
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            for (let item of children) {
 | 
			
		||||
                if (this.isTreeItemOpen(item)) {
 | 
			
		||||
                    this.openTreeItem(item);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        sortTreeItems(parentObjectPath) {
 | 
			
		||||
            const navigationPath = this.buildNavigationPath(parentObjectPath);
 | 
			
		||||
            const parentItem = this.getTreeItemByPath(navigationPath);
 | 
			
		||||
@@ -650,11 +534,6 @@ export default {
 | 
			
		||||
            // Splice in all of the sorted descendants
 | 
			
		||||
            this.treeItems.splice(this.treeItems.indexOf(parentItem) + 1, sortedTreeItems.length, ...sortedTreeItems);
 | 
			
		||||
        },
 | 
			
		||||
        buildNavigationPath(objectPath) {
 | 
			
		||||
            return '/browse/' + [...objectPath].reverse()
 | 
			
		||||
                .map((object) => this.openmct.objects.makeKeyString(object.identifier))
 | 
			
		||||
                .join('/');
 | 
			
		||||
        },
 | 
			
		||||
        compositionAddHandler(navigationPath) {
 | 
			
		||||
            return (domainObject) => {
 | 
			
		||||
                const parentItem = this.getTreeItemByPath(navigationPath);
 | 
			
		||||
@@ -662,6 +541,10 @@ export default {
 | 
			
		||||
                const descendants = this.getChildrenInTreeFor(parentItem, true);
 | 
			
		||||
                const directDescendants = this.getChildrenInTreeFor(parentItem);
 | 
			
		||||
 | 
			
		||||
                if (domainObject.isMutable) {
 | 
			
		||||
                    this.addMutable(domainObject, parentItem.objectPath);
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                this.addTreeItemObserver(domainObject, parentItem.objectPath);
 | 
			
		||||
 | 
			
		||||
                if (directDescendants.length === 0) {
 | 
			
		||||
@@ -692,13 +575,15 @@ export default {
 | 
			
		||||
        },
 | 
			
		||||
        compositionRemoveHandler(navigationPath) {
 | 
			
		||||
            return (identifier) => {
 | 
			
		||||
                let removeKeyString = this.openmct.objects.makeKeyString(identifier);
 | 
			
		||||
                let parentItem = this.getTreeItemByPath(navigationPath);
 | 
			
		||||
                let directDescendants = this.getChildrenInTreeFor(parentItem);
 | 
			
		||||
                let removeItem = directDescendants.find(item => item.id === removeKeyString);
 | 
			
		||||
                const removeKeyString = this.openmct.objects.makeKeyString(identifier);
 | 
			
		||||
                const parentItem = this.getTreeItemByPath(navigationPath);
 | 
			
		||||
                const directDescendants = this.getChildrenInTreeFor(parentItem);
 | 
			
		||||
                const removeItem = directDescendants.find(item => item.id === removeKeyString);
 | 
			
		||||
 | 
			
		||||
                // Remove the item from the tree, unobserve it, and clean up any mutables
 | 
			
		||||
                this.removeItemFromTree(removeItem);
 | 
			
		||||
                this.removeItemFromObservers(removeItem);
 | 
			
		||||
                this.destroyObserverByPath(removeItem.navigationPath);
 | 
			
		||||
                this.destroyMutableByPath(removeItem.navigationPath);
 | 
			
		||||
            };
 | 
			
		||||
        },
 | 
			
		||||
        removeCompositionListenerFor(navigationPath) {
 | 
			
		||||
@@ -720,13 +605,6 @@ export default {
 | 
			
		||||
            const removeIndex = this.getTreeItemIndex(item.navigationPath);
 | 
			
		||||
            this.treeItems.splice(removeIndex, 1);
 | 
			
		||||
        },
 | 
			
		||||
        removeItemFromObservers(item) {
 | 
			
		||||
            if (this.observers[item.id]) {
 | 
			
		||||
                this.observers[item.id]();
 | 
			
		||||
 | 
			
		||||
                delete this.observers[item.id];
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        addItemToTreeBefore(addItem, beforeItem) {
 | 
			
		||||
            const addIndex = this.getTreeItemIndex(beforeItem.navigationPath);
 | 
			
		||||
 | 
			
		||||
@@ -792,12 +670,17 @@ export default {
 | 
			
		||||
 | 
			
		||||
            for (const result of results) {
 | 
			
		||||
                if (!abortSignal.aborted) {
 | 
			
		||||
                    // Don't show deleted objects in search results
 | 
			
		||||
                    if (result.location === null) {
 | 
			
		||||
                        continue;
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    resultPromises.push(this.openmct.objects.getOriginalPath(result.identifier).then((objectPath) => {
 | 
			
		||||
                        // removing the item itself, as the path we pass to buildTreeItem is a parent path
 | 
			
		||||
                        objectPath.shift();
 | 
			
		||||
 | 
			
		||||
                        // if root, remove, we're not using in object path for tree
 | 
			
		||||
                        let lastObject = objectPath.length ? objectPath[objectPath.length - 1] : false;
 | 
			
		||||
                        const lastObject = objectPath.length ? objectPath[objectPath.length - 1] : false;
 | 
			
		||||
                        if (lastObject && lastObject.type === 'root') {
 | 
			
		||||
                            objectPath.pop();
 | 
			
		||||
                        }
 | 
			
		||||
@@ -865,44 +748,6 @@ export default {
 | 
			
		||||
 | 
			
		||||
            return Math.ceil(scrollBottom / this.itemHeight);
 | 
			
		||||
        },
 | 
			
		||||
        calculateHeights() {
 | 
			
		||||
            const RECHECK = 100;
 | 
			
		||||
 | 
			
		||||
            return new Promise((resolve, reject) => {
 | 
			
		||||
 | 
			
		||||
                let checkHeights = () => {
 | 
			
		||||
                    let treeTopMargin = this.getElementStyleValue(this.$refs.mainTree, 'marginTop');
 | 
			
		||||
                    let paddingOffset = 0;
 | 
			
		||||
 | 
			
		||||
                    if (
 | 
			
		||||
                        this.$el
 | 
			
		||||
                        && this.$refs.search
 | 
			
		||||
                        && this.$refs.mainTree
 | 
			
		||||
                        && this.$refs.treeContainer
 | 
			
		||||
                        && this.$refs.dummyItem
 | 
			
		||||
                        && this.$el.offsetHeight !== 0
 | 
			
		||||
                        && treeTopMargin > 0
 | 
			
		||||
                    ) {
 | 
			
		||||
                        if (this.isSelectorTree) {
 | 
			
		||||
                            paddingOffset = this.getElementStyleValue(this.$refs.treeContainer, 'padding');
 | 
			
		||||
                        }
 | 
			
		||||
 | 
			
		||||
                        this.mainTreeTopMargin = treeTopMargin;
 | 
			
		||||
                        this.mainTreeHeight = this.$el.offsetHeight
 | 
			
		||||
                            - this.$refs.search.offsetHeight
 | 
			
		||||
                            - this.mainTreeTopMargin
 | 
			
		||||
                            - (paddingOffset * 2);
 | 
			
		||||
                        this.itemHeight = this.getElementStyleValue(this.$refs.dummyItem, 'height');
 | 
			
		||||
 | 
			
		||||
                        resolve();
 | 
			
		||||
                    } else {
 | 
			
		||||
                        setTimeout(checkHeights, RECHECK);
 | 
			
		||||
                    }
 | 
			
		||||
                };
 | 
			
		||||
 | 
			
		||||
                checkHeights();
 | 
			
		||||
            });
 | 
			
		||||
        },
 | 
			
		||||
        getTreeItemByPath(path) {
 | 
			
		||||
            return this.treeItems.find(item => item.navigationPath === path);
 | 
			
		||||
        },
 | 
			
		||||
@@ -925,22 +770,6 @@ export default {
 | 
			
		||||
                && childItem.navigationPath.includes(parentPath);
 | 
			
		||||
            });
 | 
			
		||||
        },
 | 
			
		||||
        isTreeItemOpen(item) {
 | 
			
		||||
            return this.isTreeItemPathOpen(item.navigationPath);
 | 
			
		||||
        },
 | 
			
		||||
        isTreeItemPathOpen(path) {
 | 
			
		||||
            return this.openTreeItems.includes(path);
 | 
			
		||||
        },
 | 
			
		||||
        getElementStyleValue(el, style) {
 | 
			
		||||
            if (!el) {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            let styleString = window.getComputedStyle(el)[style];
 | 
			
		||||
            let index = styleString.indexOf('px');
 | 
			
		||||
 | 
			
		||||
            return Number(styleString.slice(0, index));
 | 
			
		||||
        },
 | 
			
		||||
        getSavedOpenItems() {
 | 
			
		||||
            if (this.isSelectorTree) {
 | 
			
		||||
                return;
 | 
			
		||||
@@ -956,16 +785,46 @@ export default {
 | 
			
		||||
 | 
			
		||||
            localStorage.setItem(LOCAL_STORAGE_KEY__TREE_EXPANDED, JSON.stringify(this.openTreeItems));
 | 
			
		||||
        },
 | 
			
		||||
        handleTreeResize() {
 | 
			
		||||
            this.calculateHeights();
 | 
			
		||||
        /**
 | 
			
		||||
         * Destroy an observer for the given navigationPath.
 | 
			
		||||
         */
 | 
			
		||||
        destroyObserverByPath(navigationPath) {
 | 
			
		||||
            if (this.observers[navigationPath]) {
 | 
			
		||||
                this.observers[navigationPath]();
 | 
			
		||||
                delete this.observers[navigationPath];
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        destroyObservers(observers) {
 | 
			
		||||
            Object.entries(observers).forEach(([keyString, unobserve]) => {
 | 
			
		||||
                if (typeof unobserve === 'function') {
 | 
			
		||||
        /**
 | 
			
		||||
         * Destroy all observers.
 | 
			
		||||
         */
 | 
			
		||||
        destroyObservers() {
 | 
			
		||||
            Object.entries(this.observers).forEach(([key, unobserve]) => {
 | 
			
		||||
                if (unobserve) {
 | 
			
		||||
                    unobserve();
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                delete observers[keyString];
 | 
			
		||||
                delete this.observers[key];
 | 
			
		||||
            });
 | 
			
		||||
        },
 | 
			
		||||
        /**
 | 
			
		||||
         * Destroy a mutable for the given navigationPath.
 | 
			
		||||
         */
 | 
			
		||||
        destroyMutableByPath(navigationPath) {
 | 
			
		||||
            if (this.mutables[navigationPath]) {
 | 
			
		||||
                this.mutables[navigationPath]();
 | 
			
		||||
                delete this.mutables[navigationPath];
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        /**
 | 
			
		||||
         * Destroy all mutables.
 | 
			
		||||
         */
 | 
			
		||||
        destroyMutables() {
 | 
			
		||||
            Object.entries(this.mutables).forEach(([key, destroyMutable]) => {
 | 
			
		||||
                if (destroyMutable) {
 | 
			
		||||
                    destroyMutable();
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                delete this.mutables[key];
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										224
									
								
								src/ui/mixins/tree-mixin.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										224
									
								
								src/ui/mixins/tree-mixin.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,224 @@
 | 
			
		||||
export const SORT_MY_ITEMS_ALPH_ASC = true;
 | 
			
		||||
export const TREE_ITEM_INDENT_PX = 18;
 | 
			
		||||
export default {
 | 
			
		||||
    data: function () {
 | 
			
		||||
        return {
 | 
			
		||||
            isLoading: false,
 | 
			
		||||
            treeItems: [],
 | 
			
		||||
            openTreeItems: [],
 | 
			
		||||
            visibleItems: [],
 | 
			
		||||
            updatingView: false,
 | 
			
		||||
            treeItemLoading: {},
 | 
			
		||||
            compositionCollections: {},
 | 
			
		||||
            selectedItem: {},
 | 
			
		||||
            observers: {},
 | 
			
		||||
            itemHeight: 27,
 | 
			
		||||
            itemOffset: 0
 | 
			
		||||
        };
 | 
			
		||||
    },
 | 
			
		||||
    watch: {
 | 
			
		||||
        syncTreeNavigation() {
 | 
			
		||||
            this.searchValue = '';
 | 
			
		||||
 | 
			
		||||
            // if there is an abort controller, then a search is in progress and will need to be canceled
 | 
			
		||||
            if (this.abortSearchController) {
 | 
			
		||||
                this.abortSearchController.abort();
 | 
			
		||||
                delete this.abortSearchController;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (!this.openmct.router.path) {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            this.$nextTick(this.showCurrentPathInTree);
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
    methods: {
 | 
			
		||||
        abortItemLoad(path) {
 | 
			
		||||
            if (this.treeItemLoading[path]) {
 | 
			
		||||
                this.treeItemLoading[path].abort();
 | 
			
		||||
                this.endItemLoad(path);
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        buildNavigationPath(objectPath) {
 | 
			
		||||
            return '/browse/' + [...objectPath].reverse()
 | 
			
		||||
                .map((object) => this.openmct.objects.makeKeyString(object.identifier))
 | 
			
		||||
                .join('/');
 | 
			
		||||
        },
 | 
			
		||||
        buildTreeItem(domainObject, parentObjectPath, isNew = false) {
 | 
			
		||||
            let objectPath = [domainObject].concat(parentObjectPath);
 | 
			
		||||
            let navigationPath = this.buildNavigationPath(objectPath);
 | 
			
		||||
 | 
			
		||||
            return {
 | 
			
		||||
                id: this.openmct.objects.makeKeyString(domainObject.identifier),
 | 
			
		||||
                object: domainObject,
 | 
			
		||||
                leftOffset: ((objectPath.length - 1) * TREE_ITEM_INDENT_PX) + 'px',
 | 
			
		||||
                isNew,
 | 
			
		||||
                objectPath,
 | 
			
		||||
                navigationPath
 | 
			
		||||
            };
 | 
			
		||||
        },
 | 
			
		||||
        calculateHeights() {
 | 
			
		||||
            const RECHECK = 100;
 | 
			
		||||
 | 
			
		||||
            return new Promise((resolve, reject) => {
 | 
			
		||||
 | 
			
		||||
                let checkHeights = () => {
 | 
			
		||||
                    let treeTopMargin = this.getElementStyleValue(this.$refs.mainTree, 'marginTop');
 | 
			
		||||
                    let paddingOffset = 0;
 | 
			
		||||
 | 
			
		||||
                    if (
 | 
			
		||||
                        this.$el
 | 
			
		||||
                        && this.$refs.search
 | 
			
		||||
                        && this.$refs.mainTree
 | 
			
		||||
                        && this.$refs.treeContainer
 | 
			
		||||
                        && this.$refs.dummyItem
 | 
			
		||||
                        && this.$el.offsetHeight !== 0
 | 
			
		||||
                        && treeTopMargin > 0
 | 
			
		||||
                    ) {
 | 
			
		||||
 | 
			
		||||
                        this.mainTreeTopMargin = treeTopMargin;
 | 
			
		||||
                        this.mainTreeHeight = this.$el.offsetHeight
 | 
			
		||||
                            - this.$refs.search.offsetHeight
 | 
			
		||||
                            - this.mainTreeTopMargin
 | 
			
		||||
                            - (paddingOffset * 2);
 | 
			
		||||
                        this.itemHeight = this.getElementStyleValue(this.$refs.dummyItem, 'height');
 | 
			
		||||
 | 
			
		||||
                        resolve();
 | 
			
		||||
                    } else {
 | 
			
		||||
                        setTimeout(checkHeights, RECHECK);
 | 
			
		||||
                    }
 | 
			
		||||
                };
 | 
			
		||||
 | 
			
		||||
                checkHeights();
 | 
			
		||||
            });
 | 
			
		||||
        },
 | 
			
		||||
        closeTreeItem(item) {
 | 
			
		||||
            this.closeTreeItemByPath(item.navigationPath);
 | 
			
		||||
        },
 | 
			
		||||
        closeTreeItemByPath(path) {
 | 
			
		||||
            // if actively loading, abort
 | 
			
		||||
            if (this.isItemLoading(path)) {
 | 
			
		||||
                this.abortItemLoad(path);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            let pathIndex = this.openTreeItems.indexOf(path);
 | 
			
		||||
 | 
			
		||||
            if (pathIndex === -1) {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            this.treeItems = this.treeItems.filter((checkItem) => {
 | 
			
		||||
                return checkItem.navigationPath === path
 | 
			
		||||
                    || !checkItem.navigationPath.includes(path);
 | 
			
		||||
            });
 | 
			
		||||
            this.openTreeItems.splice(pathIndex, 1);
 | 
			
		||||
            // this.removeCompositionListenerFor(path);
 | 
			
		||||
        },
 | 
			
		||||
        endItemLoad(path) {
 | 
			
		||||
            this.$set(this.treeItemLoading, path, undefined);
 | 
			
		||||
            delete this.treeItemLoading[path];
 | 
			
		||||
        },
 | 
			
		||||
        getElementStyleValue(el, style) {
 | 
			
		||||
            if (!el) {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            let styleString = window.getComputedStyle(el)[style];
 | 
			
		||||
            let index = styleString.indexOf('px');
 | 
			
		||||
 | 
			
		||||
            return Number(styleString.slice(0, index));
 | 
			
		||||
        },
 | 
			
		||||
        handleTreeResize() {
 | 
			
		||||
            this.calculateHeights();
 | 
			
		||||
        },
 | 
			
		||||
        isItemLoading(path) {
 | 
			
		||||
            return this.treeItemLoading[path] instanceof AbortController;
 | 
			
		||||
        },
 | 
			
		||||
        isTreeItemOpen(item) {
 | 
			
		||||
            return this.isTreeItemPathOpen(item.navigationPath);
 | 
			
		||||
        },
 | 
			
		||||
        isTreeItemPathOpen(path) {
 | 
			
		||||
            return this.openTreeItems.includes(path);
 | 
			
		||||
        },
 | 
			
		||||
        async loadAndBuildTreeItemsFor(domainObject, parentObjectPath, abortSignal) {
 | 
			
		||||
            let collection = this.openmct.composition.get(domainObject);
 | 
			
		||||
            let composition = await collection.load(abortSignal);
 | 
			
		||||
 | 
			
		||||
            if (SORT_MY_ITEMS_ALPH_ASC && this.isSortable(parentObjectPath)) {
 | 
			
		||||
                const sortedComposition = composition.slice().sort(this.sortNameAscending);
 | 
			
		||||
                composition = sortedComposition;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (parentObjectPath.length) {
 | 
			
		||||
                let navigationPath = this.buildNavigationPath(parentObjectPath);
 | 
			
		||||
 | 
			
		||||
                if (this.compositionCollections[navigationPath]) {
 | 
			
		||||
                    this.removeCompositionListenerFor(navigationPath);
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                this.compositionCollections[navigationPath] = {};
 | 
			
		||||
                this.compositionCollections[navigationPath].collection = collection;
 | 
			
		||||
                this.compositionCollections[navigationPath].addHandler = this.compositionAddHandler(navigationPath);
 | 
			
		||||
                this.compositionCollections[navigationPath].removeHandler = this.compositionRemoveHandler(navigationPath);
 | 
			
		||||
 | 
			
		||||
                this.compositionCollections[navigationPath].collection.on('add',
 | 
			
		||||
                    this.compositionCollections[navigationPath].addHandler);
 | 
			
		||||
                this.compositionCollections[navigationPath].collection.on('remove',
 | 
			
		||||
                    this.compositionCollections[navigationPath].removeHandler);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return composition.map((object) => {
 | 
			
		||||
                this.addTreeItemObserver(object, parentObjectPath);
 | 
			
		||||
 | 
			
		||||
                return this.buildTreeItem(object, parentObjectPath);
 | 
			
		||||
            });
 | 
			
		||||
        },
 | 
			
		||||
        async openTreeItem(parentItem) {
 | 
			
		||||
            let parentPath = parentItem.navigationPath;
 | 
			
		||||
 | 
			
		||||
            this.startItemLoad(parentPath);
 | 
			
		||||
            // pass in abort signal when functional
 | 
			
		||||
            let childrenItems = await this.loadAndBuildTreeItemsFor(parentItem.object, parentItem.objectPath);
 | 
			
		||||
            let parentIndex = this.treeItems.indexOf(parentItem);
 | 
			
		||||
 | 
			
		||||
            // if it's not loading, it was aborted
 | 
			
		||||
            if (!this.isItemLoading(parentPath) || parentIndex === -1) {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            this.endItemLoad(parentPath);
 | 
			
		||||
 | 
			
		||||
            this.treeItems.splice(parentIndex + 1, 0, ...childrenItems);
 | 
			
		||||
 | 
			
		||||
            if (!this.isTreeItemOpen(parentItem)) {
 | 
			
		||||
                this.openTreeItems.push(parentPath);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            for (let item of childrenItems) {
 | 
			
		||||
                if (this.isTreeItemOpen(item)) {
 | 
			
		||||
                    this.openTreeItem(item);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return;
 | 
			
		||||
        },
 | 
			
		||||
        // returns an AbortController signal to be passed on to requests
 | 
			
		||||
        startItemLoad(path) {
 | 
			
		||||
            if (this.isItemLoading(path)) {
 | 
			
		||||
                this.abortItemLoad(path);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            this.$set(this.treeItemLoading, path, new AbortController());
 | 
			
		||||
 | 
			
		||||
            return this.treeItemLoading[path].signal;
 | 
			
		||||
        },
 | 
			
		||||
        treeItemAction(parentItem, type) {
 | 
			
		||||
            if (type === 'close') {
 | 
			
		||||
                this.closeTreeItem(parentItem);
 | 
			
		||||
            } else {
 | 
			
		||||
                this.openTreeItem(parentItem);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
@@ -227,7 +227,7 @@ class ApplicationRouter extends EventEmitter {
 | 
			
		||||
 | 
			
		||||
        this.started = true;
 | 
			
		||||
 | 
			
		||||
        this.locationBar.onChange(p => this.hashChaged(p));
 | 
			
		||||
        this.locationBar.onChange(p => this.hashChanged(p));
 | 
			
		||||
        this.locationBar.start({
 | 
			
		||||
            root: location.pathname
 | 
			
		||||
        });
 | 
			
		||||
@@ -390,9 +390,9 @@ class ApplicationRouter extends EventEmitter {
 | 
			
		||||
     *
 | 
			
		||||
     * @param {string} hash new hash for url
 | 
			
		||||
     */
 | 
			
		||||
    hashChaged(hash) {
 | 
			
		||||
        this.emit('change:hash', hash);
 | 
			
		||||
    hashChanged(hash) {
 | 
			
		||||
        this.handleLocationChange(hash);
 | 
			
		||||
        this.emit('change:hash', hash);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user