Compare commits

..

21 Commits

Author SHA1 Message Date
Jesse Mazzella
510215e2da style: make width consistent across both trees 2022-12-30 13:37:36 -08:00
Jesse Mazzella
0b2dc1e8ab refactor(WIP): move common tree logic into a mixin 2022-12-30 13:37:36 -08:00
Jesse Mazzella
bb516d668b feat(WIP): enforce max number of recent items 2022-12-30 13:33:49 -08:00
Jesse Mazzella
eb150a892c feat(WIP): no duplicates in recent objects 2022-12-30 13:33:49 -08:00
Jesse Mazzella
b8b6b0f792 fix: emit after changing hash 2022-12-30 13:33:49 -08:00
Jesse Mazzella
83723065e5 refactor: fix typo 2022-12-30 13:33:49 -08:00
Jesse Mazzella
39e9d2a9c4 feat(WIP): first cut of RecentObjects component 2022-12-30 13:33:49 -08:00
Jesse Mazzella
b5a2194c36 feat: add method getRelativeObjectPath() 2022-12-30 13:33:49 -08:00
Jesse Mazzella
298e9eb361 feat: add pane for recently viewed 2022-12-30 13:32:11 -08:00
Jamie V
5424a62db5 [Notebook] Handle conflicts properly (#6067)
* making a revert on failed save more clear

* only notify conflicts for non sync items in object api, spruce up notebook with better transaction tracking and observing and unobserving during transactions, structuredClone backup in monkeypatch

* WIP

* WIP debuggin

* fresh start

* dont observe in transaction objects, small changes to notebook vue to indicate saving/prevent spamming, added forceRemote flag to objects.get

* updating readability of code as well as fix issue of stuck transaction for same value entry edits

* once entry is created, click out to blur

* quick revert
;

* click outside of entry to blur and commit

* switched to enter... as suggested :)

* removing unused variable

* initializing transaction to null as we are using that now for no transaction

* fix: ensure EventSource is closed so it recovers

- Make sure to close the CouchDB EventSource as well, so that it can recover in the case where two tabs or windows are on Open MCT and one refreshes. The check on line 81 was preventing recovery since the EventSource was not closed properly.

* enhance, enhance, enhance readability

