Merge branch 'master' into telemetry-comps

This commit is contained in:
Scott Bell 2024-12-11 16:58:25 +01:00 committed by GitHub
commit 5e15af120b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 612 additions and 36 deletions

View File

@ -484,7 +484,8 @@
"darkmatter",
"Undeletes",
"SSSZ",
"LOCF"
"LOCF",
"pageerror"
],
"dictionaries": ["npm", "softwareTerms", "node", "html", "css", "bash", "en_US", "en-gb", "misc"],
"ignorePaths": [

View File

@ -682,6 +682,21 @@ async function linkParameterToObject(page, parameterName, objectName) {
await page.getByLabel('Save').click();
}
/**
* Rename the currently viewed `domainObject` from the browse bar.
*
* @param {import('@playwright/test').Page} page
* @param {string} newName
*/
async function renameCurrentObjectFromBrowseBar(page, newName) {
const nameInput = page.getByLabel('Browse bar object name');
await nameInput.click();
await nameInput.fill('');
await nameInput.fill(newName);
// Click the browse bar container to save changes
await page.getByLabel('Browse bar', { exact: true }).click();
}
export {
createDomainObjectWithDefaults,
createExampleTelemetryObject,
@ -693,6 +708,7 @@ export {
linkParameterToObject,
navigateToObjectWithFixedTimeBounds,
navigateToObjectWithRealTime,
renameCurrentObjectFromBrowseBar,
setEndOffset,
setFixedIndependentTimeConductorBounds,
setFixedTimeMode,

View File

@ -0,0 +1,67 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2024, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
import { createDomainObjectWithDefaults, setRealTimeMode } from '../../../appActions.js';
import { MISSION_TIME } from '../../../constants.js';
import { expect, test } from '../../../pluginFixtures.js';
const TELEMETRY_RATE = 2500;
test.describe('Example Event Generator Acknowledge with Controlled Clock @clock', () => {
test.beforeEach(async ({ page }) => {
await page.clock.install({ time: MISSION_TIME });
await page.clock.resume();
await page.goto('./', { waitUntil: 'domcontentloaded' });
await setRealTimeMode(page);
await createDomainObjectWithDefaults(page, {
type: 'Event Message Generator with Acknowledge'
});
});
test('Rows are updatable in place', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/7938'
});
await test.step('First telemetry datum gets added as new row', async () => {
await page.clock.fastForward(TELEMETRY_RATE);
const rows = page.getByLabel('table content').getByLabel('Table Row');
const acknowledgeCell = rows.first().getByLabel('acknowledge table cell');
await expect(rows).toHaveCount(1);
await expect(acknowledgeCell).not.toHaveAttribute('title', 'OK');
});
await test.step('Incoming Telemetry datum matching an existing rows in place update key has data merged to existing row', async () => {
await page.clock.fastForward(TELEMETRY_RATE * 2);
const rows = page.getByLabel('table content').getByLabel('Table Row');
const acknowledgeCell = rows.first().getByLabel('acknowledge table cell');
await expect(rows).toHaveCount(1);
await expect(acknowledgeCell).toHaveAttribute('title', 'OK');
});
});
});

View File

