Compare commits

...

7 Commits

Author SHA1 Message Date
Shefali Joshi
4958594336 Update version to 2.1.5 (#6093) 2023-01-03 09:54:26 -08:00
Jesse Mazzella
532cec1531 cherry-pick(#6067): [Notebook] Handle conflicts properly (#6087) 2022-12-29 14:22:44 -08:00
Shefali Joshi
a11a4a23e1 cherry-pick(#6082): Use the current clock's timestamp to show the now line in the timestrip (#6086) 2022-12-29 10:45:40 -08:00
Jesse Mazzella
1e4d585e9d cherry-pick(#6080): fix(imagery): Unblock 'latest' strategy requests for Related Telemetry in realtime mode (#6084)
* 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:29:07 +00: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
20 changed files with 445 additions and 153 deletions

View File

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

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

@@ -1,6 +1,6 @@
{
"name": "openmct",
"version": "2.1.5-SNAPSHOT",
"version": "2.1.5",
"description": "The Open MCT core platform",
"devDependencies": {
"@babel/eslint-parser": "7.18.9",
@@ -45,7 +45,7 @@
"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",

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;

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

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

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

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

@@ -174,8 +174,7 @@ export default {
itemOffset: 0,
activeSearch: false,
mainTreeTopMargin: undefined,
selectedItem: {},
observers: {}
selectedItem: {}
};
},
computed: {
@@ -277,10 +276,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);
@@ -355,8 +357,15 @@ export default {
}
this.treeItems = this.treeItems.filter((checkItem) => {
return checkItem.navigationPath === path
|| !checkItem.navigationPath.includes(path);
if (checkItem.navigationPath !== path
&& checkItem.navigationPath.includes(path)) {
this.destroyObserverByPath(checkItem.navigationPath);
this.destroyMutableByPath(checkItem.navigationPath);
return false;
}
return true;
});
this.openTreeItems.splice(pathIndex, 1);
this.removeCompositionListenerFor(path);
@@ -436,7 +445,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);
}
});
},
@@ -537,7 +556,7 @@ export default {
composition = sortedComposition;
}
if (parentObjectPath.length) {
if (parentObjectPath.length && !this.isSelectorTree) {
let navigationPath = this.buildNavigationPath(parentObjectPath);
if (this.compositionCollections[navigationPath]) {
@@ -556,7 +575,15 @@ export default {
}
return composition.map((object) => {
this.addTreeItemObserver(object, parentObjectPath);
// Only add observers and mutables if this is NOT a selector tree
if (!this.isSelectorTree) {
if (this.openmct.objects.supportsMutation(object.identifier)) {
object = this.openmct.objects.toMutable(object);
this.addMutable(object, parentObjectPath);
}
this.addTreeItemObserver(object, parentObjectPath);
}
return this.buildTreeItem(object, parentObjectPath);
});
@@ -574,6 +601,15 @@ export default {
navigationPath
};
},
addMutable(mutableDomainObject, parentObjectPath) {
const objectPath = [mutableDomainObject].concat(parentObjectPath);
const navigationPath = this.buildNavigationPath(objectPath);
// If the mutable already exists, destroy it.
this.destroyMutableByPath(navigationPath);
this.mutables[navigationPath] = () => this.openmct.objects.destroyMutable(mutableDomainObject);
},
addTreeItemObserver(domainObject, parentObjectPath) {
const objectPath = [domainObject].concat(parentObjectPath);
const navigationPath = this.buildNavigationPath(objectPath);
@@ -588,30 +624,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);
@@ -662,6 +674,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 +708,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 +738,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);
@@ -964,13 +975,46 @@ export default {
handleTreeResize() {
this.calculateHeights();
},
destroyObservers(observers) {
Object.entries(observers).forEach(([keyString, unobserve]) => {
if (typeof unobserve === 'function') {
/**
* Destroy an observer for the given navigationPath.
*/
destroyObserverByPath(navigationPath) {
if (this.observers[navigationPath]) {
this.observers[navigationPath]();
delete this.observers[navigationPath];
}
},
/**
* 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];
});
}
}