Co-authored-by: Jesse Mazzella <jesse.d.mazzella@nasa.gov>
2022-12-29 14:11:08 -08:00
Shefali Joshi
9ed9e62202 Use the current clock's timestamp to show the now line in the timestrip (#6082) 2022-12-28 22:18:47 +00:00
Jesse Mazzella
327fc826c1 fix(imagery): Unblock 'latest' strategy requests for Related Telemetry in realtime mode (#6080)
* fix: use ephemeral timeContext for thumbnail metadata requests

* fix(TEMP): use `eval-source-map`

- **!!! REVERT THIS CHANGE BEFORE MERGE !!!**

* fix: only mutate if object supports mutation

* fix: pass identifier instead of whole domainObject

* fix: add start and end bounds to request

* Revert "fix(TEMP): use `eval-source-map`"

This reverts commit 7972d8c33a.

* docs: add comments
2022-12-28 19:12:00 +00:00
Jesse Mazzella
a9e3eca35c chore: bump Playwright to v1.29 (#6004)
* chore: bump Playwright to 1.28.0

* chore: bump playwright to v1.29.0

* fix: remove `|| true` shim for codecov

* Revert "fix: remove `|| true` shim for codecov"

This reverts commit ca3766fb5a.

* docs: add instructions for upgrading Playwright
2022-12-27 14:46:19 -08:00
Andrew Henry
cbecd79f71 Do not register time system listener until we have resolve remote clock object (#6063) 2022-12-20 14:01:47 -08:00
dependabot[bot]
3deb2e3dc2 Bump moment-timezone from 0.5.38 to 0.5.40 (#6050)
Bumps [moment-timezone](https://github.com/moment/moment-timezone) from 0.5.38 to 0.5.40.
- [Release notes](https://github.com/moment/moment-timezone/releases)
- [Changelog](https://github.com/moment/moment-timezone/blob/develop/changelog.md)
- [Commits](https://github.com/moment/moment-timezone/compare/0.5.38...0.5.40)

---
updated-dependencies:
- dependency-name: moment-timezone
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jamie V <jamie.j.vigliotta@nasa.gov>
2022-12-20 13:50:57 -08:00
Jesse Mazzella
d6e80447ab Mutables for the Tree 🎄 + clean up TreeItem observers and mutables properly (#6032)
* fix: refresh object after conflict error

* fix: recover from error thrown during create

- Ensure that the "Saving" modal dialog is closed

- Notify user of the error, and also print to console to catch in e2e

* fix: default selector tree item to 'mine' folder

- If create fails due to a conflict or otherwise, and the user immediately tries to "Create" again, default the selector tree's selected item to the "mine" folder (which we know exists).

* fix: don't listen to composition if Selector Tree

* refactor: remove dead code

* fix: use MutableDomainObjects in the tree

- Only use mutables and observers if NOT a SelectorTree

- Properly clean up observers and mutables when a parent item is removed from the tree

* test: verify conflicts don't break object creation

* test: verify dialog closes and object is created

* refactor(e2e): update test

- Error notification on 'My Items' folder missing was removed, so don't check for it

* test: increase timeout

* refactor(e2e): use Promise.any()

* refactor(e2e): use Promise instead of polling

* test: add 2p annotation

* test: use `waitForRequest` instead of promise

- tidy up test, add comments describing our pattern

* docs(e2e): add best practices for network tests

* refactor(e2e): avoid using Promise.any

* fix: de-reactify observer and mutable maps

* fix: destroy by path on treeItem close

* fix: don't refresh for synchronized objects

* docs: fix a typo 🔥

* fix: remove existing mutable before adding

* fix: fail fast if these aren't functions

- Remove check for typeof 'function' to not hide any potential coding errors

* fix: walk up navigationPath if item not found

* chore: fix lint errors

* fix: parse conflicted object name correctly

* fix: re-throw conflict error

* fix: Cancel edit mode on conflict
2022-12-20 13:27:51 -08:00
dependabot[bot]
1a4bd0fb55 Bump eslint from 8.29.0 to 8.30.0 (#6066)
Bumps [eslint](https://github.com/eslint/eslint) from 8.29.0 to 8.30.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v8.29.0...v8.30.0)

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-12-20 00:42:32 +00:00
Jesse Mazzella
80f89c7609 fix: no deleted objects in locator search (#6038)
Co-authored-by: Jamie V <jamie.j.vigliotta@nasa.gov>
2022-12-19 15:21:24 -08:00
dependabot[bot]
b82649772f Bump sinon from 14.0.1 to 15.0.1 (#6057)
Bumps [sinon](https://github.com/sinonjs/sinon) from 14.0.1 to 15.0.1.
- [Release notes](https://github.com/sinonjs/sinon/releases)
- [Changelog](https://github.com/sinonjs/sinon/blob/main/docs/changelog.md)
- [Commits](https://github.com/sinonjs/sinon/compare/v14.0.1...v15.0.1)

---
updated-dependencies:
- dependency-name: sinon
  dependency-type: direct:development
  update-type: version-update:semver-major
...

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Andrew Henry <akhenry@gmail.com>
2022-12-19 11:21:27 -08:00
Jon Ander Oribe
7f2ed27106 [CLA Approved] Add rows refractor (#5284)
* addRows Refractor

Co-authored-by: Jesse Mazzella <ozyx@users.noreply.github.com>
Co-authored-by: Andrew Henry <akhenry@gmail.com>
Co-authored-by: John Hill <john.c.hill@nasa.gov>
2022-12-19 18:13:53 +00:00
Michael Rogers
57e02db6b5 Prevent scrolling the window area on new image thumb telemetry - 5867 (#5961)
* Setup a scroll handler to avoid using scrollIntoView when in a layout

* Implement a separate scroll to action when in layouts

* Simplified scroll reset event and logic

* Adjust test to capture new scroll handler

* Remove done invocation after converting to async fn

* Prevent default for arrow keys to avoid scrolling layoyut

* await scrollToFocused

* Logical or to nullish coalescing

* Removed set in favor of using isNavigatedObject api

* Apply animation style after image history has length

* Lint fixes

Co-authored-by: Scott Bell <scott@traclabs.com>
2022-12-16 10:06:16 +01:00
30 changed files with 871 additions and 373 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,6 +28,7 @@
connected = false;
// stop listening for events
couchEventSource.removeEventListener('message', self.onCouchMessage);
couchEventSource.close();
console.debug('🚪 Closed couch connection 🚪');
return;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -335,6 +335,7 @@ export default {
dialog.dismiss();
this.openmct.notifications.error('Error saving objects');
console.error(error);
this.openmct.editor.cancel();
});
},
saveAndContinueEditing() {

View File

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

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

View File

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

View File

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

View File

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