@ -507,8 +507,140 @@ test.describe('Display Layout', () => {
// In real time mode, we don't fetch annotations at all
await expect.poll(() => networkRequests, { timeout: 10000 }).toHaveLength(0);
});
test('Same objects with different request options have unique subscriptions', async ({
page
}) => {
// Expand My Items
await page.getByLabel('Expand My Items folder').click();
// Create a Display Layout
const displayLayout = await createDomainObjectWithDefaults(page, {
type: 'Display Layout',
name: 'Test Display'
});
// Create a State Generator, set to higher frequency updates
const stateGenerator = await createDomainObjectWithDefaults(page, {
type: 'State Generator',
name: 'State Generator'
});
const stateGeneratorTreeItem = page.getByRole('treeitem', {
name: stateGenerator.name
});
await stateGeneratorTreeItem.click({ button: 'right' });
await page.getByLabel('Edit Properties...').click();
await page.getByLabel('State Duration (seconds)', { exact: true }).fill('0.1');
await page.getByLabel('Save').click();
// Create a Table for filtering ON values
const tableFilterOnValue = await createDomainObjectWithDefaults(page, {
type: 'Telemetry Table',
name: 'Table Filter On Value'
});
const tableFilterOnTreeItem = page.getByRole('treeitem', {
name: tableFilterOnValue.name
});
// Create a Table for filtering OFF values
const tableFilterOffValue = await createDomainObjectWithDefaults(page, {
type: 'Telemetry Table',
name: 'Table Filter Off Value'
});
const tableFilterOffTreeItem = page.getByRole('treeitem', {
name: tableFilterOffValue.name
});
// Navigate to ON filtering table and add state generator and setup filters
await page.goto(tableFilterOnValue.url);
await stateGeneratorTreeItem.dragTo(page.getByLabel('Object View'));
await selectFilterOption(page, '1');
await page.getByLabel('Save').click();
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
// Navigate to OFF filtering table and add state generator and setup filters
await page.goto(tableFilterOffValue.url);
await stateGeneratorTreeItem.dragTo(page.getByLabel('Object View'));
await selectFilterOption(page, '0');
await page.getByLabel('Save').click();
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
// Navigate to the display layout and edit it
await page.goto(displayLayout.url);
// Add the tables to the display layout
await page.getByLabel('Edit Object').click();
await tableFilterOffTreeItem.dragTo(page.getByLabel('Layout Grid'), {
targetPosition: { x: 10, y: 300 }
});
await page.locator('.c-frame-edit > div:nth-child(4)').dragTo(page.getByLabel('Layout Grid'), {
targetPosition: { x: 400, y: 500 },
// eslint-disable-next-line playwright/no-force-option
force: true
});
await tableFilterOnTreeItem.dragTo(page.getByLabel('Layout Grid'), {
targetPosition: { x: 10, y: 100 }
});
await page.locator('.c-frame-edit > div:nth-child(4)').dragTo(page.getByLabel('Layout Grid'), {
targetPosition: { x: 400, y: 300 },
// eslint-disable-next-line playwright/no-force-option
force: true
});
await page.getByLabel('Save').click();
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
// Get the tables so we can verify filtering is working as expected
const tableFilterOn = page.getByLabel(`${tableFilterOnValue.name} Frame`, {
exact: true
});
const tableFilterOff = page.getByLabel(`${tableFilterOffValue.name} Frame`, {
exact: true
});
// Verify filtering is working correctly
// Check that no filtered values appear for at least 2 seconds
const VERIFICATION_TIME = 2000; // 2 seconds
const CHECK_INTERVAL = 100; // Check every 100ms
// Create a promise that will check for filtered values periodically
const checkForCorrectValues = new Promise((resolve, reject) => {
const interval = setInterval(async () => {
const offCount = await tableFilterOn.locator('td[title="OFF"]').count();
const onCount = await tableFilterOff.locator('td[title="ON"]').count();
if (offCount > 0 || onCount > 0) {
clearInterval(interval);
reject(
new Error(
`Found ${offCount} OFF and ${onCount} ON values when expecting 0 OFF and 0 ON`
)
);
}
}, CHECK_INTERVAL);
// After VERIFICATION_TIME, if no filtered values were found, resolve successfully
setTimeout(() => {
clearInterval(interval);
resolve();
}, VERIFICATION_TIME);
});
await expect(checkForCorrectValues).resolves.toBeUndefined();
});
});
async function selectFilterOption(page, filterOption) {
await page.getByRole('tab', { name: 'Filters' }).click();
await page
.getByLabel('Inspector Views')
.locator('li')
.filter({ hasText: 'State Generator' })
.locator('span')
.click();
await page.getByRole('switch').click();
await page.selectOption('select[name="setSelectionThreshold"]', filterOption);
}
async function addAndRemoveDrawingObjectAndAssert(page, layoutObject, DISPLAY_LAYOUT_NAME) {
await expect(page.getByLabel(layoutObject, { exact: true })).toHaveCount(0);
await addLayoutObject(page, DISPLAY_LAYOUT_NAME, layoutObject);

View File

@ -28,7 +28,9 @@ import { v4 as uuid } from 'uuid';
import {
createDomainObjectWithDefaults,
createExampleTelemetryObject
createExampleTelemetryObject,
setRealTimeMode,
setStartOffset
} from '../../../../appActions.js';
import { expect, test } from '../../../../pluginFixtures.js';
@ -166,6 +168,57 @@ test.describe('Gauge', () => {
);
});
test('Gauge does not break when an object is missing', async ({ page }) => {
// Set up error listeners
const pageErrors = [];
// Listen for uncaught exceptions
page.on('pageerror', (err) => {
pageErrors.push(err.message);
});
await setRealTimeMode(page);
// Create a Gauge
const gauge = await createDomainObjectWithDefaults(page, {
type: 'Gauge',
name: 'Gauge with missing object'
});
// Create a Sine Wave Generator in the Gauge with a loading delay
const missingSWG = await createExampleTelemetryObject(page, gauge.uuid);
// Remove the object from local storage
await page.evaluate(
([missingObject]) => {
const mct = localStorage.getItem('mct');
const mctObjects = JSON.parse(mct);
delete mctObjects[missingObject.uuid];
localStorage.setItem('mct', JSON.stringify(mctObjects));
},
[missingSWG]
);
// Verify start bounds
await expect(page.getByLabel('Start offset: 00:30:00')).toBeVisible();
// Nav to the Gauge
await page.goto(gauge.url, { waitUntil: 'domcontentloaded' });
// adjust time bounds and ensure they are updated
await setStartOffset(page, {
startHours: '00',
startMins: '45',
startSecs: '00'
});
// Verify start bounds changed
await expect(page.getByLabel('Start offset: 00:45:00')).toBeVisible();
// // Verify no errors were thrown
expect(pageErrors).toHaveLength(0);
});
test('Gauge enforces composition policy', async ({ page }) => {
// Create a Gauge
await createDomainObjectWithDefaults(page, {

View File

@ -26,7 +26,10 @@ This test suite is dedicated to tests which verify the basic operations surround
import { fileURLToPath } from 'url';
import { createDomainObjectWithDefaults } from '../../../../appActions.js';
import {
createDomainObjectWithDefaults,
renameCurrentObjectFromBrowseBar
} from '../../../../appActions.js';
import { copy, paste, selectAll } from '../../../../helper/hotkeys/hotkeys.js';
import * as nbUtils from '../../../../helper/notebookUtils.js';
import { expect, streamToString, test } from '../../../../pluginFixtures.js';
@ -596,4 +599,61 @@ test.describe('Notebook entry tests', () => {
await expect(await page.locator(`text="${TEST_TEXT.repeat(1)}"`).count()).toEqual(1);
await expect(await page.locator(`text="${TEST_TEXT.repeat(2)}"`).count()).toEqual(0);
});
test('When changing the name of a notebook in the browse bar, new notebook changes are not lost', async ({
page
}) => {
const TEST_TEXT = 'Do not lose me!';
const FIRST_NEW_NAME = 'New Name';
const SECOND_NEW_NAME = 'Second New Name';
await page.goto(notebookObject.url);
await page.getByLabel('Expand My Items folder').click();
await renameCurrentObjectFromBrowseBar(page, FIRST_NEW_NAME);
// verify the name change in tree and browse bar
await verifyNameChange(page, FIRST_NEW_NAME);
// enter one entry
await enterAndCommitTextEntry(page, TEST_TEXT);
// verify the entry is present
await expect(await page.locator(`text="${TEST_TEXT}"`).count()).toEqual(1);
// change the name
await renameCurrentObjectFromBrowseBar(page, SECOND_NEW_NAME);
// verify the name change in tree and browse bar
await verifyNameChange(page, SECOND_NEW_NAME);
// verify the entry is still present
await expect(await page.locator(`text="${TEST_TEXT}"`).count()).toEqual(1);
});
});
/**
* Enter text into the last notebook entry and commit it.
*
* @param {import('@playwright/test').Page} page
* @param {string} text
*/
async function enterAndCommitTextEntry(page, text) {
await nbUtils.addNotebookEntry(page);
await nbUtils.enterTextInLastEntry(page, text);
await nbUtils.commitEntry(page);
}
/**
* Verify the name change in the tree and browse bar.
*
* @param {import('@playwright/test').Page} page
* @param {string} newName
*/
async function verifyNameChange(page, newName) {
await expect(
page.getByRole('treeitem').locator('.is-navigated-object .c-tree__item__name')
).toHaveText(newName);
await expect(page.getByLabel('Browse bar object name')).toHaveText(newName);
}

View File

@ -46,6 +46,24 @@ class EventMetadataProvider {
]
}
};
const inPlaceUpdateMetadataValue = {
key: 'messageId',
name: 'row identifier',
format: 'string',
useToUpdateInPlace: true
};
const eventAcknowledgeMetadataValue = {
key: 'acknowledge',
name: 'Acknowledge',
format: 'string'
};
const eventGeneratorWithAcknowledge = structuredClone(this.METADATA_BY_TYPE.eventGenerator);
eventGeneratorWithAcknowledge.values.push(inPlaceUpdateMetadataValue);
eventGeneratorWithAcknowledge.values.push(eventAcknowledgeMetadataValue);
this.METADATA_BY_TYPE.eventGeneratorWithAcknowledge = eventGeneratorWithAcknowledge;
}
supportsMetadata(domainObject) {

View File

@ -0,0 +1,70 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2024, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
/**
* Module defining EventTelemetryProvider. Created by chacskaylo on 06/18/2015.
*/
import EventTelemetryProvider from './EventTelemetryProvider.js';
class EventWithAcknowledgeTelemetryProvider extends EventTelemetryProvider {
constructor() {
super();
this.unAcknowledgedData = undefined;
}
generateData(firstObservedTime, count, startTime, duration, name) {
if (this.unAcknowledgedData === undefined) {
const unAcknowledgedData = super.generateData(
firstObservedTime,
count,
startTime,
duration,
name
);
unAcknowledgedData.messageId = unAcknowledgedData.message;
this.unAcknowledgedData = unAcknowledgedData;
return this.unAcknowledgedData;
} else {
const acknowledgedData = {
...this.unAcknowledgedData,
acknowledge: 'OK'
};
this.unAcknowledgedData = undefined;
return acknowledgedData;
}
}
supportsRequest(domainObject) {
return false;
}
supportsSubscribe(domainObject) {
return domainObject.type === 'eventGeneratorWithAcknowledge';
}
}
export default EventWithAcknowledgeTelemetryProvider;

View File

@ -21,6 +21,7 @@
*****************************************************************************/
import EventMetadataProvider from './EventMetadataProvider.js';
import EventTelemetryProvider from './EventTelemetryProvider.js';
import EventWithAcknowledgeTelemetryProvider from './EventWithAcknowledgeTelemetryProvider.js';
export default function EventGeneratorPlugin(options) {
return function install(openmct) {
@ -38,5 +39,20 @@ export default function EventGeneratorPlugin(options) {
});
openmct.telemetry.addProvider(new EventTelemetryProvider());
openmct.telemetry.addProvider(new EventMetadataProvider());
openmct.types.addType('eventGeneratorWithAcknowledge', {
name: 'Event Message Generator with Acknowledge',
description:
'For development use. Creates sample event message data stream and updates the event row with an acknowledgement.',
cssClass: 'icon-generator-events',
creatable: true,
initialize: function (object) {
object.telemetry = {
duration: 2.5
};
}
});
openmct.telemetry.addProvider(new EventWithAcknowledgeTelemetryProvider());
};
}

