Compare commits
7 Commits
revert-sou
...
release/2.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4958594336 | ||
|
|
532cec1531 | ||
|
|
a11a4a23e1 | ||
|
|
1e4d585e9d | ||
|
|
cbecd79f71 | ||
|
|
3deb2e3dc2 | ||
|
|
d6e80447ab |
@@ -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.
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
const { test, expect } = require('../../pluginFixtures');
|
||||
|
||||
test.describe("CouchDB Status Indicator @couchdb", () => {
|
||||
test.describe("CouchDB Status Indicator with mocked responses @couchdb", () => {
|
||||
test.use({ failOnConsoleError: false });
|
||||
//TODO BeforeAll Verify CouchDB Connectivity with APIContext
|
||||
test('Shows green if connected', async ({ page }) => {
|
||||
@@ -71,38 +71,41 @@ test.describe("CouchDB Status Indicator @couchdb", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("CouchDB initialization @couchdb", () => {
|
||||
test.describe("CouchDB initialization with mocked responses @couchdb", () => {
|
||||
test.use({ failOnConsoleError: false });
|
||||
test("'My Items' folder is created if it doesn't exist", async ({ page }) => {
|
||||
// Store any relevant PUT requests that happen on the page
|
||||
const createMineFolderRequests = [];
|
||||
page.on('request', req => {
|
||||
// eslint-disable-next-line playwright/no-conditional-in-test
|
||||
if (req.method() === 'PUT' && req.url().endsWith('openmct/mine')) {
|
||||
createMineFolderRequests.push(req);
|
||||
}
|
||||
});
|
||||
const mockedMissingObjectResponsefromCouchDB = {
|
||||
status: 404,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({})
|
||||
};
|
||||
|
||||
// Override the first request to GET openmct/mine to return a 404
|
||||
await page.route('**/openmct/mine', route => {
|
||||
route.fulfill({
|
||||
status: 404,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
// Override the first request to GET openmct/mine to return a 404.
|
||||
// This simulates the case of starting Open MCT with a fresh database
|
||||
// and no "My Items" folder created yet.
|
||||
await page.route('**/mine', route => {
|
||||
route.fulfill(mockedMissingObjectResponsefromCouchDB);
|
||||
}, { times: 1 });
|
||||
|
||||
// Go to baseURL
|
||||
// Set up promise to verify that a PUT request to create "My Items"
|
||||
// folder was made.
|
||||
const putMineFolderRequest = page.waitForRequest(req =>
|
||||
req.url().endsWith('/mine')
|
||||
&& req.method() === 'PUT');
|
||||
|
||||
// Set up promise to verify that a GET request to retrieve "My Items"
|
||||
// folder was made.
|
||||
const getMineFolderRequest = page.waitForRequest(req =>
|
||||
req.url().endsWith('/mine')
|
||||
&& req.method() === 'GET');
|
||||
|
||||
// Go to baseURL.
|
||||
await page.goto('./', { waitUntil: 'networkidle' });
|
||||
|
||||
// Verify that error banner is displayed
|
||||
const bannerMessage = await page.locator('.c-message-banner__message').innerText();
|
||||
expect(bannerMessage).toEqual('Failed to retrieve object mine');
|
||||
|
||||
// Verify that a PUT request to create "My Items" folder was made
|
||||
await expect.poll(() => createMineFolderRequests.length, {
|
||||
message: 'Verify that PUT request to create "mine" folder was made',
|
||||
timeout: 1000
|
||||
}).toBeGreaterThanOrEqual(1);
|
||||
// Wait for both requests to resolve.
|
||||
await Promise.all([
|
||||
putMineFolderRequest,
|
||||
getMineFolderRequest
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,8 +24,9 @@
|
||||
This test suite is dedicated to tests which verify form functionality in isolation
|
||||
*/
|
||||
|
||||
const { test, expect } = require('../../baseFixtures');
|
||||
const { test, expect } = require('../../pluginFixtures');
|
||||
const { createDomainObjectWithDefaults } = require('../../appActions');
|
||||
const genUuid = require('uuid').v4;
|
||||
const path = require('path');
|
||||
|
||||
const TEST_FOLDER = 'test folder';
|
||||
@@ -128,6 +129,108 @@ test.describe('Persistence operations @couchdb', () => {
|
||||
timeout: 1000
|
||||
}).toEqual(1);
|
||||
});
|
||||
test('Can create an object after a conflict error @couchdb @2p', async ({ page }) => {
|
||||
test.info().annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/nasa/openmct/issues/5982'
|
||||
});
|
||||
|
||||
const page2 = await page.context().newPage();
|
||||
|
||||
// Both pages: Go to baseURL
|
||||
await Promise.all([
|
||||
page.goto('./', { waitUntil: 'networkidle' }),
|
||||
page2.goto('./', { waitUntil: 'networkidle' })
|
||||
]);
|
||||
|
||||
// Both pages: Click the Create button
|
||||
await Promise.all([
|
||||
page.click('button:has-text("Create")'),
|
||||
page2.click('button:has-text("Create")')
|
||||
]);
|
||||
|
||||
// Both pages: Click "Clock" in the Create menu
|
||||
await Promise.all([
|
||||
page.click(`li[role='menuitem']:text("Clock")`),
|
||||
page2.click(`li[role='menuitem']:text("Clock")`)
|
||||
]);
|
||||
|
||||
// Generate unique names for both objects
|
||||
const nameInput = page.locator('form[name="mctForm"] .first input[type="text"]');
|
||||
const nameInput2 = page2.locator('form[name="mctForm"] .first input[type="text"]');
|
||||
|
||||
// Both pages: Fill in the 'Name' form field.
|
||||
await Promise.all([
|
||||
nameInput.fill(""),
|
||||
nameInput.fill(`Clock:${genUuid()}`),
|
||||
nameInput2.fill(""),
|
||||
nameInput2.fill(`Clock:${genUuid()}`)
|
||||
]);
|
||||
|
||||
// Both pages: Fill the "Notes" section with information about the
|
||||
// currently running test and its project.
|
||||
const testNotes = page.testNotes;
|
||||
const notesInput = page.locator('form[name="mctForm"] #notes-textarea');
|
||||
const notesInput2 = page2.locator('form[name="mctForm"] #notes-textarea');
|
||||
await Promise.all([
|
||||
notesInput.fill(testNotes),
|
||||
notesInput2.fill(testNotes)
|
||||
]);
|
||||
|
||||
// Page 2: Click "OK" to create the domain object and wait for navigation.
|
||||
// This will update the composition of the parent folder, setting the
|
||||
// conditions for a conflict error from the first page.
|
||||
await Promise.all([
|
||||
page2.waitForLoadState(),
|
||||
page2.click('[aria-label="Save"]'),
|
||||
// Wait for Save Banner to appear
|
||||
page2.waitForSelector('.c-message-banner__message')
|
||||
]);
|
||||
|
||||
// Close Page 2, we're done with it.
|
||||
await page2.close();
|
||||
|
||||
// Page 1: Click "OK" to create the domain object and wait for navigation.
|
||||
// This will trigger a conflict error upon attempting to update
|
||||
// the composition of the parent folder.
|
||||
await Promise.all([
|
||||
page.waitForLoadState(),
|
||||
page.click('[aria-label="Save"]'),
|
||||
// Wait for Save Banner to appear
|
||||
page.waitForSelector('.c-message-banner__message')
|
||||
]);
|
||||
|
||||
// Page 1: Verify that the conflict has occurred and an error notification is displayed.
|
||||
await expect(page.locator('.c-message-banner__message', {
|
||||
hasText: "Conflict detected while saving mine"
|
||||
})).toBeVisible();
|
||||
|
||||
// Page 1: Start logging console errors from this point on
|
||||
let errors = [];
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
errors.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
// Page 1: Try to create a clock with the page that received the conflict.
|
||||
const clockAfterConflict = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Clock'
|
||||
});
|
||||
|
||||
// Page 1: Wait for save progress dialog to appear/disappear
|
||||
await page.locator('.c-message-banner__message', {
|
||||
hasText: 'Do not navigate away from this page or close this browser tab while this message is displayed.',
|
||||
state: 'visible'
|
||||
}).waitFor({ state: 'hidden' });
|
||||
|
||||
// Page 1: Navigate to 'My Items' and verify that the second clock was created
|
||||
await page.goto('./#/browse/mine');
|
||||
await expect(page.locator(`.c-grid-item__name[title="${clockAfterConflict.name}"]`)).toBeVisible();
|
||||
|
||||
// Verify no console errors occurred
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Form Correctness by Object Type', () => {
|
||||
|
||||
@@ -44,6 +44,7 @@ async function createNotebookAndEntry(page, iterations = 1) {
|
||||
const entryLocator = `[aria-label="Notebook Entry Input"] >> nth = ${iteration}`;
|
||||
await page.locator(entryLocator).click();
|
||||
await page.locator(entryLocator).fill(`Entry ${iteration}`);
|
||||
await page.locator(entryLocator).press('Enter');
|
||||
}
|
||||
|
||||
return notebook;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -73,6 +73,10 @@ export default class Editor extends EventEmitter {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = this.openmct.objects.getActiveTransaction();
|
||||
if (!transaction) {
|
||||
return resolve();
|
||||
}
|
||||
|
||||
transaction.cancel()
|
||||
.then(resolve)
|
||||
.catch(reject)
|
||||
|
||||
@@ -193,23 +193,27 @@ export default class ObjectAPI {
|
||||
* @memberof module:openmct.ObjectProvider#
|
||||
* @param {string} key the key for the domain object to load
|
||||
* @param {AbortController.signal} abortSignal (optional) signal to abort fetch requests
|
||||
* @param {boolean} forceRemote defaults to false. If true, will skip cached and
|
||||
* dirty/in-transaction objects use and the provider.get method
|
||||
* @returns {Promise} a promise which will resolve when the domain object
|
||||
* has been saved, or be rejected if it cannot be saved
|
||||
*/
|
||||
get(identifier, abortSignal) {
|
||||
get(identifier, abortSignal, forceRemote = false) {
|
||||
let keystring = this.makeKeyString(identifier);
|
||||
|
||||
if (this.cache[keystring] !== undefined) {
|
||||
return this.cache[keystring];
|
||||
}
|
||||
if (!forceRemote) {
|
||||
if (this.cache[keystring] !== undefined) {
|
||||
return this.cache[keystring];
|
||||
}
|
||||
|
||||
identifier = utils.parseKeyString(identifier);
|
||||
identifier = utils.parseKeyString(identifier);
|
||||
|
||||
if (this.isTransactionActive()) {
|
||||
let dirtyObject = this.transaction.getDirtyObject(identifier);
|
||||
if (this.isTransactionActive()) {
|
||||
let dirtyObject = this.transaction.getDirtyObject(identifier);
|
||||
|
||||
if (dirtyObject) {
|
||||
return Promise.resolve(dirtyObject);
|
||||
if (dirtyObject) {
|
||||
return Promise.resolve(dirtyObject);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,7 +395,6 @@ export default class ObjectAPI {
|
||||
lastPersistedTime = domainObject.persisted;
|
||||
const persistedTime = Date.now();
|
||||
this.#mutate(domainObject, 'persisted', persistedTime);
|
||||
|
||||
savedObjectPromise = provider.update(domainObject);
|
||||
}
|
||||
|
||||
@@ -399,7 +402,7 @@ export default class ObjectAPI {
|
||||
savedObjectPromise.then(response => {
|
||||
savedResolve(response);
|
||||
}).catch((error) => {
|
||||
if (lastPersistedTime !== undefined) {
|
||||
if (!isNewObject) {
|
||||
this.#mutate(domainObject, 'persisted', lastPersistedTime);
|
||||
}
|
||||
|
||||
@@ -410,9 +413,20 @@ export default class ObjectAPI {
|
||||
}
|
||||
}
|
||||
|
||||
return result.catch((error) => {
|
||||
return result.catch(async (error) => {
|
||||
if (error instanceof this.errors.Conflict) {
|
||||
this.openmct.notifications.error(`Conflict detected while saving ${this.makeKeyString(domainObject.identifier)}`);
|
||||
// Synchronized objects will resolve their own conflicts
|
||||
if (this.SYNCHRONIZED_OBJECT_TYPES.includes(domainObject.type)) {
|
||||
this.openmct.notifications.info(`Conflict detected while saving "${this.makeKeyString(domainObject.name)}", attempting to resolve`);
|
||||
} else {
|
||||
this.openmct.notifications.error(`Conflict detected while saving ${this.makeKeyString(domainObject.identifier)}`);
|
||||
|
||||
if (this.isTransactionActive()) {
|
||||
this.endTransaction();
|
||||
}
|
||||
|
||||
await this.refresh(domainObject);
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ function copyRelatedMetadata(metadata) {
|
||||
return copiedMetadata;
|
||||
}
|
||||
|
||||
import IndependentTimeContext from "@/api/time/IndependentTimeContext";
|
||||
export default class RelatedTelemetry {
|
||||
|
||||
constructor(openmct, domainObject, telemetryKeys) {
|
||||
@@ -88,9 +89,31 @@ export default class RelatedTelemetry {
|
||||
this[key].historicalDomainObject = await this._openmct.objects.get(this[key].historical.telemetryObjectId);
|
||||
|
||||
this[key].requestLatestFor = async (datum) => {
|
||||
const options = {
|
||||
// We need to create a throwaway time context and pass it along
|
||||
// as a request option. We do this to "trick" the Time API
|
||||
// into thinking we are in fixed time mode in order to bypass this logic:
|
||||
// https://github.com/akhenry/openmct-yamcs/blob/1060d42ebe43bf346dac0f6a8068cb288ade4ba4/src/providers/historical-telemetry-provider.js#L59
|
||||
// Context: https://github.com/akhenry/openmct-yamcs/pull/217
|
||||
const ephemeralContext = new IndependentTimeContext(
|
||||
this._openmct,
|
||||
this._openmct.time,
|
||||
[this[key].historicalDomainObject]
|
||||
);
|
||||
|
||||
// Stop following the global context, stop the clock,
|
||||
// and set bounds.
|
||||
ephemeralContext.resetContext();
|
||||
const newBounds = {
|
||||
start: this._openmct.time.bounds().start,
|
||||
end: this._parseTime(datum),
|
||||
end: this._parseTime(datum)
|
||||
};
|
||||
ephemeralContext.stopClock();
|
||||
ephemeralContext.bounds(newBounds);
|
||||
|
||||
const options = {
|
||||
start: newBounds.start,
|
||||
end: newBounds.end,
|
||||
timeContext: ephemeralContext,
|
||||
strategy: 'latest'
|
||||
};
|
||||
let results = await this._openmct.telemetry
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
<Sidebar
|
||||
ref="sidebar"
|
||||
class="c-notebook__nav c-sidebar c-drawer c-drawer--align-left"
|
||||
:class="[{'is-expanded': showNav}, {'c-drawer--push': !sidebarCoversEntries}, {'c-drawer--overlays': sidebarCoversEntries}]"
|
||||
:class="sidebarClasses"
|
||||
:default-page-id="defaultPageId"
|
||||
:selected-page-id="getSelectedPageId()"
|
||||
:default-section-id="defaultSectionId"
|
||||
@@ -123,6 +123,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="selectedPage && !selectedPage.isLocked"
|
||||
:class="{ 'disabled': activeTransaction }"
|
||||
class="c-notebook__drag-area icon-plus"
|
||||
@click="newEntry()"
|
||||
@dragover="dragOver"
|
||||
@@ -133,6 +134,11 @@
|
||||
To start a new entry, click here or drag and drop any object
|
||||
</span>
|
||||
</div>
|
||||
<progress-bar
|
||||
v-if="savingTransaction"
|
||||
class="c-telemetry-table__progress-bar"
|
||||
:model="{ progressPerc: undefined }"
|
||||
/>
|
||||
<div
|
||||
v-if="selectedPage && selectedPage.isLocked"
|
||||
class="c-notebook__page-locked"
|
||||
@@ -183,6 +189,7 @@ import NotebookEntry from './NotebookEntry.vue';
|
||||
import Search from '@/ui/components/search.vue';
|
||||
import SearchResults from './SearchResults.vue';
|
||||
import Sidebar from './Sidebar.vue';
|
||||
import ProgressBar from '../../../ui/components/ProgressBar.vue';
|
||||
import { clearDefaultNotebook, getDefaultNotebook, setDefaultNotebook, setDefaultNotebookSectionId, setDefaultNotebookPageId } from '../utils/notebook-storage';
|
||||
import { addNotebookEntry, createNewEmbed, getEntryPosById, getNotebookEntries, mutateObject } from '../utils/notebook-entries';
|
||||
import { saveNotebookImageDomainObject, updateNamespaceOfDomainObject } from '../utils/notebook-image';
|
||||
@@ -200,7 +207,8 @@ export default {
|
||||
NotebookEntry,
|
||||
Search,
|
||||
SearchResults,
|
||||
Sidebar
|
||||
Sidebar,
|
||||
ProgressBar
|
||||
},
|
||||
inject: ['agent', 'openmct', 'snapshotContainer'],
|
||||
props: {
|
||||
@@ -225,7 +233,9 @@ export default {
|
||||
showNav: false,
|
||||
sidebarCoversEntries: false,
|
||||
filteredAndSortedEntries: [],
|
||||
notebookAnnotations: {}
|
||||
notebookAnnotations: {},
|
||||
activeTransaction: false,
|
||||
savingTransaction: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -270,6 +280,20 @@ export default {
|
||||
|
||||
return this.sections[0];
|
||||
},
|
||||
sidebarClasses() {
|
||||
let sidebarClasses = [];
|
||||
if (this.showNav) {
|
||||
sidebarClasses.push('is-expanded');
|
||||
}
|
||||
|
||||
if (this.sidebarCoversEntries) {
|
||||
sidebarClasses.push('c-drawer--overlays');
|
||||
} else {
|
||||
sidebarClasses.push('c-drawer--push');
|
||||
}
|
||||
|
||||
return sidebarClasses;
|
||||
},
|
||||
showLockButton() {
|
||||
const entries = getNotebookEntries(this.domainObject, this.selectedSection, this.selectedPage);
|
||||
|
||||
@@ -297,6 +321,8 @@ export default {
|
||||
this.formatSidebar();
|
||||
this.setSectionAndPageFromUrl();
|
||||
|
||||
this.transaction = null;
|
||||
|
||||
window.addEventListener('orientationchange', this.formatSidebar);
|
||||
window.addEventListener('hashchange', this.setSectionAndPageFromUrl);
|
||||
this.filterAndSortEntries();
|
||||
@@ -749,6 +775,7 @@ export default {
|
||||
return section.id;
|
||||
},
|
||||
async newEntry(embed = null) {
|
||||
this.startTransaction();
|
||||
this.resetSearch();
|
||||
const notebookStorage = this.createNotebookStorageObject();
|
||||
this.updateDefaultNotebook(notebookStorage);
|
||||
@@ -891,20 +918,34 @@ export default {
|
||||
},
|
||||
startTransaction() {
|
||||
if (!this.openmct.objects.isTransactionActive()) {
|
||||
this.activeTransaction = true;
|
||||
this.transaction = this.openmct.objects.startTransaction();
|
||||
}
|
||||
},
|
||||
async saveTransaction() {
|
||||
if (this.transaction !== undefined) {
|
||||
await this.transaction.commit();
|
||||
this.openmct.objects.endTransaction();
|
||||
if (this.transaction !== null) {
|
||||
this.savingTransaction = true;
|
||||
try {
|
||||
await this.transaction.commit();
|
||||
} finally {
|
||||
this.endTransaction();
|
||||
}
|
||||
}
|
||||
},
|
||||
async cancelTransaction() {
|
||||
if (this.transaction !== undefined) {
|
||||
await this.transaction.cancel();
|
||||
this.openmct.objects.endTransaction();
|
||||
if (this.transaction !== null) {
|
||||
try {
|
||||
await this.transaction.cancel();
|
||||
} finally {
|
||||
this.endTransaction();
|
||||
}
|
||||
}
|
||||
},
|
||||
endTransaction() {
|
||||
this.openmct.objects.endTransaction();
|
||||
this.transaction = null;
|
||||
this.savingTransaction = false;
|
||||
this.activeTransaction = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -74,19 +74,22 @@ async function resolveNotebookTagConflicts(localAnnotation, openmct) {
|
||||
|
||||
async function resolveNotebookEntryConflicts(localMutable, openmct) {
|
||||
if (localMutable.configuration.entries) {
|
||||
const FORCE_REMOTE = true;
|
||||
const localEntries = structuredClone(localMutable.configuration.entries);
|
||||
const remoteMutable = await openmct.objects.getMutable(localMutable.identifier);
|
||||
applyLocalEntries(remoteMutable, localEntries, openmct);
|
||||
openmct.objects.destroyMutable(remoteMutable);
|
||||
const remoteObject = await openmct.objects.get(localMutable.identifier, undefined, FORCE_REMOTE);
|
||||
|
||||
return applyLocalEntries(remoteObject, localEntries, openmct);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyLocalEntries(mutable, entries, openmct) {
|
||||
function applyLocalEntries(remoteObject, entries, openmct) {
|
||||
let shouldSave = false;
|
||||
|
||||
Object.entries(entries).forEach(([sectionKey, pagesInSection]) => {
|
||||
Object.entries(pagesInSection).forEach(([pageKey, localEntries]) => {
|
||||
const remoteEntries = mutable.configuration.entries[sectionKey][pageKey];
|
||||
const remoteEntries = remoteObject.configuration.entries[sectionKey][pageKey];
|
||||
const mergedEntries = [].concat(remoteEntries);
|
||||
let shouldMutate = false;
|
||||
|
||||
@@ -110,8 +113,13 @@ function applyLocalEntries(mutable, entries, openmct) {
|
||||
});
|
||||
|
||||
if (shouldMutate) {
|
||||
openmct.objects.mutate(mutable, `configuration.entries.${sectionKey}.${pageKey}`, mergedEntries);
|
||||
shouldSave = true;
|
||||
openmct.objects.mutate(remoteObject, `configuration.entries.${sectionKey}.${pageKey}`, mergedEntries);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (shouldSave) {
|
||||
return openmct.objects.save(remoteObject);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +36,8 @@ export default function () {
|
||||
}
|
||||
|
||||
let wrappedFunction = openmct.objects.get;
|
||||
openmct.objects.get = function migrate(identifier) {
|
||||
return wrappedFunction.apply(openmct.objects, [identifier])
|
||||
openmct.objects.get = function migrate() {
|
||||
return wrappedFunction.apply(openmct.objects, [...arguments])
|
||||
.then(function (object) {
|
||||
if (needsMigration(object)) {
|
||||
migrateObject(object)
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
connected = false;
|
||||
// stop listening for events
|
||||
couchEventSource.removeEventListener('message', self.onCouchMessage);
|
||||
couchEventSource.close();
|
||||
console.debug('🚪 Closed couch connection 🚪');
|
||||
|
||||
return;
|
||||
|
||||
@@ -96,8 +96,13 @@ class CouchObjectProvider {
|
||||
let keyString = this.openmct.objects.makeKeyString(objectIdentifier);
|
||||
//TODO: Optimize this so that we don't 'get' the object if it's current revision (from this.objectQueue) is the same as the one we already have.
|
||||
let observersForObject = this.observers[keyString];
|
||||
let isInTransaction = false;
|
||||
|
||||
if (observersForObject) {
|
||||
if (this.openmct.objects.isTransactionActive()) {
|
||||
isInTransaction = this.openmct.objects.transaction.getDirtyObject(objectIdentifier);
|
||||
}
|
||||
|
||||
if (observersForObject && !isInTransaction) {
|
||||
observersForObject.forEach(async (observer) => {
|
||||
const updatedObject = await this.get(objectIdentifier);
|
||||
if (this.isSynchronizedObject(updatedObject)) {
|
||||
@@ -219,7 +224,12 @@ class CouchObjectProvider {
|
||||
console.error(error.message);
|
||||
throw new Error(`CouchDB Error - No response"`);
|
||||
} else {
|
||||
console.error(error.message);
|
||||
if (body?.model && isNotebookOrAnnotationType(body.model)) {
|
||||
// warn since we handle conflicts for notebooks
|
||||
console.warn(error.message);
|
||||
} else {
|
||||
console.error(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
@@ -234,7 +244,8 @@ class CouchObjectProvider {
|
||||
#handleResponseCode(status, json, fetchOptions) {
|
||||
this.indicator.setIndicatorToState(this.#statusCodeToIndicatorState(status));
|
||||
if (status === CouchObjectProvider.HTTP_CONFLICT) {
|
||||
throw new this.openmct.objects.errors.Conflict(`Conflict persisting ${fetchOptions.body.name}`);
|
||||
const objectName = JSON.parse(fetchOptions.body)?.model?.name;
|
||||
throw new this.openmct.objects.errors.Conflict(`Conflict persisting "${objectName}"`);
|
||||
} else if (status >= CouchObjectProvider.HTTP_BAD_REQUEST) {
|
||||
if (!json.error || !json.reason) {
|
||||
throw new Error(`CouchDB Error ${status}`);
|
||||
|
||||
@@ -62,8 +62,8 @@ export default class RemoteClock extends DefaultClock {
|
||||
}
|
||||
|
||||
start() {
|
||||
this.openmct.time.on('timeSystem', this._timeSystemChange);
|
||||
this.openmct.objects.get(this.identifier).then((domainObject) => {
|
||||
this.openmct.time.on('timeSystem', this._timeSystemChange);
|
||||
this.timeTelemetryObject = domainObject;
|
||||
this.metadata = this.openmct.telemetry.getMetadata(domainObject);
|
||||
this._timeSystemChange();
|
||||
|
||||
@@ -71,7 +71,10 @@ describe("the RemoteClock plugin", () => {
|
||||
parse: (datum) => datum.key
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
let objectPromise;
|
||||
let requestPromise;
|
||||
|
||||
beforeEach(() => {
|
||||
openmct.install(openmct.plugins.RemoteClock(TIME_TELEMETRY_ID));
|
||||
|
||||
let clocks = openmct.time.getAllClocks();
|
||||
@@ -89,7 +92,9 @@ describe("the RemoteClock plugin", () => {
|
||||
spyOn(metadata, 'value').and.callThrough();
|
||||
|
||||
let requestPromiseResolve;
|
||||
let requestPromise = new Promise((resolve) => {
|
||||
let objectPromiseResolve;
|
||||
|
||||
requestPromise = new Promise((resolve) => {
|
||||
requestPromiseResolve = resolve;
|
||||
});
|
||||
spyOn(openmct.telemetry, 'request').and.callFake(() => {
|
||||
@@ -98,8 +103,7 @@ describe("the RemoteClock plugin", () => {
|
||||
return requestPromise;
|
||||
});
|
||||
|
||||
let objectPromiseResolve;
|
||||
let objectPromise = new Promise((resolve) => {
|
||||
objectPromise = new Promise((resolve) => {
|
||||
objectPromiseResolve = resolve;
|
||||
});
|
||||
spyOn(openmct.objects, 'get').and.callFake(() => {
|
||||
@@ -112,39 +116,48 @@ describe("the RemoteClock plugin", () => {
|
||||
start: OFFSET_START,
|
||||
end: OFFSET_END
|
||||
});
|
||||
|
||||
await Promise.all([objectPromiseResolve, requestPromise]);
|
||||
});
|
||||
|
||||
it('is available and sets up initial values and listeners', () => {
|
||||
expect(remoteClock.key).toEqual(REMOTE_CLOCK_KEY);
|
||||
expect(remoteClock.identifier).toEqual(TIME_TELEMETRY_ID);
|
||||
expect(openmct.time.on).toHaveBeenCalledWith('timeSystem', remoteClock._timeSystemChange);
|
||||
expect(remoteClock._timeSystemChange).toHaveBeenCalled();
|
||||
it("Does not throw error if time system is changed before remote clock initialized", () => {
|
||||
expect(() => openmct.time.timeSystem('utc')).not.toThrow();
|
||||
});
|
||||
|
||||
it('will request/store the object based on the identifier passed in', () => {
|
||||
expect(remoteClock.timeTelemetryObject).toEqual(object);
|
||||
describe('once resolved', () => {
|
||||
beforeEach(async () => {
|
||||
await Promise.all([objectPromise, requestPromise]);
|
||||
});
|
||||
|
||||
it('is available and sets up initial values and listeners', () => {
|
||||
expect(remoteClock.key).toEqual(REMOTE_CLOCK_KEY);
|
||||
expect(remoteClock.identifier).toEqual(TIME_TELEMETRY_ID);
|
||||
expect(openmct.time.on).toHaveBeenCalledWith('timeSystem', remoteClock._timeSystemChange);
|
||||
expect(remoteClock._timeSystemChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('will request/store the object based on the identifier passed in', () => {
|
||||
expect(remoteClock.timeTelemetryObject).toEqual(object);
|
||||
});
|
||||
|
||||
it('will request metadata and set up formatters', () => {
|
||||
expect(remoteClock.metadata).toEqual(metadata);
|
||||
expect(metadata.value).toHaveBeenCalled();
|
||||
expect(openmct.telemetry.getValueFormatter).toHaveBeenCalledWith(metadataValue);
|
||||
});
|
||||
|
||||
it('will request the latest datum for the object it received and process the datum returned', () => {
|
||||
expect(openmct.telemetry.request).toHaveBeenCalledWith(remoteClock.timeTelemetryObject, REQ_OPTIONS);
|
||||
expect(boundsCallback).toHaveBeenCalledWith({
|
||||
start: TIME_VALUE + OFFSET_START,
|
||||
end: TIME_VALUE + OFFSET_END
|
||||
}, true);
|
||||
});
|
||||
|
||||
it('will set up subscriptions correctly', () => {
|
||||
expect(remoteClock._unsubscribe).toBeDefined();
|
||||
expect(openmct.telemetry.subscribe).toHaveBeenCalledWith(remoteClock.timeTelemetryObject, remoteClock._processDatum);
|
||||
});
|
||||
});
|
||||
|
||||
it('will request metadata and set up formatters', () => {
|
||||
expect(remoteClock.metadata).toEqual(metadata);
|
||||
expect(metadata.value).toHaveBeenCalled();
|
||||
expect(openmct.telemetry.getValueFormatter).toHaveBeenCalledWith(metadataValue);
|
||||
});
|
||||
|
||||
it('will request the latest datum for the object it received and process the datum returned', () => {
|
||||
expect(openmct.telemetry.request).toHaveBeenCalledWith(remoteClock.timeTelemetryObject, REQ_OPTIONS);
|
||||
expect(boundsCallback).toHaveBeenCalledWith({
|
||||
start: TIME_VALUE + OFFSET_START,
|
||||
end: TIME_VALUE + OFFSET_END
|
||||
}, true);
|
||||
});
|
||||
|
||||
it('will set up subscriptions correctly', () => {
|
||||
expect(remoteClock._unsubscribe).toBeDefined();
|
||||
expect(openmct.telemetry.subscribe).toHaveBeenCalledWith(remoteClock.timeTelemetryObject, remoteClock._processDatum);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -101,7 +101,8 @@ export default {
|
||||
if (nowMarker) {
|
||||
nowMarker.classList.remove('hidden');
|
||||
nowMarker.style.height = this.contentHeight + 'px';
|
||||
const now = this.xScale(Date.now());
|
||||
const nowTimeStamp = this.openmct.time.clock().currentValue();
|
||||
const now = this.xScale(nowTimeStamp);
|
||||
nowMarker.style.left = now + this.offset + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,6 +335,7 @@ export default {
|
||||
dialog.dismiss();
|
||||
this.openmct.notifications.error('Error saving objects');
|
||||
console.error(error);
|
||||
this.openmct.editor.cancel();
|
||||
});
|
||||
},
|
||||
saveAndContinueEditing() {
|
||||
|
||||
@@ -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];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user