View File

@ -108,6 +108,16 @@ const METADATA_BY_TYPE = {
string: 'ON'
}
],
filters: [
{
singleSelectionThreshold: true,
comparator: 'equals',
possibleValues: [
{ label: 'OFF', value: 0 },
{ label: 'ON', value: 1 }
]
}
],
hints: {
range: 1
}

View File

@ -34,14 +34,16 @@ StateGeneratorProvider.prototype.supportsSubscribe = function (domainObject) {
return domainObject.type === 'example.state-generator';
};
StateGeneratorProvider.prototype.subscribe = function (domainObject, callback) {
StateGeneratorProvider.prototype.subscribe = function (domainObject, callback, options) {
var duration = domainObject.telemetry.duration * 1000;
var interval = setInterval(function () {
var interval = setInterval(() => {
var now = Date.now();
var datum = pointForTimestamp(now, duration, domainObject.name);
datum.value = String(datum.value);
callback(datum);
if (!this.shouldBeFiltered(datum, options)) {
datum.value = String(datum.value);
callback(datum);
}
}, duration);
return function () {
@ -63,9 +65,25 @@ StateGeneratorProvider.prototype.request = function (domainObject, options) {
var data = [];
while (start <= end && data.length < 5000) {
data.push(pointForTimestamp(start, duration, domainObject.name));
const point = pointForTimestamp(start, duration, domainObject.name);
if (!this.shouldBeFiltered(point, options)) {
data.push(point);
}
start += duration;
}
return Promise.resolve(data);
};
StateGeneratorProvider.prototype.shouldBeFiltered = function (point, options) {
const valueToFilter = options?.filters?.state?.equals?.[0];
if (!valueToFilter) {
return false;
}
const { value } = point;
return value !== Number(valueToFilter);
};

View File

@ -250,6 +250,90 @@ export default class TelemetryAPI {
return options;
}
/**
* Sanitizes objects for consistent serialization by:
* 1. Removing non-plain objects (class instances) and functions
* 2. Sorting object keys alphabetically to ensure consistent ordering
*/
sanitizeForSerialization(key, value) {
// Handle null and primitives directly
if (value === null || typeof value !== 'object') {
return value;
}
// Remove functions and non-plain objects (except arrays)
if (
typeof value === 'function' ||
(Object.getPrototypeOf(value) !== Object.prototype && !Array.isArray(value))
) {
return undefined;
}
// For plain objects, just sort the keys
if (!Array.isArray(value)) {
const sortedObject = {};
const sortedKeys = Object.keys(value).sort();
sortedKeys.forEach((objectKey) => {
sortedObject[objectKey] = value[objectKey];
});
return sortedObject;
}
return value;
}
/**
* Generates a numeric hash value for an options object. The hash is consistent
* for equivalent option objects regardless of property order.
*
* This is used to create compact, unique cache keys for telemetry subscriptions with
* different options configurations. The hash function ensures that identical options
* objects will always generate the same hash value, while different options objects
* (even with small differences) will generate different hash values.
*
* @private
* @param {Object} options The options object to hash
* @returns {number} A positive integer hash of the options object
*/
#hashOptions(options) {
const sanitizedOptionsString = JSON.stringify(
options,
this.sanitizeForSerialization.bind(this)
);
let hash = 0;
const prime = 31;
const modulus = 1e9 + 9; // Large prime number
for (let i = 0; i < sanitizedOptionsString.length; i++) {
const char = sanitizedOptionsString.charCodeAt(i);
// Calculate new hash value while keeping numbers manageable
hash = Math.floor((hash * prime + char) % modulus);
}
return Math.abs(hash);
}
/**
* Generates a unique cache key for a telemetry subscription based on the
* domain object identifier and options (which includes strategy).
*
* Uses a hash of the options object to create compact cache keys while still
* ensuring unique keys for different subscription configurations.
*
* @private
* @param {import('openmct').DomainObject} domainObject The domain object being subscribed to
* @param {Object} options The subscription options object (including strategy)
* @returns {string} A unique key string for caching the subscription
*/
#getSubscriptionCacheKey(domainObject, options) {
const keyString = makeKeyString(domainObject.identifier);
return `${keyString}:${this.#hashOptions(options)}`;
}
/**
* Register a request interceptor that transforms a request via module:openmct.TelemetryAPI.request
* The request will be modified when it is received and will be returned in it's modified state
@ -418,16 +502,14 @@ export default class TelemetryAPI {
this.#subscribeCache = {};
}
const keyString = makeKeyString(domainObject.identifier);
const supportedStrategy = supportsBatching ? requestedStrategy : SUBSCRIBE_STRATEGY.LATEST;
// Override the requested strategy with the strategy supported by the provider
const optionsWithSupportedStrategy = {
...options,
strategy: supportedStrategy
};
// If batching is supported, we need to cache a subscription for each strategy -
// latest and batched.
const cacheKey = `${keyString}:${supportedStrategy}`;
const cacheKey = this.#getSubscriptionCacheKey(domainObject, optionsWithSupportedStrategy);
let subscriber = this.#subscribeCache[cacheKey];
if (!subscriber) {

View File

@ -27,9 +27,9 @@ To define a filter, you'll need to add a new `filter` property to the domain obj
singleSelectionThreshold: true,
comparator: 'equals',
possibleValues: [
{ name: 'Apple', value: 'apple' },
{ name: 'Banana', value: 'banana' },
{ name: 'Orange', value: 'orange' }
{ label: 'Apple', value: 'apple' },
{ label: 'Banana', value: 'banana' },
{ label: 'Orange', value: 'orange' }
]
}]
}

View File

@ -649,6 +649,11 @@ export default {
},
request(domainObject = this.telemetryObject) {
this.metadata = this.openmct.telemetry.getMetadata(domainObject);
if (!this.metadata) {
return;
}
this.formats = this.openmct.telemetry.getFormatMap(this.metadata);
const LimitEvaluator = this.openmct.telemetry.getLimits(domainObject);
LimitEvaluator.limits().then(this.updateLimits);

View File

@ -91,15 +91,19 @@ export default class TelemetryTableRow {
return [VIEW_DATUM_ACTION_KEY, VIEW_HISTORICAL_DATA_ACTION_KEY];
}
updateWithDatum(updatesToDatum) {
const normalizedUpdatesToDatum = createNormalizedDatum(updatesToDatum, this.columns);
/**
* Merges the row parameter's datum with the current row datum
* @param {TelemetryTableRow} row
*/
updateWithDatum(row) {
this.datum = {
...this.datum,
...normalizedUpdatesToDatum
...row.datum
};
this.fullDatum = {
...this.fullDatum,
...updatesToDatum
...row.fullDatum
};
}
}

View File

@ -23,6 +23,11 @@ import { EventEmitter } from 'eventemitter3';
import _ from 'lodash';
import { ORDER } from '../constants.js';
/**
* @typedef {import('.TelemetryTableRow.js').default} TelemetryTableRow
*/
/**
* @constructor
*/
@ -124,10 +129,22 @@ export default class TableRowCollection extends EventEmitter {
return foundIndex;
}
updateRowInPlace(row, index) {
const foundRow = this.rows[index];
foundRow.updateWithDatum(row.datum);
this.rows[index] = foundRow;
/**
* `incomingRow` exists in the collection,
* so merge existing and incoming row properties
*
* Do to reactivity of Vue, we want to replace the existing row with the updated row
* @param {TelemetryTableRow} incomingRow to update
* @param {number} index of the existing row in the collection to update
*/
updateRowInPlace(incomingRow, index) {
// Update the incoming row, not the existing row
const existingRow = this.rows[index];
incomingRow.updateWithDatum(existingRow);
// Replacing the existing row with the updated, incoming row will trigger Vue reactivity
// because the reference to the row has changed
this.rows.splice(index, 1, incomingRow);
}
setLimit(rowLimit) {

View File

@ -373,7 +373,6 @@ export default {
configuredColumnWidths: configuration.columnWidths,
sizingRows: {},
rowHeight: ROW_HEIGHT,
scrollOffset: 0,
totalHeight: 0,
totalWidth: 0,
rowOffset: 0,
@ -552,6 +551,7 @@ export default {
//Default sort
this.sortOptions = this.table.tableRows.sortBy();
this.scrollable = this.$refs.scrollable;
this.lastScrollLeft = this.scrollable.scrollLeft;
this.contentTable = this.$refs.contentTable;
this.sizingTable = this.$refs.sizingTable;
this.headersHolderEl = this.$refs.headersHolderEl;
@ -740,7 +740,9 @@ export default {
this.table.sortBy(this.sortOptions);
},
scroll() {
this.throttledUpdateVisibleRows();
if (this.lastScrollLeft === this.scrollable.scrollLeft) {
this.throttledUpdateVisibleRows();
}
this.synchronizeScrollX();
if (this.shouldAutoScroll()) {
@ -765,6 +767,8 @@ export default {
this.scrollable.scrollTop = Number.MAX_SAFE_INTEGER;
},
synchronizeScrollX() {
this.lastScrollLeft = this.scrollable.scrollLeft;
if (this.$refs.headersHolderEl && this.scrollable) {
this.headersHolderEl.scrollLeft = this.scrollable.scrollLeft;
}

View File

@ -436,6 +436,9 @@ export default {
return startInBounds || endInBounds || middleInBounds;
},
isActivityInProgress(activity) {
return this.persistedActivityStates[activity.id] === 'in-progress';
},
filterActivities(activity) {
if (this.isEditing) {
return true;
@ -460,7 +463,8 @@ export default {
return false;
}
if (!this.isActivityInBounds(activity)) {
// An activity may be out of bounds, but if it is in-progress, we show it.
if (!this.isActivityInBounds(activity) && !this.isActivityInProgress(activity)) {
return false;
}
//current event or future start event or past end event

View File

@ -20,7 +20,7 @@
at runtime from the About dialog for additional information.
-->
<template>
<div class="l-browse-bar">
<div class="l-browse-bar" aria-label="Browse bar">
<div class="l-browse-bar__start">
<button
v-if="hasParent"
@ -35,6 +35,7 @@
</div>
<span
ref="objectName"
aria-label="Browse bar object name"
class="l-browse-bar__object-name c-object-label__name"
:class="{ 'c-input-inline': isPersistable }"
:contenteditable="isNameEditable"

View File

@ -80,13 +80,11 @@ class Browse {
this.#openmct.layout.$refs.browseBar.viewKey = viewProvider.key;
}
#updateDocumentTitleOnNameMutation(newName) {
if (typeof newName === 'string' && newName !== document.title) {
document.title = newName;
this.#openmct.layout.$refs.browseBar.domainObject = {
...this.#openmct.layout.$refs.browseBar.domainObject,
name: newName
};
#handleBrowseObjectUpdate(newObject) {
this.#openmct.layout.$refs.browseBar.domainObject = newObject;
if (typeof newObject.name === 'string' && newObject.name !== document.title) {
document.title = newObject.name;
}
}
@ -120,8 +118,8 @@ class Browse {
document.title = this.#browseObject.name; //change document title to current object in main view
this.#unobserve = this.#openmct.objects.observe(
this.#browseObject,
'name',
this.#updateDocumentTitleOnNameMutation.bind(this)
'*',
this.#handleBrowseObjectUpdate.bind(this)
);
const currentProvider = this.#openmct.objectViews.getByProviderKey(currentViewKey);
if (currentProvider && currentProvider.canView(this.#browseObject, this.#openmct.router.path)) {