mirror of
https://github.com/nasa/openmct.git
synced 2025-07-03 05:22:52 +00:00
Compare commits
46 Commits
sort-fixes
...
conditions
Author | SHA1 | Date | |
---|---|---|---|
84d8d8b0e8 | |||
573bbb041e | |||
6a450a0e89 | |||
e5631c9f6c | |||
15b5d1405d | |||
0377788533 | |||
28dcff7e89 | |||
b191eb9d64 | |||
f8e4aba922 | |||
28f6987dd7 | |||
f3047093d6 | |||
34e57ef300 | |||
88e6557782 | |||
dbd4abebae | |||
50ca27e54f | |||
2955092c86 | |||
28b5d7c41c | |||
ecd120387c | |||
a6517bb33e | |||
0d675c13c7 | |||
bc4893f70b | |||
9dca981130 | |||
19a77b37dd | |||
1fde0d9e38 | |||
5be103ea72 | |||
d74e1b19b6 | |||
5bb6a18cd4 | |||
14b947c101 | |||
61b982ab99 | |||
ba4d8a428b | |||
ea9947cab5 | |||
2010f2e377 | |||
3241e9ba57 | |||
057a5f997c | |||
078cd341a5 | |||
518b55cf0f | |||
3e23dceb64 | |||
7f8b5e09e5 | |||
7c2bb16bfd | |||
890ddcac4e | |||
d8c5095ebb | |||
ccf7ed91af | |||
2b8673941a | |||
703186adf1 | |||
c43ef64733 | |||
f4cf9c756b |
@ -5,7 +5,7 @@ orbs:
|
||||
executors:
|
||||
pw-focal-development:
|
||||
docker:
|
||||
- image: mcr.microsoft.com/playwright:v1.47.2-focal
|
||||
- image: mcr.microsoft.com/playwright:v1.48.1-focal
|
||||
environment:
|
||||
NODE_ENV: development # Needed to ensure 'dist' folder created and devDependencies installed
|
||||
PERCY_POSTINSTALL_BROWSER: 'true' # Needed to store the percy browser in cache deps
|
||||
@ -198,7 +198,7 @@ jobs:
|
||||
steps:
|
||||
- build_and_install:
|
||||
node-version: lts/hydrogen
|
||||
- run: npx playwright@1.47.2 install #Necessary for bare ubuntu machine
|
||||
- run: npx playwright@1.48.1 install #Necessary for bare ubuntu machine
|
||||
- run: |
|
||||
export $(cat src/plugins/persistence/couch/.env.ci | xargs)
|
||||
docker compose -f src/plugins/persistence/couch/couchdb-compose.yaml up --detach
|
||||
@ -286,8 +286,8 @@ workflows:
|
||||
overall-circleci-commit-status: #These jobs run on every commit
|
||||
jobs:
|
||||
- lint:
|
||||
name: node20-lint
|
||||
node-version: lts/iron
|
||||
name: node22-lint
|
||||
node-version: '22'
|
||||
- unit-test:
|
||||
name: node18-chrome
|
||||
node-version: lts/hydrogen
|
||||
@ -304,8 +304,8 @@ workflows:
|
||||
the-nightly: #These jobs do not run on PRs, but against master at night
|
||||
jobs:
|
||||
- unit-test:
|
||||
name: node20-chrome-nightly
|
||||
node-version: lts/iron
|
||||
name: node22-chrome-nightly
|
||||
node-version: '22'
|
||||
- unit-test:
|
||||
name: node18-chrome
|
||||
node-version: lts/hydrogen
|
||||
|
@ -483,7 +483,8 @@
|
||||
"countup",
|
||||
"darkmatter",
|
||||
"Undeletes",
|
||||
"SSSZ"
|
||||
"SSSZ",
|
||||
"pageerror"
|
||||
],
|
||||
"dictionaries": ["npm", "softwareTerms", "node", "html", "css", "bash", "en_US", "en-gb", "misc"],
|
||||
"ignorePaths": [
|
||||
|
14
.github/workflows/e2e-couchdb.yml
vendored
14
.github/workflows/e2e-couchdb.yml
vendored
@ -37,7 +37,7 @@ jobs:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- run: npx playwright@1.47.2 install
|
||||
- run: npx playwright@1.48.1 install
|
||||
|
||||
- name: Start CouchDB Docker Container and Init with Setup Scripts
|
||||
run: |
|
||||
@ -51,12 +51,12 @@ jobs:
|
||||
env:
|
||||
COMMIT_INFO_SHA: ${{github.event.pull_request.head.sha }}
|
||||
run: npm run test:e2e:couchdb
|
||||
|
||||
|
||||
- name: Generate Code Coverage Report
|
||||
run: npm run cov:e2e:report
|
||||
|
||||
- name: Publish Results to Codecov.io
|
||||
uses: codecov/codecov-action@v4
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./coverage/e2e/lcov.info
|
||||
@ -66,15 +66,19 @@ jobs:
|
||||
|
||||
- name: Archive test results
|
||||
if: success() || failure()
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2e-couchdb-test-results
|
||||
path: test-results
|
||||
overwrite: true
|
||||
|
||||
- name: Archive html test results
|
||||
if: success() || failure()
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2e-couchdb-html-test-results
|
||||
path: html-test-results
|
||||
overwrite: true
|
||||
|
||||
- name: Remove pr:e2e:couchdb label (if present)
|
||||
if: always()
|
||||
|
6
.github/workflows/e2e-flakefinder.yml
vendored
6
.github/workflows/e2e-flakefinder.yml
vendored
@ -30,7 +30,7 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
|
||||
- run: npx playwright@1.47.2 install
|
||||
- run: npx playwright@1.48.1 install
|
||||
- run: npm ci --no-audit --progress=false
|
||||
|
||||
- name: Run E2E Tests (Repeated 10 Times)
|
||||
@ -38,9 +38,11 @@ jobs:
|
||||
|
||||
- name: Archive test results
|
||||
if: success() || failure()
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2e-flakefinder-test-results
|
||||
path: test-results
|
||||
overwrite: true
|
||||
|
||||
- name: Remove pr:e2e:flakefinder label (if present)
|
||||
if: always()
|
||||
|
6
.github/workflows/e2e-perf.yml
vendored
6
.github/workflows/e2e-perf.yml
vendored
@ -28,16 +28,18 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
|
||||
- run: npx playwright@1.47.2 install
|
||||
- run: npx playwright@1.48.1 install
|
||||
- run: npm ci --no-audit --progress=false
|
||||
- run: npm run test:perf:localhost
|
||||
- run: npm run test:perf:contract
|
||||
- run: npm run test:perf:memory
|
||||
- name: Archive test results
|
||||
if: success() || failure()
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2e-perf-test-results
|
||||
path: test-results
|
||||
overwrite: true
|
||||
|
||||
- name: Remove pr:e2e:perf label (if present)
|
||||
if: always()
|
||||
|
4
.github/workflows/e2e-pr.yml
vendored
4
.github/workflows/e2e-pr.yml
vendored
@ -45,9 +45,11 @@ jobs:
|
||||
npm run cov:e2e:full:publish
|
||||
- name: Archive test results
|
||||
if: success() || failure()
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2e-pr-test-results
|
||||
path: test-results
|
||||
overwrite: true
|
||||
|
||||
- name: Remove pr:e2e label (if present)
|
||||
if: always()
|
||||
|
116
.github/workflows/release.yml
vendored
116
.github/workflows/release.yml
vendored
@ -1,116 +0,0 @@
|
||||
# GitHub Actions Workflow for Automated Releases
|
||||
|
||||
name: Automated Release Workflow
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Nightly builds at 6 PM PST every day
|
||||
- cron: '0 2 * * *'
|
||||
release:
|
||||
types:
|
||||
- created
|
||||
- published
|
||||
|
||||
jobs:
|
||||
nightly-build:
|
||||
if: github.event_name == 'schedule'
|
||||
runs-on: ubuntu-latest
|
||||
name: Nightly Build and Release
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set Up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 'lts/iron' # Specify your Node.js version
|
||||
registry-url: 'https://registry.npmjs.org/'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Bump Version for Nightly
|
||||
id: bump_version
|
||||
run: |
|
||||
PACKAGE_VERSION=$(node -p "require('./package.json').version")
|
||||
DATE=$(date +%Y%m%d)
|
||||
NIGHTLY_VERSION=$(echo $PACKAGE_VERSION | awk -F. -v OFS=. '{$NF+=1; print}')-nightly-$DATE
|
||||
echo "NIGHTLY_VERSION=${NIGHTLY_VERSION}" >> $GITHUB_ENV
|
||||
|
||||
- name: Update package.json
|
||||
run: |
|
||||
npm version $NIGHTLY_VERSION --no-git-tag-version
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add package.json
|
||||
git commit -m "chore: bump version to $NIGHTLY_VERSION for nightly build"
|
||||
|
||||
- name: Push Changes
|
||||
uses: ad-m/github-push-action@v0.6.0
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
branch: ${{ github.ref }}
|
||||
|
||||
- name: Build Project
|
||||
run: npm run build:prod
|
||||
|
||||
- name: Publish Nightly to NPM
|
||||
run: |
|
||||
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc
|
||||
npm publish --access public --tag nightly
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
prerelease-build:
|
||||
if: github.event.release.prerelease == true
|
||||
runs-on: ubuntu-latest
|
||||
name: Pre-release (Beta) Build and Publish
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set Up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '16' # Specify your Node.js version
|
||||
registry-url: 'https://registry.npmjs.org/'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build Project
|
||||
run: npm run build:prod
|
||||
|
||||
- name: Publish Beta to NPM
|
||||
run: |
|
||||
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc
|
||||
npm publish --access public --tag beta
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
stable-release-build:
|
||||
if: github.event.release.prerelease == false
|
||||
runs-on: ubuntu-latest
|
||||
name: Stable Release Build and Publish
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set Up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '16' # Specify your Node.js version
|
||||
registry-url: 'https://registry.npmjs.org/'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build Project
|
||||
run: npm run build:prod
|
||||
|
||||
- name: Publish to NPM
|
||||
run: |
|
||||
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc
|
||||
npm publish --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
@ -1,6 +1,10 @@
|
||||
codecov:
|
||||
require_ci_to_pass: false #This setting will update the bot regardless of whether or not tests pass
|
||||
|
||||
# Disabling annotations for now. They are incorrectly labelling lines as lacking coverage when they are in fact covered by tests.
|
||||
github_checks:
|
||||
annotations: false
|
||||
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
|
@ -2,7 +2,6 @@
|
||||
module.exports = {
|
||||
extends: ['plugin:playwright/recommended'],
|
||||
rules: {
|
||||
'playwright/max-nested-describe': ['error', { max: 1 }],
|
||||
'playwright/expect-expect': 'off'
|
||||
},
|
||||
overrides: [
|
||||
|
@ -510,6 +510,10 @@ async function setTimeConductorBounds(page, { submitChanges = true, ...bounds })
|
||||
// Open the time conductor popup
|
||||
await page.getByRole('button', { name: 'Time Conductor Mode', exact: true }).click();
|
||||
|
||||
// FIXME: https://github.com/nasa/openmct/pull/7818
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
if (startDate) {
|
||||
await page.getByLabel('Start date').fill(startDate);
|
||||
}
|
||||
@ -678,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,
|
||||
@ -689,6 +708,7 @@ export {
|
||||
linkParameterToObject,
|
||||
navigateToObjectWithFixedTimeBounds,
|
||||
navigateToObjectWithRealTime,
|
||||
renameCurrentObjectFromBrowseBar,
|
||||
setEndOffset,
|
||||
setFixedIndependentTimeConductorBounds,
|
||||
setFixedTimeMode,
|
||||
|
@ -103,25 +103,40 @@ const extendedTest = test.extend({
|
||||
* Default: `true`
|
||||
*/
|
||||
failOnConsoleError: [true, { option: true }],
|
||||
ignore404s: [[], { option: true }],
|
||||
/**
|
||||
* Extends the base page class to enable console log error detection.
|
||||
* @see {@link https://github.com/microsoft/playwright/discussions/11690 Github Discussion}
|
||||
*/
|
||||
page: async ({ page, failOnConsoleError }, use) => {
|
||||
page: async ({ page, failOnConsoleError, ignore404s }, use) => {
|
||||
// Capture any console errors during test execution
|
||||
const messages = [];
|
||||
let messages = [];
|
||||
page.on('console', (msg) => messages.push(msg));
|
||||
|
||||
await use(page);
|
||||
|
||||
if (ignore404s.length > 0) {
|
||||
messages = messages.filter((msg) => {
|
||||
let keep = true;
|
||||
|
||||
if (msg.text().match(/404 \((Object )?Not Found\)/) !== null) {
|
||||
keep = ignore404s.every((ignoreRule) => {
|
||||
return msg.location().url.match(ignoreRule) === null;
|
||||
});
|
||||
}
|
||||
|
||||
return keep;
|
||||
});
|
||||
}
|
||||
|
||||
// Assert against console errors during teardown
|
||||
if (failOnConsoleError) {
|
||||
messages.forEach((msg) =>
|
||||
messages.forEach((msg) => {
|
||||
// eslint-disable-next-line playwright/no-standalone-expect
|
||||
expect
|
||||
.soft(msg.type(), `Console error detected: ${_consoleMessageToString(msg)}`)
|
||||
.not.toEqual('error')
|
||||
);
|
||||
.not.toEqual('error');
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -129,6 +129,7 @@ export async function setBoundsToSpanAllActivities(page, planJson, planObjectUrl
|
||||
*/
|
||||
export function getEarliestStartTime(planJson) {
|
||||
const activities = Object.values(planJson).flat();
|
||||
|
||||
return Math.min(...activities.map((activity) => activity.start));
|
||||
}
|
||||
|
||||
@ -139,6 +140,7 @@ export function getEarliestStartTime(planJson) {
|
||||
*/
|
||||
export function getLatestEndTime(planJson) {
|
||||
const activities = Object.values(planJson).flat();
|
||||
|
||||
return Math.max(...activities.map((activity) => activity.end));
|
||||
}
|
||||
|
||||
@ -151,6 +153,7 @@ export function getFirstActivity(planJson) {
|
||||
const groups = Object.keys(planJson);
|
||||
const firstGroupKey = groups[0];
|
||||
const firstGroupItems = planJson[firstGroupKey];
|
||||
|
||||
return firstGroupItems[0];
|
||||
}
|
||||
|
||||
|
@ -16,7 +16,7 @@
|
||||
"devDependencies": {
|
||||
"@percy/cli": "1.27.4",
|
||||
"@percy/playwright": "1.0.4",
|
||||
"@playwright/test": "1.47.2",
|
||||
"@playwright/test": "1.48.1",
|
||||
"@axe-core/playwright": "4.8.5"
|
||||
},
|
||||
"author": {
|
||||
|
BIN
e2e/test-data/rick space roll.jpg
Normal file
BIN
e2e/test-data/rick space roll.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 10 KiB |
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
@ -54,8 +54,7 @@ const examplePlanSmall1 = JSON.parse(
|
||||
const TIME_TO_FROM_COLUMN = 2;
|
||||
const HEADER_ROW = 0;
|
||||
const NUM_COLUMNS = 5;
|
||||
const FULL_CIRCLE_PATH =
|
||||
'M3.061616997868383e-15,-50A50,50,0,1,1,-3.061616997868383e-15,50A50,50,0,1,1,3.061616997868383e-15,-50Z';
|
||||
const FULL_CIRCLE_PATH = 'M0,-50A50,50,0,1,1,0,50A50,50,0,1,1,0,-50Z';
|
||||
|
||||
/**
|
||||
* The regular expression used to parse the countdown string.
|
||||
|
@ -24,7 +24,9 @@ import {
|
||||
createDomainObjectWithDefaults,
|
||||
createPlanFromJSON,
|
||||
navigateToObjectWithFixedTimeBounds,
|
||||
setFixedIndependentTimeConductorBounds
|
||||
setFixedIndependentTimeConductorBounds,
|
||||
setFixedTimeMode,
|
||||
setTimeConductorBounds
|
||||
} from '../../../appActions.js';
|
||||
import { expect, test } from '../../../pluginFixtures.js';
|
||||
|
||||
@ -74,21 +76,14 @@ const testPlan = {
|
||||
};
|
||||
|
||||
test.describe('Time Strip', () => {
|
||||
test('Create two Time Strips, add a single Plan to both, and verify they can have separate Independent Time Contexts', async ({
|
||||
page
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/nasa/openmct/issues/5627'
|
||||
});
|
||||
|
||||
// Constant locators
|
||||
const activityBounds = page.locator('.activity-bounds');
|
||||
let timestrip;
|
||||
let plan;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Goto baseURL
|
||||
await page.goto('./', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
const timestrip = await test.step('Create a Time Strip', async () => {
|
||||
timestrip = await test.step('Create a Time Strip', async () => {
|
||||
const createdTimeStrip = await createDomainObjectWithDefaults(page, { type: 'Time Strip' });
|
||||
const objectName = await page.locator('.l-browse-bar__object-name').innerText();
|
||||
expect(objectName).toBe(createdTimeStrip.name);
|
||||
@ -96,7 +91,7 @@ test.describe('Time Strip', () => {
|
||||
return createdTimeStrip;
|
||||
});
|
||||
|
||||
const plan = await test.step('Create a Plan and add it to the timestrip', async () => {
|
||||
plan = await test.step('Create a Plan and add it to the timestrip', async () => {
|
||||
const createdPlan = await createPlanFromJSON(page, {
|
||||
name: 'Test Plan',
|
||||
json: testPlan
|
||||
@ -110,6 +105,22 @@ test.describe('Time Strip', () => {
|
||||
.dragTo(page.getByLabel('Object View'));
|
||||
await page.getByLabel('Save').click();
|
||||
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
|
||||
|
||||
return createdPlan;
|
||||
});
|
||||
});
|
||||
test('Create two Time Strips, add a single Plan to both, and verify they can have separate Independent Time Contexts', async ({
|
||||
page
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/nasa/openmct/issues/5627'
|
||||
});
|
||||
|
||||
// Constant locators
|
||||
const activityBounds = page.locator('.activity-bounds');
|
||||
|
||||
await test.step('Set time strip to fixed timespan mode and verify activities', async () => {
|
||||
const startBound = testPlan.TEST_GROUP[0].start;
|
||||
const endBound = testPlan.TEST_GROUP[testPlan.TEST_GROUP.length - 1].end;
|
||||
|
||||
@ -119,8 +130,6 @@ test.describe('Time Strip', () => {
|
||||
// Verify all events are displayed
|
||||
const eventCount = await page.locator('.activity-bounds').count();
|
||||
expect(eventCount).toEqual(testPlan.TEST_GROUP.length);
|
||||
|
||||
return createdPlan;
|
||||
});
|
||||
|
||||
await test.step('TimeStrip can use the Independent Time Conductor', async () => {
|
||||
@ -177,4 +186,48 @@ test.describe('Time Strip', () => {
|
||||
expect(await activityBounds.count()).toEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
test('Time strip now line', async ({ page }) => {
|
||||
test.info().annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/nasa/openmct/issues/7817'
|
||||
});
|
||||
|
||||
await test.step('Is displayed in realtime mode', async () => {
|
||||
await expect(page.getByLabel('Now Marker')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Is hidden when out of bounds of the time axis', async () => {
|
||||
// Switch to fixed timespan mode
|
||||
await setFixedTimeMode(page);
|
||||
// Get the end bounds
|
||||
const endBounds = await page.getByLabel('End bounds').textContent();
|
||||
|
||||
// Add 2 minutes to end bound datetime and use it as the new end time
|
||||
let endTimeStamp = new Date(endBounds);
|
||||
endTimeStamp.setUTCMinutes(endTimeStamp.getUTCMinutes() + 2);
|
||||
const endDate = endTimeStamp.toISOString().split('T')[0];
|
||||
const milliseconds = endTimeStamp.getMilliseconds();
|
||||
const endTime = endTimeStamp.toISOString().split('T')[1].replace(`.${milliseconds}Z`, '');
|
||||
|
||||
// Subtract 1 minute from the end bound and use it as the new start time
|
||||
let startTimeStamp = new Date(endBounds);
|
||||
startTimeStamp.setUTCMinutes(startTimeStamp.getUTCMinutes() + 1);
|
||||
const startDate = startTimeStamp.toISOString().split('T')[0];
|
||||
const startMilliseconds = startTimeStamp.getMilliseconds();
|
||||
const startTime = startTimeStamp
|
||||
.toISOString()
|
||||
.split('T')[1]
|
||||
.replace(`.${startMilliseconds}Z`, '');
|
||||
// Set fixed timespan mode to the future so that "now" is out of bounds.
|
||||
await setTimeConductorBounds(page, {
|
||||
startDate,
|
||||
endDate,
|
||||
startTime,
|
||||
endTime
|
||||
});
|
||||
|
||||
await expect(page.getByLabel('Now Marker')).toBeHidden();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -287,6 +287,41 @@ test.describe('Basic Condition Set Use', () => {
|
||||
description: 'https://github.com/nasa/openmct/issues/7484'
|
||||
});
|
||||
});
|
||||
|
||||
test('ConditionSet has add criteria button enabled/disabled when composition is and is not available', async ({
|
||||
page
|
||||
}) => {
|
||||
const exampleTelemetry = await createExampleTelemetryObject(page);
|
||||
|
||||
await page.getByLabel('Show selected item in tree').click();
|
||||
await page.goto(conditionSet.url);
|
||||
// Change the object to edit mode
|
||||
await page.getByLabel('Edit Object').click();
|
||||
|
||||
// Create a condition
|
||||
await page.locator('#addCondition').click();
|
||||
await page.locator('#conditionCollection').getByRole('textbox').nth(0).fill('First Condition');
|
||||
|
||||
// Validate that the add criteria button is disabled
|
||||
await expect(page.getByLabel('Add Criteria - Disabled')).toHaveAttribute('disabled');
|
||||
|
||||
// Add Telemetry to ConditionSet
|
||||
const sineWaveGeneratorTreeItem = page
|
||||
.getByRole('tree', {
|
||||
name: 'Main Tree'
|
||||
})
|
||||
.getByRole('treeitem', {
|
||||
name: exampleTelemetry.name
|
||||
});
|
||||
const conditionCollection = page.locator('#conditionCollection');
|
||||
await sineWaveGeneratorTreeItem.dragTo(conditionCollection);
|
||||
|
||||
// Validate that the add criteria button is enabled and adds a new criterion
|
||||
await expect(page.getByLabel('Add Criteria - Enabled')).not.toHaveAttribute('disabled');
|
||||
await page.getByLabel('Add Criteria - Enabled').click();
|
||||
const numOfUnnamedCriteria = await page.getByLabel('Criterion Telemetry Selection').count();
|
||||
expect(numOfUnnamedCriteria).toEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Condition Set Composition', () => {
|
||||
|
@ -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);
|
||||
|
@ -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, {
|
||||
|
@ -96,9 +96,6 @@ test.describe('Example Imagery Object', () => {
|
||||
expect(newPage.url()).toContain('.jpg');
|
||||
});
|
||||
|
||||
// this requires CORS to be enabled in some fashion
|
||||
test.fixme('Can right click on image and save it as a file', async ({ page }) => {});
|
||||
|
||||
test('Can adjust image brightness/contrast by dragging the sliders', async ({
|
||||
page,
|
||||
browserName
|
||||
|
@ -0,0 +1,93 @@
|
||||
/*****************************************************************************
|
||||
* 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.
|
||||
*****************************************************************************/
|
||||
|
||||
/*
|
||||
* This test suite verifies modifying the image location of the example imagery object.
|
||||
*/
|
||||
|
||||
import { createDomainObjectWithDefaults } from '../../../../appActions.js';
|
||||
import { expect, test } from '../../../../pluginFixtures.js';
|
||||
|
||||
test.describe('Example Imagery Object Custom Images', () => {
|
||||
let exampleImagery;
|
||||
test.beforeEach(async ({ page }) => {
|
||||
//Go to baseURL
|
||||
await page.goto('./', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// Create a default 'Example Imagery' object
|
||||
exampleImagery = await createDomainObjectWithDefaults(page, {
|
||||
name: 'Example Imagery',
|
||||
type: 'Example Imagery'
|
||||
});
|
||||
|
||||
// Verify that the created object is focused
|
||||
await expect(page.locator('.l-browse-bar__object-name')).toContainText(exampleImagery.name);
|
||||
await page.getByLabel('Focused Image Element').hover({ trial: true });
|
||||
|
||||
// Wait for image thumbnail auto-scroll to complete
|
||||
await expect(page.getByLabel('Image Thumbnail from').last()).toBeInViewport();
|
||||
});
|
||||
// this requires CORS to be enabled in some fashion
|
||||
test.fixme('Can right click on image and save it as a file', async ({ page }) => {});
|
||||
test('Can provide a custom image location for the example imagery object', async ({ page }) => {
|
||||
// Modify Example Imagery to create a really stable image which will never let us down
|
||||
await page.getByRole('button', { name: 'More actions' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Edit Properties...' }).click();
|
||||
await page
|
||||
.locator('#imageLocation-textarea')
|
||||
.fill(
|
||||
'https://raw.githubusercontent.com/nasa/openmct/554f77c42fec81cf0f63e62b278012cb08d82af9/e2e/test-data/rick.jpg,https://raw.githubusercontent.com/nasa/openmct/554f77c42fec81cf0f63e62b278012cb08d82af9/e2e/test-data/rick.jpg'
|
||||
);
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
|
||||
// Wait for the thumbnails to finish their scroll animation
|
||||
// (Wait until the rightmost thumbnail is in view)
|
||||
await expect(page.getByLabel('Image Thumbnail from').last()).toBeInViewport();
|
||||
|
||||
await expect(page.getByLabel('Image Wrapper')).toBeVisible();
|
||||
});
|
||||
test.fixme('Can provide a custom image with spaces in name', async ({ page }) => {
|
||||
test.info().annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/nasa/openmct/issues/7903'
|
||||
});
|
||||
await page.goto(exampleImagery.url, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// Modify Example Imagery to create a really stable image which will never let us down
|
||||
await page.getByRole('button', { name: 'More actions' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Edit Properties...' }).click();
|
||||
await page
|
||||
.locator('#imageLocation-textarea')
|
||||
.fill(
|
||||
'https://raw.githubusercontent.com/nasa/openmct/d8c64f183400afb70137221fc1a035e091bea912/e2e/test-data/rick%20space%20roll.jpg'
|
||||
);
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
|
||||
// Wait for the thumbnails to finish their scroll animation
|
||||
// (Wait until the rightmost thumbnail is in view)
|
||||
await expect(page.getByLabel('Image Thumbnail from').last()).toBeInViewport();
|
||||
|
||||
await expect(page.getByLabel('Image Wrapper')).toBeVisible();
|
||||
});
|
||||
});
|
@ -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);
|
||||
}
|
||||
|
@ -108,4 +108,42 @@ test.describe('Plot Controls', () => {
|
||||
// Expect before and after plot points to match
|
||||
await expect(plotPixelSizeAtPause).toEqual(plotPixelSizeAfterWait);
|
||||
});
|
||||
|
||||
/*
|
||||
Test to verify that switching a plot's time context from global to
|
||||
its own independent time context and then back to global context works correctly.
|
||||
|
||||
After switching from fixed time mode (ITC) to real time mode (global context),
|
||||
the pause control for the plot should be available, indicating that it is following the right context.
|
||||
*/
|
||||
test('Plots follow the right time context', async ({ page }) => {
|
||||
// Set global time conductor to real-time mode
|
||||
await setRealTimeMode(page);
|
||||
|
||||
// hover over plot for plot controls
|
||||
await page.getByLabel('Plot Canvas').hover();
|
||||
// Ensure pause control is visible since global time conductor is in Real time mode.
|
||||
await expect(page.getByTitle('Pause incoming real-time data')).toBeVisible();
|
||||
|
||||
// Toggle independent time conductor ON
|
||||
await page.getByLabel('Enable Independent Time Conductor').click();
|
||||
|
||||
// Bring up the independent time conductor popup and switch to fixed time mode
|
||||
await page.getByLabel('Independent Time Conductor Settings').click();
|
||||
await page.getByLabel('Independent Time Conductor Mode Menu').click();
|
||||
await page.getByRole('menuitem', { name: /Fixed Timespan/ }).click();
|
||||
|
||||
// hover over plot for plot controls
|
||||
await page.getByLabel('Plot Canvas').hover();
|
||||
// Ensure pause control is no longer visible since the plot is following the independent time context
|
||||
await expect(page.getByTitle('Pause incoming real-time data')).toBeHidden();
|
||||
|
||||
// Toggle independent time conductor OFF - Note that the global time conductor is still in Real time mode
|
||||
await page.getByLabel('Disable Independent Time Conductor').click();
|
||||
|
||||
// hover over plot for plot controls
|
||||
await page.getByLabel('Plot Canvas').hover();
|
||||
// Ensure pause control is visible since the global time conductor is in real time mode
|
||||
await expect(page.getByTitle('Pause incoming real-time data')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
@ -0,0 +1,58 @@
|
||||
/*****************************************************************************
|
||||
* Open MCT, Copyright (c) 2014-2025, 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.
|
||||
*****************************************************************************/
|
||||
|
||||
/*
|
||||
* This test suite is dedicated to testing the rendering and interaction of plots.
|
||||
*
|
||||
*/
|
||||
|
||||
import { createDomainObjectWithDefaults } from '../../../../appActions.js';
|
||||
import { expect, test } from '../../../../pluginFixtures.js';
|
||||
|
||||
test.describe('Plot Controls in compact mode', () => {
|
||||
let timeStrip;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Open a browser, navigate to the main page, and wait until all networkevents to resolve
|
||||
await page.goto('./', { waitUntil: 'domcontentloaded' });
|
||||
timeStrip = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Time Strip'
|
||||
});
|
||||
|
||||
// Create an overlay plot with a sine wave generator
|
||||
await createDomainObjectWithDefaults(page, {
|
||||
type: 'Sine Wave Generator',
|
||||
parent: timeStrip.uuid
|
||||
});
|
||||
await page.goto(`${timeStrip.url}`);
|
||||
});
|
||||
|
||||
test('Plots show cursor guides', async ({ page }) => {
|
||||
// hover over plot for plot controls
|
||||
await page.getByLabel('Plot Canvas').hover();
|
||||
// click on cursor guides control
|
||||
await page.getByTitle('Toggle cursor guides').click();
|
||||
await page.getByLabel('Plot Canvas').hover();
|
||||
await expect(page.getByLabel('Vertical cursor guide')).toBeVisible();
|
||||
await expect(page.getByLabel('Horizontal cursor guide')).toBeVisible();
|
||||
});
|
||||
});
|
@ -57,7 +57,7 @@ test.describe('Tabs View', () => {
|
||||
await page.goto(tabsView.url);
|
||||
|
||||
// select first tab
|
||||
await page.getByLabel(`${table.name} tab`, { exact: true }).click();
|
||||
await page.getByLabel(`${table.name} tab - selected`, { exact: true }).click();
|
||||
// ensure table header visible
|
||||
await expect(page.getByRole('searchbox', { name: 'message filter input' })).toBeVisible();
|
||||
|
||||
@ -92,6 +92,38 @@ test.describe('Tabs View', () => {
|
||||
// no canvas (i.e., sine wave generator) in the document should be visible
|
||||
await expect(page.locator('canvas[id=webglContext]')).toBeHidden();
|
||||
});
|
||||
|
||||
test('Changing the displayed tab should not be persisted if the view is locked', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(tabsView.url);
|
||||
//lock the view
|
||||
await page.getByLabel('Unlocked for editing, click to lock.', { exact: true }).click();
|
||||
// get the initial tab index
|
||||
const initialTab = page.getByLabel(/- selected/);
|
||||
// switch to a different tab in the view
|
||||
const swgTab = page.getByLabel(`${sineWaveGenerator.name} tab`, { exact: true });
|
||||
await swgTab.click();
|
||||
await page.getByLabel(`${sineWaveGenerator.name} Object View`).isVisible();
|
||||
// navigate away from the tabbed view and back
|
||||
await page.getByRole('treeitem', { name: 'My Items' }).click();
|
||||
await page.goto(tabsView.url);
|
||||
// check that the initial tab is displayed
|
||||
const lockedSelectedTab = page.getByLabel(/- selected/);
|
||||
await expect(lockedSelectedTab).toHaveText(await initialTab.textContent());
|
||||
|
||||
//unlock the view
|
||||
await page.getByLabel('Locked for editing. Click to unlock.', { exact: true }).click();
|
||||
// switch to a different tab in the view
|
||||
await swgTab.click();
|
||||
await page.getByLabel(`${sineWaveGenerator.name} Object View`).isVisible();
|
||||
// navigate away from the tabbed view and back
|
||||
await page.getByRole('treeitem', { name: 'My Items' }).click();
|
||||
await page.goto(tabsView.url);
|
||||
// check that the newly selected tab is displayed
|
||||
const unlockedSelectedTab = page.getByLabel(/- selected/);
|
||||
await expect(unlockedSelectedTab).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Tabs View CRUD', () => {
|
||||
|
@ -117,7 +117,8 @@ test.describe('Telemetry Table', () => {
|
||||
|
||||
endTimeStamp.setUTCMinutes(endTimeStamp.getUTCMinutes() - 5);
|
||||
const endDate = endTimeStamp.toISOString().split('T')[0];
|
||||
const endTime = endTimeStamp.toISOString().split('T')[1];
|
||||
const milliseconds = endTimeStamp.getMilliseconds();
|
||||
const endTime = endTimeStamp.toISOString().split('T')[1].replace(`.${milliseconds}Z`, '');
|
||||
|
||||
await setTimeConductorBounds(page, { endDate, endTime });
|
||||
|
||||
|
@ -24,65 +24,210 @@ import {
|
||||
setEndOffset,
|
||||
setFixedTimeMode,
|
||||
setRealTimeMode,
|
||||
setStartOffset,
|
||||
setTimeConductorBounds
|
||||
setStartOffset
|
||||
} from '../../../../appActions.js';
|
||||
import { expect, test } from '../../../../pluginFixtures.js';
|
||||
|
||||
test.describe('Time conductor operations', () => {
|
||||
test('validate start time does not exceed end time', async ({ page }) => {
|
||||
const DAY = '2024-01-01';
|
||||
const DAY_AFTER = '2024-01-02';
|
||||
const ONE_O_CLOCK = '01:00:00';
|
||||
const TWO_O_CLOCK = '02:00:00';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Go to baseURL
|
||||
await page.goto('./', { waitUntil: 'domcontentloaded' });
|
||||
const year = new Date().getFullYear();
|
||||
});
|
||||
|
||||
// Set initial valid time bounds
|
||||
const startDate = `${year}-01-01`;
|
||||
const startTime = '01:00:00';
|
||||
const endDate = `${year}-01-01`;
|
||||
const endTime = '02:00:00';
|
||||
await setTimeConductorBounds(page, { startDate, startTime, endDate, endTime });
|
||||
test('validate date and time inputs are validated on input event', async ({ page }) => {
|
||||
const submitButtonLocator = page.getByLabel('Submit time bounds');
|
||||
|
||||
// Open the time conductor popup
|
||||
await page.getByRole('button', { name: 'Time Conductor Mode', exact: true }).click();
|
||||
|
||||
// Test invalid start date
|
||||
const invalidStartDate = `${year}-01-02`;
|
||||
await page.getByLabel('Start date').fill(invalidStartDate);
|
||||
await expect(page.getByLabel('Submit time bounds')).toBeDisabled();
|
||||
await page.getByLabel('Start date').fill(startDate);
|
||||
await expect(page.getByLabel('Submit time bounds')).toBeEnabled();
|
||||
await test.step('invalid start date disables submit button', async () => {
|
||||
const initialStartDate = await page.getByLabel('Start date').inputValue();
|
||||
const invalidStartDate = `${initialStartDate.substring(0, 5)}${initialStartDate.substring(6)}`;
|
||||
|
||||
// Test invalid end date
|
||||
const invalidEndDate = `${year - 1}-12-31`;
|
||||
await page.getByLabel('End date').fill(invalidEndDate);
|
||||
await expect(page.getByLabel('Submit time bounds')).toBeDisabled();
|
||||
await page.getByLabel('End date').fill(endDate);
|
||||
await expect(page.getByLabel('Submit time bounds')).toBeEnabled();
|
||||
await page.getByLabel('Start date').fill(invalidStartDate);
|
||||
await expect(submitButtonLocator).toBeDisabled();
|
||||
await page.getByLabel('Start date').fill(initialStartDate);
|
||||
await expect(submitButtonLocator).toBeEnabled();
|
||||
});
|
||||
|
||||
// Test invalid start time
|
||||
const invalidStartTime = '42:00:00';
|
||||
await page.getByLabel('Start time').fill(invalidStartTime);
|
||||
await expect(page.getByLabel('Submit time bounds')).toBeDisabled();
|
||||
await page.getByLabel('Start time').fill(startTime);
|
||||
await expect(page.getByLabel('Submit time bounds')).toBeEnabled();
|
||||
await test.step('invalid start time disables submit button', async () => {
|
||||
const initialStartTime = await page.getByLabel('Start time').inputValue();
|
||||
const invalidStartTime = `${initialStartTime.substring(0, 5)}${initialStartTime.substring(6)}`;
|
||||
|
||||
// Test invalid end time
|
||||
const invalidEndTime = '43:00:00';
|
||||
await page.getByLabel('End time').fill(invalidEndTime);
|
||||
await expect(page.getByLabel('Submit time bounds')).toBeDisabled();
|
||||
await page.getByLabel('End time').fill(endTime);
|
||||
await expect(page.getByLabel('Submit time bounds')).toBeEnabled();
|
||||
await page.getByLabel('Start time').fill(invalidStartTime);
|
||||
await expect(submitButtonLocator).toBeDisabled();
|
||||
await page.getByLabel('Start time').fill(initialStartTime);
|
||||
await expect(submitButtonLocator).toBeEnabled();
|
||||
});
|
||||
|
||||
// Submit valid time bounds
|
||||
await test.step('disable/enable submit button also works with multiple invalid inputs', async () => {
|
||||
const initialEndDate = await page.getByLabel('End date').inputValue();
|
||||
const invalidEndDate = `${initialEndDate.substring(0, 5)}${initialEndDate.substring(6)}`;
|
||||
const initialStartTime = await page.getByLabel('Start time').inputValue();
|
||||
const invalidStartTime = `${initialStartTime.substring(0, 5)}${initialStartTime.substring(6)}`;
|
||||
|
||||
await page.getByLabel('Start time').fill(invalidStartTime);
|
||||
await expect(submitButtonLocator).toBeDisabled();
|
||||
await page.getByLabel('End date').fill(invalidEndDate);
|
||||
await expect(submitButtonLocator).toBeDisabled();
|
||||
await page.getByLabel('End date').fill(initialEndDate);
|
||||
await expect(submitButtonLocator).toBeDisabled();
|
||||
await page.getByLabel('Start time').fill(initialStartTime);
|
||||
await expect(submitButtonLocator).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
test('validate date and time inputs validation is reported on change event', async ({ page }) => {
|
||||
// Open the time conductor popup
|
||||
await page.getByRole('button', { name: 'Time Conductor Mode', exact: true }).click();
|
||||
|
||||
await test.step('invalid start date is reported on change event, not on input event', async () => {
|
||||
const initialStartDate = await page.getByLabel('Start date').inputValue();
|
||||
const invalidStartDate = `${initialStartDate.substring(0, 5)}${initialStartDate.substring(6)}`;
|
||||
|
||||
await page.getByLabel('Start date').fill(invalidStartDate);
|
||||
await expect(page.getByLabel('Start date')).not.toHaveAttribute('title', 'Invalid Date');
|
||||
await page.getByLabel('Start date').press('Tab');
|
||||
await expect(page.getByLabel('Start date')).toHaveAttribute('title', 'Invalid Date');
|
||||
await page.getByLabel('Start date').fill(initialStartDate);
|
||||
await expect(page.getByLabel('Start date')).not.toHaveAttribute('title', 'Invalid Date');
|
||||
});
|
||||
|
||||
await test.step('invalid start time is reported on change event, not on input event', async () => {
|
||||
const initialStartTime = await page.getByLabel('Start time').inputValue();
|
||||
const invalidStartTime = `${initialStartTime.substring(0, 5)}${initialStartTime.substring(6)}`;
|
||||
|
||||
await page.getByLabel('Start time').fill(invalidStartTime);
|
||||
await expect(page.getByLabel('Start time')).not.toHaveAttribute('title', 'Invalid Time');
|
||||
await page.getByLabel('Start time').press('Tab');
|
||||
await expect(page.getByLabel('Start time')).toHaveAttribute('title', 'Invalid Time');
|
||||
await page.getByLabel('Start time').fill(initialStartTime);
|
||||
await expect(page.getByLabel('Start time')).not.toHaveAttribute('title', 'Invalid Time');
|
||||
});
|
||||
|
||||
await test.step('invalid end date is reported on change event, not on input event', async () => {
|
||||
const initialEndDate = await page.getByLabel('End date').inputValue();
|
||||
const invalidEndDate = `${initialEndDate.substring(0, 5)}${initialEndDate.substring(6)}`;
|
||||
|
||||
await page.getByLabel('End date').fill(invalidEndDate);
|
||||
await expect(page.getByLabel('End date')).not.toHaveAttribute('title', 'Invalid Date');
|
||||
await page.getByLabel('End date').press('Tab');
|
||||
await expect(page.getByLabel('End date')).toHaveAttribute('title', 'Invalid Date');
|
||||
await page.getByLabel('End date').fill(initialEndDate);
|
||||
await expect(page.getByLabel('End date')).not.toHaveAttribute('title', 'Invalid Date');
|
||||
});
|
||||
|
||||
await test.step('invalid end time is reported on change event, not on input event', async () => {
|
||||
const initialEndTime = await page.getByLabel('End time').inputValue();
|
||||
const invalidEndTime = `${initialEndTime.substring(0, 5)}${initialEndTime.substring(6)}`;
|
||||
|
||||
await page.getByLabel('End time').fill(invalidEndTime);
|
||||
await expect(page.getByLabel('End time')).not.toHaveAttribute('title', 'Invalid Time');
|
||||
await page.getByLabel('End time').press('Tab');
|
||||
await expect(page.getByLabel('End time')).toHaveAttribute('title', 'Invalid Time');
|
||||
await page.getByLabel('End time').fill(initialEndTime);
|
||||
await expect(page.getByLabel('End time')).not.toHaveAttribute('title', 'Invalid Time');
|
||||
});
|
||||
});
|
||||
|
||||
test('validate start time does not exceed end time on submit', async ({ page }) => {
|
||||
// Open the time conductor popup
|
||||
await page.getByRole('button', { name: 'Time Conductor Mode', exact: true }).click();
|
||||
|
||||
// FIXME: https://github.com/nasa/openmct/pull/7818
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await page.getByLabel('Start date').fill(DAY);
|
||||
await page.getByLabel('Start time').fill(TWO_O_CLOCK);
|
||||
await page.getByLabel('End date').fill(DAY);
|
||||
await page.getByLabel('End time').fill(ONE_O_CLOCK);
|
||||
await page.getByLabel('Submit time bounds').click();
|
||||
|
||||
// Verify the submitted time bounds
|
||||
await expect(page.getByLabel('Start bounds')).toHaveText(
|
||||
new RegExp(`${startDate} ${startTime}.000Z`)
|
||||
await expect(page.getByLabel('Start date')).toHaveAttribute(
|
||||
'title',
|
||||
'Specified start date exceeds end bound'
|
||||
);
|
||||
await expect(page.getByLabel('End bounds')).toHaveText(
|
||||
new RegExp(`${endDate} ${endTime}.000Z`)
|
||||
await expect(page.getByLabel('Start bounds')).not.toHaveText(`${DAY} ${TWO_O_CLOCK}.000Z`);
|
||||
await expect(page.getByLabel('End bounds')).not.toHaveText(`${DAY} ${ONE_O_CLOCK}.000Z`);
|
||||
|
||||
await page.getByLabel('Start date').fill(DAY);
|
||||
await page.getByLabel('Start time').fill(ONE_O_CLOCK);
|
||||
await page.getByLabel('End date').fill(DAY);
|
||||
await page.getByLabel('End time').fill(TWO_O_CLOCK);
|
||||
await page.getByLabel('Submit time bounds').click();
|
||||
|
||||
await expect(page.getByLabel('Start bounds')).toHaveText(`${DAY} ${ONE_O_CLOCK}.000Z`);
|
||||
await expect(page.getByLabel('End bounds')).toHaveText(`${DAY} ${TWO_O_CLOCK}.000Z`);
|
||||
});
|
||||
|
||||
test('validate start datetime does not exceed end datetime on submit', async ({ page }) => {
|
||||
// Open the time conductor popup
|
||||
await page.getByRole('button', { name: 'Time Conductor Mode', exact: true }).click();
|
||||
|
||||
// FIXME: https://github.com/nasa/openmct/pull/7818
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await page.getByLabel('Start date').fill(DAY_AFTER);
|
||||
await page.getByLabel('Start time').fill(ONE_O_CLOCK);
|
||||
await page.getByLabel('End date').fill(DAY);
|
||||
await page.getByLabel('End time').fill(ONE_O_CLOCK);
|
||||
await page.getByLabel('Submit time bounds').click();
|
||||
|
||||
await expect(page.getByLabel('Start date')).toHaveAttribute(
|
||||
'title',
|
||||
'Specified start date exceeds end bound'
|
||||
);
|
||||
await expect(page.getByLabel('Start bounds')).not.toHaveText(
|
||||
`${DAY_AFTER} ${ONE_O_CLOCK}.000Z`
|
||||
);
|
||||
await expect(page.getByLabel('End bounds')).not.toHaveText(`${DAY} ${ONE_O_CLOCK}.000Z`);
|
||||
|
||||
await page.getByLabel('Start date').fill(DAY);
|
||||
await page.getByLabel('Start time').fill(ONE_O_CLOCK);
|
||||
await page.getByLabel('End date').fill(DAY_AFTER);
|
||||
await page.getByLabel('End time').fill(ONE_O_CLOCK);
|
||||
await page.getByLabel('Submit time bounds').click();
|
||||
|
||||
await expect(page.getByLabel('Start bounds')).toHaveText(`${DAY} ${ONE_O_CLOCK}.000Z`);
|
||||
await expect(page.getByLabel('End bounds')).toHaveText(`${DAY_AFTER} ${ONE_O_CLOCK}.000Z`);
|
||||
});
|
||||
|
||||
test('cancelling form does not set bounds', async ({ page }) => {
|
||||
test.info().annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/nasa/openmct/issues/7791'
|
||||
});
|
||||
|
||||
// Open the time conductor popup
|
||||
await page.getByRole('button', { name: 'Time Conductor Mode', exact: true }).click();
|
||||
|
||||
await page.getByLabel('Start date').fill(DAY);
|
||||
await page.getByLabel('Start time').fill(ONE_O_CLOCK);
|
||||
await page.getByLabel('End date').fill(DAY_AFTER);
|
||||
await page.getByLabel('End time').fill(ONE_O_CLOCK);
|
||||
await page.getByLabel('Discard changes and close time popup').click();
|
||||
|
||||
await expect(page.getByLabel('Start bounds')).not.toHaveText(`${DAY} ${ONE_O_CLOCK}.000Z`);
|
||||
await expect(page.getByLabel('End bounds')).not.toHaveText(`${DAY_AFTER} ${ONE_O_CLOCK}.000Z`);
|
||||
|
||||
// Open the time conductor popup
|
||||
await page.getByRole('button', { name: 'Time Conductor Mode', exact: true }).click();
|
||||
|
||||
await page.getByLabel('Start date').fill(DAY);
|
||||
await page.getByLabel('Start time').fill(ONE_O_CLOCK);
|
||||
await page.getByLabel('End date').fill(DAY_AFTER);
|
||||
await page.getByLabel('End time').fill(ONE_O_CLOCK);
|
||||
await page.getByLabel('Submit time bounds').click();
|
||||
|
||||
await expect(page.getByLabel('Start bounds')).toHaveText(`${DAY} ${ONE_O_CLOCK}.000Z`);
|
||||
await expect(page.getByLabel('End bounds')).toHaveText(`${DAY_AFTER} ${ONE_O_CLOCK}.000Z`);
|
||||
});
|
||||
});
|
||||
|
||||
@ -131,77 +276,6 @@ test.describe('Global Time Conductor', () => {
|
||||
await expect(page.getByLabel('End offset: 01:30:31')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Input field validation: fixed time mode', async ({ page }) => {
|
||||
test.info().annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/nasa/openmct/issues/7791'
|
||||
});
|
||||
// Switch to fixed time mode
|
||||
await setFixedTimeMode(page);
|
||||
|
||||
// Define valid time bounds for testing
|
||||
const validBounds = {
|
||||
startDate: '2024-04-20',
|
||||
startTime: '00:04:20',
|
||||
endDate: '2024-04-20',
|
||||
endTime: '16:04:20'
|
||||
};
|
||||
// Set valid time conductor bounds ✌️
|
||||
await setTimeConductorBounds(page, validBounds);
|
||||
|
||||
// Verify that the time bounds are set correctly
|
||||
await expect(page.getByLabel(`Start bounds: 2024-04-20 00:04:20.000Z`)).toBeVisible();
|
||||
await expect(page.getByLabel(`End bounds: 2024-04-20 16:04:20.000Z`)).toBeVisible();
|
||||
|
||||
// Open the Time Conductor Mode popup
|
||||
await page.getByLabel('Time Conductor Mode').click();
|
||||
|
||||
// Test invalid start date
|
||||
const invalidStartDate = '2024-04-21';
|
||||
await page.getByLabel('Start date').fill(invalidStartDate);
|
||||
await expect(page.getByLabel('Submit time bounds')).toBeDisabled();
|
||||
await page.getByLabel('Start date').fill(validBounds.startDate);
|
||||
await expect(page.getByLabel('Submit time bounds')).toBeEnabled();
|
||||
|
||||
// Test invalid end date
|
||||
const invalidEndDate = '2024-04-19';
|
||||
await page.getByLabel('End date').fill(invalidEndDate);
|
||||
await expect(page.getByLabel('Submit time bounds')).toBeDisabled();
|
||||
await page.getByLabel('End date').fill(validBounds.endDate);
|
||||
await expect(page.getByLabel('Submit time bounds')).toBeEnabled();
|
||||
|
||||
// Test invalid start time
|
||||
const invalidStartTime = '16:04:21';
|
||||
await page.getByLabel('Start time').fill(invalidStartTime);
|
||||
await expect(page.getByLabel('Submit time bounds')).toBeDisabled();
|
||||
await page.getByLabel('Start time').fill(validBounds.startTime);
|
||||
await expect(page.getByLabel('Submit time bounds')).toBeEnabled();
|
||||
|
||||
// Test invalid end time
|
||||
const invalidEndTime = '00:04:19';
|
||||
await page.getByLabel('End time').fill(invalidEndTime);
|
||||
await expect(page.getByLabel('Submit time bounds')).toBeDisabled();
|
||||
await page.getByLabel('End time').fill(validBounds.endTime);
|
||||
await expect(page.getByLabel('Submit time bounds')).toBeEnabled();
|
||||
|
||||
// Verify that the time bounds remain unchanged after invalid inputs
|
||||
await expect(page.getByLabel(`Start bounds: 2024-04-20 00:04:20.000Z`)).toBeVisible();
|
||||
await expect(page.getByLabel(`End bounds: 2024-04-20 16:04:20.000Z`)).toBeVisible();
|
||||
|
||||
// Discard changes and verify that bounds remain unchanged
|
||||
await setTimeConductorBounds(page, {
|
||||
startDate: validBounds.startDate,
|
||||
startTime: '04:20:00',
|
||||
endDate: validBounds.endDate,
|
||||
endTime: '04:20:20',
|
||||
submitChanges: false
|
||||
});
|
||||
|
||||
// Verify that the original time bounds are still displayed after discarding changes
|
||||
await expect(page.getByLabel(`Start bounds: 2024-04-20 00:04:20.000Z`)).toBeVisible();
|
||||
await expect(page.getByLabel(`End bounds: 2024-04-20 16:04:20.000Z`)).toBeVisible();
|
||||
});
|
||||
|
||||
/**
|
||||
* Verify that offsets and url params are preserved when switching
|
||||
* between fixed timespan and real-time mode.
|
||||
|
@ -31,6 +31,8 @@ import { expect, test } from '../../pluginFixtures.js';
|
||||
test.describe('Grand Search', () => {
|
||||
let grandSearchInput;
|
||||
|
||||
test.use({ ignore404s: [/_design\/object_names\/_view\/object_names$/] });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
grandSearchInput = page
|
||||
.getByLabel('OpenMCT Search')
|
||||
@ -191,7 +193,88 @@ test.describe('Grand Search', () => {
|
||||
await expect(searchResults).toContainText(folderName);
|
||||
});
|
||||
|
||||
test.describe('Search will test for the presence of the object_names index, and', () => {
|
||||
test('use index if available @couchdb @network', async ({ page }) => {
|
||||
await createObjectsForSearch(page);
|
||||
|
||||
let isObjectNamesViewAvailable = false;
|
||||
let isObjectNamesUsedForSearch = false;
|
||||
|
||||
page.on('request', async (request) => {
|
||||
const isObjectNamesRequest = request.url().endsWith('_view/object_names');
|
||||
const isHeadRequest = request.method().toLowerCase() === 'head';
|
||||
|
||||
if (isObjectNamesRequest && isHeadRequest) {
|
||||
const response = await request.response();
|
||||
isObjectNamesViewAvailable = response.status() === 200;
|
||||
}
|
||||
});
|
||||
|
||||
page.on('request', (request) => {
|
||||
const isObjectNamesRequest = request.url().endsWith('_view/object_names');
|
||||
const isPostRequest = request.method().toLowerCase() === 'post';
|
||||
|
||||
if (isObjectNamesRequest && isPostRequest) {
|
||||
isObjectNamesUsedForSearch = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Full search for object
|
||||
await grandSearchInput.pressSequentially('Clock', { delay: 100 });
|
||||
|
||||
// Wait for search to finish
|
||||
await waitForSearchCompletion(page);
|
||||
|
||||
expect(isObjectNamesViewAvailable).toBe(true);
|
||||
expect(isObjectNamesUsedForSearch).toBe(true);
|
||||
});
|
||||
|
||||
test('fall-back on base index if index not available @couchdb @network', async ({ page }) => {
|
||||
await page.route('**/_view/object_names', (route) => {
|
||||
route.fulfill({
|
||||
status: 404
|
||||
});
|
||||
});
|
||||
await createObjectsForSearch(page);
|
||||
|
||||
let isObjectNamesViewAvailable = false;
|
||||
let isFindUsedForSearch = false;
|
||||
|
||||
page.on('request', async (request) => {
|
||||
const isObjectNamesRequest = request.url().endsWith('_view/object_names');
|
||||
const isHeadRequest = request.method().toLowerCase() === 'head';
|
||||
|
||||
if (isObjectNamesRequest && isHeadRequest) {
|
||||
const response = await request.response();
|
||||
isObjectNamesViewAvailable = response.status() === 200;
|
||||
}
|
||||
});
|
||||
|
||||
page.on('request', (request) => {
|
||||
const isFindRequest = request.url().endsWith('_find');
|
||||
const isPostRequest = request.method().toLowerCase() === 'post';
|
||||
|
||||
if (isFindRequest && isPostRequest) {
|
||||
isFindUsedForSearch = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Full search for object
|
||||
await grandSearchInput.pressSequentially('Clock', { delay: 100 });
|
||||
|
||||
// Wait for search to finish
|
||||
await waitForSearchCompletion(page);
|
||||
console.info(
|
||||
`isObjectNamesViewAvailable: ${isObjectNamesViewAvailable} | isFindUsedForSearch: ${isFindUsedForSearch}`
|
||||
);
|
||||
expect(isObjectNamesViewAvailable).toBe(false);
|
||||
expect(isFindUsedForSearch).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('Search results are debounced @couchdb @network', async ({ page }) => {
|
||||
// Unfortunately 404s are always logged to the JavaScript console and can't be suppressed
|
||||
// A 404 is now thrown when we test for the presence of the object names view used by search.
|
||||
test.info().annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/nasa/openmct/issues/6179'
|
||||
@ -199,11 +282,17 @@ test.describe('Grand Search', () => {
|
||||
await createObjectsForSearch(page);
|
||||
|
||||
let networkRequests = [];
|
||||
|
||||
page.on('request', (request) => {
|
||||
const searchRequest =
|
||||
request.url().endsWith('_find') || request.url().includes('by_keystring');
|
||||
const fetchRequest = request.resourceType() === 'fetch';
|
||||
if (searchRequest && fetchRequest) {
|
||||
const isSearchRequest =
|
||||
request.url().endsWith('object_names') ||
|
||||
request.url().endsWith('_find') ||
|
||||
request.url().includes('by_keystring');
|
||||
const isFetchRequest = request.resourceType() === 'fetch';
|
||||
// CouchDB search results in a one-time head request to test for the presence of an index.
|
||||
const isHeadRequest = request.method().toLowerCase() === 'head';
|
||||
|
||||
if (isSearchRequest && isFetchRequest && !isHeadRequest) {
|
||||
networkRequests.push(request);
|
||||
}
|
||||
});
|
||||
|
@ -213,7 +213,6 @@ test.describe('Navigation memory leak is not detected in', () => {
|
||||
page,
|
||||
'example-imagery-memory-leak-test'
|
||||
);
|
||||
|
||||
// If we got here without timing out, then the root view object was garbage collected and no memory leak was detected.
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
@ -317,6 +316,12 @@ test.describe('Navigation memory leak is not detected in', () => {
|
||||
|
||||
// Manually invoke the garbage collector once all references are removed.
|
||||
window.gc();
|
||||
window.gc();
|
||||
window.gc();
|
||||
|
||||
setTimeout(() => {
|
||||
window.gc();
|
||||
}, 1000);
|
||||
|
||||
return gcPromise;
|
||||
});
|
||||
|
@ -64,7 +64,7 @@ test.describe('Tabs View', () => {
|
||||
page.goto(tabsView.url);
|
||||
|
||||
// select first tab
|
||||
await page.getByLabel(`${table.name} tab`, { exact: true }).click();
|
||||
await page.getByLabel(`${table.name} tab - selected`, { exact: true }).click();
|
||||
// ensure table header visible
|
||||
await expect(page.getByRole('searchbox', { name: 'message filter input' })).toBeVisible();
|
||||
|
||||
|
@ -26,14 +26,25 @@ import fs from 'fs';
|
||||
import { createDomainObjectWithDefaults, createPlanFromJSON } from '../../appActions.js';
|
||||
import { scanForA11yViolations, test } from '../../avpFixtures.js';
|
||||
import { VISUAL_FIXED_URL } from '../../constants.js';
|
||||
import { setBoundsToSpanAllActivities, setDraftStatusForPlan } from '../../helper/planningUtils.js';
|
||||
import {
|
||||
getFirstActivity,
|
||||
setBoundsToSpanAllActivities,
|
||||
setDraftStatusForPlan
|
||||
} from '../../helper/planningUtils.js';
|
||||
|
||||
const examplePlanSmall2 = JSON.parse(
|
||||
fs.readFileSync(new URL('../../test-data/examplePlans/ExamplePlan_Small2.json', import.meta.url))
|
||||
);
|
||||
|
||||
const FIRST_ACTIVITY_SMALL_2 = getFirstActivity(examplePlanSmall2);
|
||||
|
||||
test.describe('Visual - Gantt Chart @a11y', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Set the clock to the end of the first activity in the plan
|
||||
// This is so we can see the "now" line in the plan view
|
||||
await page.clock.install({ time: FIRST_ACTIVITY_SMALL_2.end + 10000 });
|
||||
await page.clock.resume();
|
||||
|
||||
await page.goto(VISUAL_FIXED_URL, { waitUntil: 'domcontentloaded' });
|
||||
});
|
||||
test('Gantt Chart View', async ({ page, theme }) => {
|
||||
|
@ -27,14 +27,21 @@ import { createDomainObjectWithDefaults, createPlanFromJSON } from '../../appAct
|
||||
import { scanForA11yViolations, test } from '../../avpFixtures.js';
|
||||
import { waitForAnimations } from '../../baseFixtures.js';
|
||||
import { VISUAL_FIXED_URL } from '../../constants.js';
|
||||
import { setBoundsToSpanAllActivities } from '../../helper/planningUtils.js';
|
||||
import { getFirstActivity, setBoundsToSpanAllActivities } from '../../helper/planningUtils.js';
|
||||
|
||||
const examplePlanSmall2 = JSON.parse(
|
||||
fs.readFileSync(new URL('../../test-data/examplePlans/ExamplePlan_Small2.json', import.meta.url))
|
||||
);
|
||||
|
||||
const FIRST_ACTIVITY_SMALL_2 = getFirstActivity(examplePlanSmall2);
|
||||
|
||||
test.describe('Visual - Time Strip @a11y', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Set the clock to the end of the first activity in the plan
|
||||
// This is so we can see the "now" line in the plan view
|
||||
await page.clock.install({ time: FIRST_ACTIVITY_SMALL_2.end + 10000 });
|
||||
await page.clock.resume();
|
||||
|
||||
await page.goto(VISUAL_FIXED_URL, { waitUntil: 'domcontentloaded' });
|
||||
});
|
||||
test('Time Strip View', async ({ page, theme }) => {
|
||||
|
@ -42,6 +42,7 @@ const examplePlanSmall2 = JSON.parse(
|
||||
);
|
||||
|
||||
const FIRST_ACTIVITY_SMALL_1 = getFirstActivity(examplePlanSmall1);
|
||||
const FIRST_ACTIVITY_SMALL_2 = getFirstActivity(examplePlanSmall2);
|
||||
|
||||
test.describe('Visual - Timelist progress bar @clock @a11y', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
@ -59,6 +60,11 @@ test.describe('Visual - Timelist progress bar @clock @a11y', () => {
|
||||
|
||||
test.describe('Visual - Plan View @a11y', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Set the clock to the end of the first activity in the plan
|
||||
// This is so we can see the "now" line in the plan view
|
||||
await page.clock.install({ time: FIRST_ACTIVITY_SMALL_2.end + 10000 });
|
||||
await page.clock.resume();
|
||||
|
||||
await page.goto(VISUAL_FIXED_URL, { waitUntil: 'domcontentloaded' });
|
||||
});
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
@ -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());
|
||||
};
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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);
|
||||
};
|
||||
|
@ -20,6 +20,8 @@
|
||||
* at runtime from the About dialog for additional information.
|
||||
*****************************************************************************/
|
||||
|
||||
import { seededRandom } from 'utils/random.js';
|
||||
|
||||
const DEFAULT_IMAGE_SAMPLES = [
|
||||
'https://www.nasa.gov/wp-content/uploads/static/history/alsj/a16/AS16-117-18731.jpg',
|
||||
'https://www.nasa.gov/wp-content/uploads/static/history/alsj/a16/AS16-117-18732.jpg',
|
||||
@ -162,8 +164,8 @@ export default function () {
|
||||
};
|
||||
}
|
||||
|
||||
function getCompassValues(min, max) {
|
||||
return min + Math.random() * (max - min);
|
||||
function getCompassValues(min, max, timestamp) {
|
||||
return min + seededRandom(timestamp) * (max - min);
|
||||
}
|
||||
|
||||
function getImageSamples(configuration) {
|
||||
@ -283,9 +285,9 @@ function pointForTimestamp(timestamp, name, imageSamples, delay) {
|
||||
utc: Math.floor(timestamp / delay) * delay,
|
||||
local: Math.floor(timestamp / delay) * delay,
|
||||
url,
|
||||
sunOrientation: getCompassValues(0, 360),
|
||||
cameraAzimuth: getCompassValues(0, 360),
|
||||
heading: getCompassValues(0, 360),
|
||||
sunOrientation: getCompassValues(0, 360, timestamp),
|
||||
cameraAzimuth: getCompassValues(0, 360, timestamp),
|
||||
heading: getCompassValues(0, 360, timestamp),
|
||||
transformations: navCamTransformations,
|
||||
imageDownloadName
|
||||
};
|
||||
|
1344
package-lock.json
generated
1344
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
35
package.json
35
package.json
@ -16,25 +16,25 @@
|
||||
],
|
||||
"devDependencies": {
|
||||
"@babel/eslint-parser": "7.23.3",
|
||||
"@braintree/sanitize-url": "6.0.4",
|
||||
"@braintree/sanitize-url": "7.1.1",
|
||||
"@types/d3-axis": "3.0.6",
|
||||
"@types/d3-scale": "4.0.8",
|
||||
"@types/d3-selection": "3.0.10",
|
||||
"@types/d3-shape": "3.0.0",
|
||||
"@types/d3-shape": "3.1.7",
|
||||
"@types/eventemitter3": "1.2.0",
|
||||
"@types/jasmine": "5.1.2",
|
||||
"@types/lodash": "4.17.0",
|
||||
"@vue/compiler-sfc": "3.4.3",
|
||||
"babel-loader": "9.1.0",
|
||||
"babel-plugin-istanbul": "6.1.1",
|
||||
"babel-plugin-istanbul": "7.0.0",
|
||||
"comma-separated-values": "3.6.4",
|
||||
"copy-webpack-plugin": "12.0.2",
|
||||
"copy-webpack-plugin": "13.0.0",
|
||||
"cspell": "7.3.8",
|
||||
"css-loader": "6.10.0",
|
||||
"d3-axis": "3.0.0",
|
||||
"d3-scale": "4.0.2",
|
||||
"d3-selection": "3.0.0",
|
||||
"d3-shape": "3.0.0",
|
||||
"d3-shape": "3.2.0",
|
||||
"eslint": "8.56.0",
|
||||
"eslint-config-prettier": "9.1.0",
|
||||
"eslint-plugin-compat": "4.2.0",
|
||||
@ -51,7 +51,7 @@
|
||||
"git-rev-sync": "3.0.2",
|
||||
"html2canvas": "1.4.1",
|
||||
"imports-loader": "5.0.0",
|
||||
"jasmine-core": "5.1.1",
|
||||
"jasmine-core": "5.6.0",
|
||||
"karma": "6.4.2",
|
||||
"karma-chrome-launcher": "3.2.0",
|
||||
"karma-cli": "2.0.0",
|
||||
@ -64,13 +64,14 @@
|
||||
"karma-webpack": "5.0.1",
|
||||
"location-bar": "3.0.1",
|
||||
"lodash": "4.17.21",
|
||||
"marked": "12.0.0",
|
||||
"mini-css-extract-plugin": "2.7.6",
|
||||
"marked": "15.0.7",
|
||||
"mini-css-extract-plugin": "2.9.2",
|
||||
"moment": "2.30.1",
|
||||
"moment-duration-format": "2.3.2",
|
||||
"moment-timezone": "0.5.41",
|
||||
"npm-run-all2": "6.1.2",
|
||||
"nyc": "15.1.0",
|
||||
"nano": "10.1.4",
|
||||
"npm-run-all2": "7.0.2",
|
||||
"nyc": "17.1.0",
|
||||
"painterro": "1.2.87",
|
||||
"plotly.js-basic-dist-min": "2.29.1",
|
||||
"plotly.js-gl2d-dist-min": "2.20.0",
|
||||
@ -78,21 +79,21 @@
|
||||
"prettier-eslint": "16.3.0",
|
||||
"printj": "1.3.1",
|
||||
"resolve-url-loader": "5.0.0",
|
||||
"sanitize-html": "2.12.1",
|
||||
"sanitize-html": "2.15.0",
|
||||
"sass": "1.71.1",
|
||||
"sass-loader": "14.1.1",
|
||||
"style-loader": "3.3.3",
|
||||
"style-loader": "4.0.0",
|
||||
"terser-webpack-plugin": "5.3.9",
|
||||
"tiny-emitter": "2.1.0",
|
||||
"typescript": "5.3.3",
|
||||
"uuid": "9.0.1",
|
||||
"uuid": "11.1.0",
|
||||
"vue": "3.4.24",
|
||||
"vue-eslint-parser": "9.4.2",
|
||||
"vue-loader": "16.8.3",
|
||||
"webpack": "5.90.3",
|
||||
"webpack": "5.98.0",
|
||||
"webpack-cli": "5.1.1",
|
||||
"webpack-dev-server": "5.0.2",
|
||||
"webpack-merge": "5.10.0"
|
||||
"webpack-merge": "6.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf ./dist ./node_modules ./coverage ./html-test-results ./e2e/test-results ./.nyc_output ./e2e/.nyc_output",
|
||||
@ -138,7 +139,7 @@
|
||||
"url": "git+https://github.com/nasa/openmct.git"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.14.2 <22"
|
||||
"node": ">=18.14.2 <23"
|
||||
},
|
||||
"browserslist": [
|
||||
"Firefox ESR",
|
||||
@ -156,4 +157,4 @@
|
||||
"keywords": [
|
||||
"nasa"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
@ -20,6 +20,8 @@
|
||||
* at runtime from the About dialog for additional information.
|
||||
*****************************************************************************/
|
||||
|
||||
import { isIdentifier } from '../objects/object-utils';
|
||||
|
||||
/**
|
||||
* @typedef {import('openmct').DomainObject} DomainObject
|
||||
*/
|
||||
@ -209,9 +211,15 @@ export default class CompositionCollection {
|
||||
this.#cleanUpMutables();
|
||||
const children = await this.#provider.load(this.domainObject);
|
||||
const childObjects = await Promise.all(
|
||||
children.map((c) => this.#publicAPI.objects.get(c, abortSignal))
|
||||
children.map((child) => {
|
||||
if (isIdentifier(child)) {
|
||||
return this.#publicAPI.objects.get(child, abortSignal);
|
||||
} else {
|
||||
return Promise.resolve(child);
|
||||
}
|
||||
})
|
||||
);
|
||||
childObjects.forEach((c) => this.add(c, true));
|
||||
childObjects.forEach((child) => this.add(child, true));
|
||||
this.#emit('load');
|
||||
|
||||
return childObjects;
|
||||
|
@ -96,8 +96,9 @@ export default class CompositionProvider {
|
||||
* object.
|
||||
* @param {DomainObject} domainObject the domain object
|
||||
* for which to load composition
|
||||
* @returns {Promise<Identifier[]>} a promise for
|
||||
* the Identifiers in this composition
|
||||
* @returns {Promise<Identifier[] | DomainObject[]>} a promise for
|
||||
* the Identifiers or Domain Objects in this composition. If Identifiers are returned,
|
||||
* they will be automatically resolved to domain objects by the API.
|
||||
*/
|
||||
load(domainObject) {
|
||||
throw new Error('This method must be implemented by a subclass.');
|
||||
|
@ -21,7 +21,7 @@
|
||||
*****************************************************************************/
|
||||
import { toRaw } from 'vue';
|
||||
|
||||
import { makeKeyString } from '../objects/object-utils.js';
|
||||
import { makeKeyString, parseKeyString } from '../objects/object-utils.js';
|
||||
import CompositionProvider from './CompositionProvider.js';
|
||||
|
||||
/**
|
||||
@ -75,7 +75,11 @@ export default class DefaultCompositionProvider extends CompositionProvider {
|
||||
* the Identifiers in this composition
|
||||
*/
|
||||
load(domainObject) {
|
||||
return Promise.all(domainObject.composition);
|
||||
const identifiers = domainObject.composition
|
||||
.filter((idOrKeystring) => idOrKeystring !== null && idOrKeystring !== undefined)
|
||||
.map((idOrKeystring) => parseKeyString(idOrKeystring));
|
||||
|
||||
return Promise.all(identifiers);
|
||||
}
|
||||
/**
|
||||
* Attach listeners for changes to the composition of a given domain object.
|
||||
|
@ -35,7 +35,7 @@ export const DEFAULT_SHELVE_DURATIONS = [
|
||||
value: 900000
|
||||
},
|
||||
{
|
||||
name: 'Indefinite',
|
||||
name: 'Unlimited',
|
||||
value: null
|
||||
}
|
||||
];
|
||||
@ -136,17 +136,21 @@ export default class FaultManagementAPI {
|
||||
/**
|
||||
* Retrieves the available shelve durations from the provider, or the default durations if the
|
||||
* provider does not provide any.
|
||||
* @returns {ShelveDuration[]}
|
||||
* @returns {ShelveDuration[] | undefined}
|
||||
*/
|
||||
getShelveDurations() {
|
||||
return this.provider?.getShelveDurations() ?? DEFAULT_SHELVE_DURATIONS;
|
||||
if (!this.provider) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.provider.getShelveDurations?.() ?? DEFAULT_SHELVE_DURATIONS;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} ShelveDuration
|
||||
* @property {string} name - The name of the shelve duration
|
||||
* @property {number|null} value - The value of the shelve duration in milliseconds, or null for indefinite
|
||||
* @property {number|null} value - The value of the shelve duration in milliseconds, or null for unlimited
|
||||
*/
|
||||
|
||||
/**
|
||||
|
@ -27,6 +27,7 @@ import ConflictError from './ConflictError.js';
|
||||
import InMemorySearchProvider from './InMemorySearchProvider.js';
|
||||
import InterceptorRegistry from './InterceptorRegistry.js';
|
||||
import MutableDomainObject from './MutableDomainObject.js';
|
||||
import { isIdentifier, isKeyString } from './object-utils.js';
|
||||
import RootObjectProvider from './RootObjectProvider.js';
|
||||
import RootRegistry from './RootRegistry.js';
|
||||
import Transaction from './Transaction.js';
|
||||
@ -742,11 +743,19 @@ export default class ObjectAPI {
|
||||
* @param {AbortSignal} abortSignal (optional) signal to abort fetch requests
|
||||
* @returns {Promise<Array<DomainObject>>} a promise containing an array of domain objects
|
||||
*/
|
||||
async getOriginalPath(identifier, path = [], abortSignal = null) {
|
||||
const domainObject = await this.get(identifier, abortSignal);
|
||||
async getOriginalPath(identifierOrObject, path = [], abortSignal = null) {
|
||||
let domainObject;
|
||||
|
||||
if (isKeyString(identifierOrObject) || isIdentifier(identifierOrObject)) {
|
||||
domainObject = await this.get(identifierOrObject, abortSignal);
|
||||
} else {
|
||||
domainObject = identifierOrObject;
|
||||
}
|
||||
|
||||
if (!domainObject) {
|
||||
return [];
|
||||
}
|
||||
|
||||
path.push(domainObject);
|
||||
const { location } = domainObject;
|
||||
if (location && !this.#pathContainsDomainObject(location, path)) {
|
||||
|
@ -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) {
|
||||
|
@ -359,6 +359,18 @@ class IndependentTimeContext extends TimeContext {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
* @override
|
||||
*/
|
||||
isFixed() {
|
||||
if (this.upstreamTimeContext) {
|
||||
return this.upstreamTimeContext.isFixed(...arguments);
|
||||
} else {
|
||||
return super.isFixed(...arguments);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {number}
|
||||
* @override
|
||||
@ -400,7 +412,7 @@ class IndependentTimeContext extends TimeContext {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the time context to the global time context
|
||||
* Reset the time context from the global time context
|
||||
*/
|
||||
resetContext() {
|
||||
if (this.upstreamTimeContext) {
|
||||
@ -428,6 +440,10 @@ class IndependentTimeContext extends TimeContext {
|
||||
// Emit bounds so that views that are changing context get the upstream bounds
|
||||
this.emit('bounds', this.getBounds());
|
||||
this.emit(TIME_CONTEXT_EVENTS.boundsChanged, this.getBounds());
|
||||
// Also emit the mode in case it's different from previous time context
|
||||
if (this.getMode()) {
|
||||
this.emit(TIME_CONTEXT_EVENTS.modeChanged, this.#copy(this.getMode()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -502,6 +518,10 @@ class IndependentTimeContext extends TimeContext {
|
||||
// Emit bounds so that views that are changing context get the upstream bounds
|
||||
this.emit('bounds', this.getBounds());
|
||||
this.emit(TIME_CONTEXT_EVENTS.boundsChanged, this.getBounds());
|
||||
// Also emit the mode in case it's different from the global time context
|
||||
if (this.getMode()) {
|
||||
this.emit(TIME_CONTEXT_EVENTS.modeChanged, this.#copy(this.getMode()));
|
||||
}
|
||||
// now that the view's context is set, tell others to check theirs in case they were following this view's context.
|
||||
this.globalTimeContext.emit('refreshContext', viewKey);
|
||||
}
|
||||
|
@ -23,6 +23,7 @@
|
||||
import { FIXED_MODE_KEY, REALTIME_MODE_KEY } from '@/api/time/constants';
|
||||
import IndependentTimeContext from '@/api/time/IndependentTimeContext';
|
||||
|
||||
import { TIME_CONTEXT_EVENTS } from './constants';
|
||||
import GlobalTimeContext from './GlobalTimeContext.js';
|
||||
|
||||
/**
|
||||
@ -142,7 +143,7 @@ class TimeAPI extends GlobalTimeContext {
|
||||
addIndependentContext(key, value, clockKey) {
|
||||
let timeContext = this.getIndependentContext(key);
|
||||
|
||||
//stop following upstream time context since the view has it's own
|
||||
//stop following upstream time context since the view has its own
|
||||
timeContext.resetContext();
|
||||
|
||||
if (clockKey) {
|
||||
@ -152,6 +153,9 @@ class TimeAPI extends GlobalTimeContext {
|
||||
timeContext.setMode(FIXED_MODE_KEY, value);
|
||||
}
|
||||
|
||||
// Also emit the mode in case it's different from the previous time context
|
||||
timeContext.emit(TIME_CONTEXT_EVENTS.modeChanged, structuredClone(timeContext.getMode()));
|
||||
|
||||
// Notify any nested views to update, pass in the viewKey so that particular view can skip getting an upstream context
|
||||
this.emit('refreshContext', key);
|
||||
|
||||
|
@ -24,6 +24,9 @@ export default function (folderName, couchPlugin, searchFilter) {
|
||||
location: 'ROOT'
|
||||
});
|
||||
}
|
||||
},
|
||||
search() {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
});
|
||||
|
||||
@ -35,9 +38,17 @@ export default function (folderName, couchPlugin, searchFilter) {
|
||||
);
|
||||
},
|
||||
load() {
|
||||
return couchProvider.getObjectsByFilter(searchFilter).then((objects) => {
|
||||
return objects.map((object) => object.identifier);
|
||||
});
|
||||
let searchResults;
|
||||
|
||||
if (searchFilter.viewName !== undefined) {
|
||||
// Use a view to search, instead of an _all_docs find
|
||||
searchResults = couchProvider.getObjectsByView(searchFilter);
|
||||
} else {
|
||||
// Use the _find endpoint to search _all_docs
|
||||
searchResults = couchProvider.getObjectsByFilter(searchFilter);
|
||||
}
|
||||
|
||||
return searchResults;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
@ -332,7 +332,11 @@ export default {
|
||||
this.domainObject.configuration.axes.xKey === undefined ||
|
||||
this.domainObject.configuration.axes.yKey === undefined
|
||||
) {
|
||||
return;
|
||||
const { xKey, yKey } = this.identifyAxesKeys(axisMetadata);
|
||||
this.openmct.objects.mutate(this.domainObject, 'configuration.axes', {
|
||||
xKey,
|
||||
yKey
|
||||
});
|
||||
}
|
||||
|
||||
let xValues = [];
|
||||
@ -431,6 +435,30 @@ export default {
|
||||
subscribeToAll() {
|
||||
const telemetryObjects = Object.values(this.telemetryObjects);
|
||||
telemetryObjects.forEach(this.subscribeToObject);
|
||||
},
|
||||
identifyAxesKeys(metadata) {
|
||||
const { xAxisMetadata, yAxisMetadata } = metadata;
|
||||
|
||||
let xKey;
|
||||
let yKey;
|
||||
|
||||
// If xAxisMetadata contains array values, use the first one for xKey
|
||||
const arrayValues = xAxisMetadata.filter((metaDatum) => metaDatum.isArrayValue);
|
||||
const nonArrayValues = xAxisMetadata.filter((metaDatum) => !metaDatum.isArrayValue);
|
||||
|
||||
if (arrayValues.length > 0) {
|
||||
xKey = arrayValues[0].key;
|
||||
yKey = arrayValues.length > 1 ? arrayValues[1].key : yAxisMetadata.key;
|
||||
} else if (nonArrayValues.length > 0) {
|
||||
xKey = nonArrayValues[0].key;
|
||||
yKey = 'none';
|
||||
} else {
|
||||
// Fallback if no valid xKey or yKey is found
|
||||
xKey = 'none';
|
||||
yKey = 'none';
|
||||
}
|
||||
|
||||
return { xKey, yKey };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -160,8 +160,10 @@
|
||||
</div>
|
||||
</template>
|
||||
<div class="c-cdef__separator c-row-separator"></div>
|
||||
<div class="c-cdef__controls" :disabled="!telemetry.length">
|
||||
<div class="c-cdef__controls">
|
||||
<button
|
||||
:disabled="!telemetry.length"
|
||||
:aria-label="`Add Criteria - ${!telemetry.length ? 'Disabled' : 'Enabled'}`"
|
||||
class="c-cdef__add-criteria-button c-button c-button--labeled icon-plus"
|
||||
@click="addCriteria"
|
||||
>
|
||||
|
@ -28,11 +28,7 @@
|
||||
{ 'is-style-invisible': styleItem.style && styleItem.style.isStyleInvisible },
|
||||
{ 'c-style-thumb--mixed': mixedStyles.indexOf('backgroundColor') > -1 }
|
||||
]"
|
||||
:style="[
|
||||
styleItem.style.imageUrl
|
||||
? { backgroundImage: 'url(' + styleItem.style.imageUrl + ')' }
|
||||
: itemStyle
|
||||
]"
|
||||
:style="[encodedImageUrl ? { backgroundImage: 'url(' + encodedImageUrl + ')' } : itemStyle]"
|
||||
class="c-style-thumb"
|
||||
>
|
||||
<span
|
||||
@ -62,7 +58,7 @@
|
||||
@change="updateStyleValue"
|
||||
/>
|
||||
<ToolbarButton
|
||||
v-if="hasProperty(styleItem.style.imageUrl)"
|
||||
v-if="hasProperty(encodedImageUrl)"
|
||||
class="c-style__toolbar-button--image-url"
|
||||
:options="imageUrlOption"
|
||||
@change="updateStyleValue"
|
||||
@ -93,6 +89,8 @@ import ToolbarButton from '@/ui/toolbar/components/ToolbarButton.vue';
|
||||
import ToolbarColorPicker from '@/ui/toolbar/components/ToolbarColorPicker.vue';
|
||||
import ToolbarToggleButton from '@/ui/toolbar/components/ToolbarToggleButton.vue';
|
||||
|
||||
import { encode_url } from '../../../../utils/encoding';
|
||||
|
||||
export default {
|
||||
name: 'StyleEditor',
|
||||
components: {
|
||||
@ -183,11 +181,14 @@ export default {
|
||||
},
|
||||
property: 'imageUrl',
|
||||
formKeys: ['url'],
|
||||
value: { url: this.styleItem.style.imageUrl },
|
||||
value: { url: this.encodedImageUrl },
|
||||
isEditing: this.isEditing,
|
||||
nonSpecific: this.mixedStyles.indexOf('imageUrl') > -1
|
||||
};
|
||||
},
|
||||
encodedImageUrl() {
|
||||
return encode_url(this.styleItem.style.imageUrl);
|
||||
},
|
||||
isStyleInvisibleOption() {
|
||||
return {
|
||||
value: this.styleItem.style.isStyleInvisible,
|
||||
|
@ -151,9 +151,12 @@ export default class TelemetryCriterion extends EventEmitter {
|
||||
}
|
||||
|
||||
createNormalizedDatum(telemetryDatum, endpoint) {
|
||||
if (!telemetryDatum) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = this.openmct.objects.makeKeyString(endpoint.identifier);
|
||||
const metadata = this.openmct.telemetry.getMetadata(endpoint).valueMetadatas;
|
||||
|
||||
const normalizedDatum = Object.values(metadata).reduce((datum, metadatum) => {
|
||||
const formatter = this.openmct.telemetry.getValueFormatter(metadatum);
|
||||
datum[metadatum.key] = formatter.parse(telemetryDatum[metadatum.source]);
|
||||
|
@ -35,6 +35,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { encode_url } from '../../../utils/encoding';
|
||||
import conditionalStylesMixin from '../mixins/objectStyles-mixin.js';
|
||||
import LayoutFrame from './LayoutFrame.vue';
|
||||
|
||||
@ -80,12 +81,12 @@ export default {
|
||||
return this.isEditing || !this.itemStyle?.isStyleInvisible;
|
||||
},
|
||||
style() {
|
||||
let backgroundImage = 'url(' + this.item.url + ')';
|
||||
let backgroundImage = `url('${encode_url(this.item.url)}')`;
|
||||
let border = '1px solid ' + this.item.stroke;
|
||||
|
||||
if (this.itemStyle) {
|
||||
if (this.itemStyle.imageUrl !== undefined) {
|
||||
backgroundImage = 'url(' + this.itemStyle.imageUrl + ')';
|
||||
backgroundImage = `url('${encode_url(this.itemStyle.imageUrl)}')`;
|
||||
}
|
||||
|
||||
border = this.itemStyle.border;
|
||||
|
@ -109,8 +109,9 @@ class DuplicateAction {
|
||||
let currentParentKeystring = this.openmct.objects.makeKeyString(currentParent.identifier);
|
||||
let parentCandidateKeystring = this.openmct.objects.makeKeyString(parentCandidate.identifier);
|
||||
let objectKeystring = this.openmct.objects.makeKeyString(this.object.identifier);
|
||||
const isLocked = parentCandidate.locked === true;
|
||||
|
||||
if (!this.openmct.objects.isPersistable(parentCandidate.identifier)) {
|
||||
if (isLocked || !this.openmct.objects.isPersistable(parentCandidate.identifier)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -139,10 +140,9 @@ class DuplicateAction {
|
||||
const parentType = parent && this.openmct.types.get(parent.type);
|
||||
const child = objectPath[0];
|
||||
const childType = child && this.openmct.types.get(child.type);
|
||||
const locked = child.locked ? child.locked : parent && parent.locked;
|
||||
const isPersistable = this.openmct.objects.isPersistable(child.identifier);
|
||||
|
||||
if (locked || !isPersistable) {
|
||||
if (!isPersistable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -330,7 +330,8 @@ export default {
|
||||
}
|
||||
|
||||
shelveData.comment = data.comment || '';
|
||||
shelveData.shelveDuration = data.shelveDuration ?? this.shelveDurations[0].value;
|
||||
shelveData.shelveDuration =
|
||||
data.shelveDuration === undefined ? this.shelveDurations[0].value : data.shelveDuration;
|
||||
} else {
|
||||
shelveData = {
|
||||
shelved: false
|
||||
|
@ -42,24 +42,6 @@ export const FAULT_MANAGEMENT_TYPE = 'faultManagement';
|
||||
export const FAULT_MANAGEMENT_INSPECTOR = 'faultManagementInspector';
|
||||
export const FAULT_MANAGEMENT_ALARMS = 'alarms';
|
||||
export const FAULT_MANAGEMENT_GLOBAL_ALARMS = 'global-alarm-status';
|
||||
export const FAULT_MANAGEMENT_SHELVE_DURATIONS_IN_MS = [
|
||||
{
|
||||
name: '5 Minutes',
|
||||
value: 300000
|
||||
},
|
||||
{
|
||||
name: '10 Minutes',
|
||||
value: 600000
|
||||
},
|
||||
{
|
||||
name: '15 Minutes',
|
||||
value: 900000
|
||||
},
|
||||
{
|
||||
name: 'Indefinite',
|
||||
value: 0
|
||||
}
|
||||
];
|
||||
export const FAULT_MANAGEMENT_VIEW = 'faultManagement.view';
|
||||
export const FAULT_MANAGEMENT_NAMESPACE = 'faults.taxonomy';
|
||||
export const FILTER_ITEMS = ['Standard View', 'Acknowledged', 'Unacknowledged', 'Shelved'];
|
||||
|
@ -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' }
|
||||
]
|
||||
}]
|
||||
}
|
||||
|
@ -45,7 +45,7 @@ class EditPropertiesAction extends PropertiesAction {
|
||||
const definition = this._getTypeDefinition(object.type);
|
||||
const persistable = this.openmct.objects.isPersistable(object.identifier);
|
||||
|
||||
return persistable && definition && definition.creatable;
|
||||
return persistable && definition && definition.creatable && !object.locked;
|
||||
}
|
||||
|
||||
invoke(objectPath) {
|
||||
|
@ -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);
|
||||
|
@ -38,7 +38,7 @@
|
||||
<img
|
||||
ref="img"
|
||||
class="c-thumb__image"
|
||||
:src="`${image.thumbnailUrl || image.url}`"
|
||||
:src="imageSrc"
|
||||
fetchpriority="low"
|
||||
@load="imageLoadCompleted"
|
||||
/>
|
||||
@ -54,6 +54,8 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { encode_url } from '../../../utils/encoding';
|
||||
|
||||
const THUMB_PADDING = 4;
|
||||
const BORDER_WIDTH = 2;
|
||||
|
||||
@ -96,6 +98,9 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
imageSrc() {
|
||||
return `${encode_url(this.image.thumbnailUrl) || encode_url(this.image.url)}`;
|
||||
},
|
||||
ariaLabel() {
|
||||
return `Image thumbnail from ${this.image.formattedTime}${this.showAnnotationIndicator ? ', has annotations' : ''}`;
|
||||
},
|
||||
|
@ -222,6 +222,7 @@ import { TIME_CONTEXT_EVENTS } from '@/api/time/constants.js';
|
||||
import imageryData from '@/plugins/imagery/mixins/imageryData.js';
|
||||
import { VIEW_LARGE_ACTION_KEY } from '@/plugins/viewLargeAction/viewLargeAction.js';
|
||||
|
||||
import { encode_url } from '../../../utils/encoding';
|
||||
import eventHelpers from '../lib/eventHelpers.js';
|
||||
import AnnotationsCanvas from './AnnotationsCanvas.vue';
|
||||
import Compass from './Compass/CompassComponent.vue';
|
||||
@ -364,7 +365,7 @@ export default {
|
||||
filter: `brightness(${this.filters.brightness}%) contrast(${this.filters.contrast}%)`,
|
||||
backgroundImage: `${
|
||||
this.imageUrl
|
||||
? `url(${this.imageUrl}),
|
||||
? `url(${encode_url(this.imageUrl)}),
|
||||
repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
@ -789,7 +790,7 @@ export default {
|
||||
},
|
||||
getVisibleLayerStyles(layer) {
|
||||
return {
|
||||
backgroundImage: `url(${layer.source})`,
|
||||
backgroundImage: `url(${encode_url(layer.source)})`,
|
||||
transform: `scale(${this.zoomFactor}) translate(${this.imageTranslateX / 2}px, ${
|
||||
this.imageTranslateY / 2
|
||||
}px)`,
|
||||
|
@ -96,6 +96,8 @@ export default {
|
||||
const createdTimestamp = this.domainObject.created;
|
||||
const createdBy = this.domainObject.createdBy ? this.domainObject.createdBy : UNKNOWN_USER;
|
||||
const modifiedBy = this.domainObject.modifiedBy ? this.domainObject.modifiedBy : UNKNOWN_USER;
|
||||
const locked = this.domainObject.locked;
|
||||
const lockedBy = this.domainObject.lockedBy ?? UNKNOWN_USER;
|
||||
const modifiedTimestamp = this.domainObject.modified
|
||||
? this.domainObject.modified
|
||||
: this.domainObject.created;
|
||||
@ -148,6 +150,13 @@ export default {
|
||||
});
|
||||
}
|
||||
|
||||
if (locked === true) {
|
||||
details.push({
|
||||
name: 'Locked By',
|
||||
value: lockedBy
|
||||
});
|
||||
}
|
||||
|
||||
if (version) {
|
||||
details.push({
|
||||
name: 'Version',
|
||||
|
@ -344,12 +344,19 @@ export default {
|
||||
},
|
||||
beforeMount() {
|
||||
this.marked = new Marked();
|
||||
this.renderer = new this.marked.Renderer();
|
||||
this.marked.use({
|
||||
breaks: true,
|
||||
extensions: [
|
||||
{
|
||||
name: 'link',
|
||||
renderer: (options) => {
|
||||
return this.validateLink(options);
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
},
|
||||
mounted() {
|
||||
const originalLinkRenderer = this.renderer.link;
|
||||
this.renderer.link = this.validateLink.bind(this, originalLinkRenderer);
|
||||
|
||||
this.manageEmbedLayout = _.debounce(this.manageEmbedLayout, 400);
|
||||
|
||||
if (this.$refs.embedsWrapper) {
|
||||
@ -437,10 +444,7 @@ export default {
|
||||
}
|
||||
},
|
||||
convertMarkDownToHtml(text = '') {
|
||||
let markDownHtml = this.marked.parse(text, {
|
||||
breaks: true,
|
||||
renderer: this.renderer
|
||||
});
|
||||
let markDownHtml = this.marked.parse(text);
|
||||
markDownHtml = sanitizeHtml(markDownHtml, SANITIZATION_SCHEMA);
|
||||
return markDownHtml;
|
||||
},
|
||||
@ -451,21 +455,19 @@ export default {
|
||||
this.$refs.entryInput.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
},
|
||||
validateLink(originalLinkRenderer, href, title, text) {
|
||||
validateLink(options) {
|
||||
const { href, text } = options;
|
||||
try {
|
||||
const domain = new URL(href).hostname;
|
||||
const urlIsWhitelisted = this.urlWhitelist.some((partialDomain) => {
|
||||
return domain.endsWith(partialDomain);
|
||||
});
|
||||
|
||||
if (!urlIsWhitelisted) {
|
||||
return text;
|
||||
}
|
||||
const linkHtml = originalLinkRenderer.call(this.renderer, href, title, text);
|
||||
const linkHtmlWithTarget = linkHtml.replace(
|
||||
/^<a /,
|
||||
'<a class="c-hyperlink" target="_blank"'
|
||||
);
|
||||
return linkHtmlWithTarget;
|
||||
|
||||
return `<a class="c-hyperlink" target="_blank" href="${href}">${text}</a>`;
|
||||
} catch (error) {
|
||||
// had error parsing this URL, just return the text
|
||||
return text;
|
||||
|
@ -434,16 +434,69 @@ class CouchObjectProvider {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
async getObjectsByView({ designDoc, viewName, keysToSearch }, abortSignal) {
|
||||
const stringifiedKeys = JSON.stringify(keysToSearch);
|
||||
const url = `${this.url}/_design/${designDoc}/_view/${viewName}?keys=${stringifiedKeys}&include_docs=true`;
|
||||
async isViewDefined(designDoc, viewName) {
|
||||
const url = `${this.url}/_design/${designDoc}/_view/${viewName}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'HEAD'
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef GetObjectByViewOptions
|
||||
* @property {String} designDoc the name of the design document that the view belongs to
|
||||
* @property {String} viewName
|
||||
* @property {Array.<String>} [keysToSearch] a list of discrete view keys to search for. View keys are not object identifiers.
|
||||
* @property {String} [startKey] limit the search to a range of keys starting with the provided `startKey`. One of `keysToSearch` OR `startKey` AND `endKey` must be provided
|
||||
* @property {String} [endKey] limit the search to a range of keys ending with the provided `endKey`. One of `keysToSearch` OR `startKey` AND `endKey` must be provided
|
||||
* @property {Number} [limit] limit the number of results returned
|
||||
* @property {String} [objectIdField] The field (either key or value) to treat as an object key. If provided, include_docs will be set to false in the request, and the field will be used as an object identifier. A bulk request will be used to resolve objects from identifiers
|
||||
*/
|
||||
/**
|
||||
* Return objects based on a call to a view. See https://docs.couchdb.org/en/stable/api/ddoc/views.html.
|
||||
* @param {GetObjectByViewOptions} options
|
||||
* @param {AbortSignal} abortSignal
|
||||
* @returns {Promise<Array.<import('openmct.js').DomainObject>>}
|
||||
*/
|
||||
async getObjectsByView(
|
||||
{ designDoc, viewName, keysToSearch, startKey, endKey, limit, objectIdField },
|
||||
abortSignal
|
||||
) {
|
||||
let stringifiedKeys = JSON.stringify(keysToSearch);
|
||||
const url = `${this.url}/_design/${designDoc}/_view/${viewName}`;
|
||||
const requestBody = {};
|
||||
let requestBodyString;
|
||||
|
||||
if (objectIdField === undefined) {
|
||||
requestBody.include_docs = true;
|
||||
}
|
||||
|
||||
if (limit !== undefined) {
|
||||
requestBody.limit = limit;
|
||||
}
|
||||
|
||||
if (startKey !== undefined && endKey !== undefined) {
|
||||
/* spell-checker: disable */
|
||||
requestBody.startkey = startKey;
|
||||
requestBody.endkey = endKey;
|
||||
requestBodyString = JSON.stringify(requestBody);
|
||||
requestBodyString = requestBodyString.replace('$START_KEY', startKey);
|
||||
requestBodyString = requestBodyString.replace('$END_KEY', endKey);
|
||||
/* spell-checker: enable */
|
||||
} else {
|
||||
requestBody.keys = stringifiedKeys;
|
||||
requestBodyString = JSON.stringify(requestBody);
|
||||
}
|
||||
|
||||
let objectModels = [];
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: abortSignal
|
||||
signal: abortSignal,
|
||||
body: requestBodyString
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@ -454,13 +507,21 @@ class CouchObjectProvider {
|
||||
|
||||
const result = await response.json();
|
||||
const couchRows = result.rows;
|
||||
couchRows.forEach((couchRow) => {
|
||||
const couchDoc = couchRow.doc;
|
||||
const objectModel = this.#getModel(couchDoc);
|
||||
if (objectModel) {
|
||||
objectModels.push(objectModel);
|
||||
}
|
||||
});
|
||||
if (objectIdField !== undefined) {
|
||||
const objectIdsToResolve = [];
|
||||
couchRows.forEach((couchRow) => {
|
||||
objectIdsToResolve.push(couchRow[objectIdField]);
|
||||
});
|
||||
objectModels = Object.values(await this.#bulkGet(objectIdsToResolve), abortSignal);
|
||||
} else {
|
||||
couchRows.forEach((couchRow) => {
|
||||
const couchDoc = couchRow.doc;
|
||||
const objectModel = this.#getModel(couchDoc);
|
||||
if (objectModel) {
|
||||
objectModels.push(objectModel);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// do nothing
|
||||
}
|
||||
|
@ -33,7 +33,11 @@ class CouchSearchProvider {
|
||||
#bulkPromise;
|
||||
#batchIds;
|
||||
#lastAbortSignal;
|
||||
|
||||
#isSearchByNameViewDefined;
|
||||
/**
|
||||
*
|
||||
* @param {import('./CouchObjectProvider').default} couchObjectProvider
|
||||
*/
|
||||
constructor(couchObjectProvider) {
|
||||
this.couchObjectProvider = couchObjectProvider;
|
||||
this.searchTypes = couchObjectProvider.openmct.objects.SEARCH_TYPES;
|
||||
@ -67,18 +71,47 @@ class CouchSearchProvider {
|
||||
}
|
||||
}
|
||||
|
||||
searchForObjects(query, abortSignal) {
|
||||
const filter = {
|
||||
selector: {
|
||||
model: {
|
||||
name: {
|
||||
$regex: `(?i)${query}`
|
||||
#isOptimizedSearchByNameSupported() {
|
||||
let isOptimizedSearchAvailable;
|
||||
|
||||
if (this.#isSearchByNameViewDefined === undefined) {
|
||||
isOptimizedSearchAvailable = this.#isSearchByNameViewDefined =
|
||||
this.couchObjectProvider.isViewDefined('object_names', 'object_names');
|
||||
} else {
|
||||
isOptimizedSearchAvailable = this.#isSearchByNameViewDefined;
|
||||
}
|
||||
|
||||
return isOptimizedSearchAvailable;
|
||||
}
|
||||
|
||||
async searchForObjects(query, abortSignal) {
|
||||
const preparedQuery = query.toLowerCase().trim();
|
||||
const supportsOptimizedSearchByName = await this.#isOptimizedSearchByNameSupported();
|
||||
|
||||
if (supportsOptimizedSearchByName) {
|
||||
return this.couchObjectProvider.getObjectsByView(
|
||||
{
|
||||
designDoc: 'object_names',
|
||||
viewName: 'object_names',
|
||||
startKey: preparedQuery,
|
||||
endKey: preparedQuery + `\ufff0`,
|
||||
objectIdField: 'value',
|
||||
limit: 1000
|
||||
},
|
||||
abortSignal
|
||||
);
|
||||
} else {
|
||||
const filter = {
|
||||
selector: {
|
||||
model: {
|
||||
name: {
|
||||
$regex: `(?i)${query}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return this.couchObjectProvider.getObjectsByFilter(filter, abortSignal);
|
||||
};
|
||||
return this.couchObjectProvider.getObjectsByFilter(filter);
|
||||
}
|
||||
}
|
||||
|
||||
async #deferBatchAnnotationSearch() {
|
||||
|
@ -373,44 +373,6 @@ describe('the plugin', () => {
|
||||
expect(requestMethod).toEqual('PUT');
|
||||
});
|
||||
});
|
||||
describe('implements server-side search', () => {
|
||||
let mockPromise;
|
||||
beforeEach(() => {
|
||||
mockPromise = Promise.resolve({
|
||||
body: {
|
||||
getReader() {
|
||||
return {
|
||||
read() {
|
||||
return Promise.resolve({
|
||||
done: true,
|
||||
value: undefined
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
fetch.and.returnValue(mockPromise);
|
||||
});
|
||||
|
||||
it("using Couch's 'find' endpoint", async () => {
|
||||
await Promise.all(openmct.objects.search('test'));
|
||||
const requestUrl = fetch.calls.mostRecent().args[0];
|
||||
|
||||
// we only want one call to fetch, not 2!
|
||||
// see https://github.com/nasa/openmct/issues/4667
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(requestUrl.endsWith('_find')).toBeTrue();
|
||||
});
|
||||
|
||||
it('and supports search by object name', async () => {
|
||||
await Promise.all(openmct.objects.search('test'));
|
||||
const requestPayload = JSON.parse(fetch.calls.mostRecent().args[1].body);
|
||||
|
||||
expect(requestPayload).toBeDefined();
|
||||
expect(requestPayload.selector.model.name.$regex).toEqual('(?i)test');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('the view', () => {
|
||||
|
191
src/plugins/persistence/couch/scripts/lockObjects.mjs
Normal file
191
src/plugins/persistence/couch/scripts/lockObjects.mjs
Normal file
@ -0,0 +1,191 @@
|
||||
import http from 'http';
|
||||
import nano from 'nano';
|
||||
import { parseArgs } from 'util';
|
||||
|
||||
const COUCH_URL = process.env.OPENMCT_COUCH_URL || 'http://127.0.0.1:5984';
|
||||
const COUCH_DB_NAME = process.env.OPENMCT_DATABASE_NAME || 'openmct';
|
||||
|
||||
const {
|
||||
values: { couchUrl, database, lock, unlock, startObjectKeystring, user, pass }
|
||||
} = parseArgs({
|
||||
options: {
|
||||
couchUrl: {
|
||||
type: 'string',
|
||||
default: COUCH_URL
|
||||
},
|
||||
database: {
|
||||
type: 'string',
|
||||
short: 'd',
|
||||
default: COUCH_DB_NAME
|
||||
},
|
||||
lock: {
|
||||
type: 'boolean',
|
||||
short: 'l'
|
||||
},
|
||||
unlock: {
|
||||
type: 'boolean',
|
||||
short: 'u'
|
||||
},
|
||||
startObjectKeystring: {
|
||||
type: 'string',
|
||||
short: 'o',
|
||||
default: 'mine'
|
||||
},
|
||||
user: {
|
||||
type: 'string'
|
||||
},
|
||||
pass: {
|
||||
type: 'string'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const BATCH_SIZE = 100;
|
||||
const SOCKET_POOL_SIZE = 100;
|
||||
|
||||
const locked = lock === true;
|
||||
console.info(`Connecting to ${couchUrl}/${database}`);
|
||||
console.info(`${locked ? 'Locking' : 'Unlocking'} all children of ${startObjectKeystring}`);
|
||||
|
||||
const poolingAgent = new http.Agent({
|
||||
keepAlive: true,
|
||||
maxSockets: SOCKET_POOL_SIZE
|
||||
});
|
||||
|
||||
const db = nano({
|
||||
url: couchUrl,
|
||||
requestDefaults: {
|
||||
agent: poolingAgent
|
||||
}
|
||||
}).use(database);
|
||||
db.auth(user, pass);
|
||||
|
||||
if (!unlock && !lock) {
|
||||
throw new Error('Either -l or -u option is required');
|
||||
}
|
||||
|
||||
const startObjectIdentifier = keystringToIdentifier(startObjectKeystring);
|
||||
const documentBatch = [];
|
||||
const alreadySeen = new Set();
|
||||
let updatedDocumentCount = 0;
|
||||
|
||||
await processObjectTreeFrom(startObjectIdentifier);
|
||||
//Persist final batch
|
||||
await persistBatch();
|
||||
console.log(`Processed ${updatedDocumentCount} documents`);
|
||||
|
||||
function processObjectTreeFrom(parentObjectIdentifier) {
|
||||
//1. Fetch document for identifier;
|
||||
return fetchDocument(parentObjectIdentifier)
|
||||
.then(async (document) => {
|
||||
if (document !== undefined) {
|
||||
if (!alreadySeen.has(document._id)) {
|
||||
alreadySeen.add(document._id);
|
||||
//2. Lock or unlock object
|
||||
document.model.locked = locked;
|
||||
document.model.disallowUnlock = locked;
|
||||
|
||||
if (locked) {
|
||||
document.model.lockedBy = 'script';
|
||||
} else {
|
||||
delete document.model.lockedBy;
|
||||
}
|
||||
//3. Push document to a batch
|
||||
documentBatch.push(document);
|
||||
//4. Persist batch if necessary, reporting failures
|
||||
await persistBatchIfNeeded();
|
||||
//5. Repeat for each composee
|
||||
const composition = document.model.composition || [];
|
||||
return Promise.all(
|
||||
composition.map((composee) => {
|
||||
return processObjectTreeFrom(composee);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(`Error ${error}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchDocument(identifierOrKeystring) {
|
||||
let keystring;
|
||||
if (typeof identifierOrKeystring === 'object') {
|
||||
keystring = identifierToKeystring(identifierOrKeystring);
|
||||
} else {
|
||||
keystring = identifierOrKeystring;
|
||||
}
|
||||
|
||||
try {
|
||||
const document = await db.get(keystring);
|
||||
|
||||
return document;
|
||||
} catch (error) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function persistBatchIfNeeded() {
|
||||
if (documentBatch.length >= BATCH_SIZE) {
|
||||
return persistBatch();
|
||||
} else {
|
||||
//Noop - batch is not big enough yet
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function persistBatch() {
|
||||
try {
|
||||
const localBatch = [].concat(documentBatch);
|
||||
|
||||
//Immediately clear the shared batch array. This asynchronous process is non-blocking, and
|
||||
//we don't want to try and persist the same batch multiple times while we are waiting for
|
||||
//the subsequent bulk operation to complete.
|
||||
updatedDocumentCount += documentBatch.length;
|
||||
|
||||
documentBatch.splice(0, documentBatch.length);
|
||||
const response = await db.bulk({ docs: localBatch });
|
||||
|
||||
if (response instanceof Array) {
|
||||
response.forEach((r) => {
|
||||
console.info(JSON.stringify(r));
|
||||
});
|
||||
} else {
|
||||
console.info(JSON.stringify(response));
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Array) {
|
||||
error.forEach((e) => console.error(JSON.stringify(e)));
|
||||
} else {
|
||||
console.error(`${error.statusCode} - ${error.reason}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function keystringToIdentifier(keystring) {
|
||||
const tokens = keystring.split(':');
|
||||
if (tokens.length === 2) {
|
||||
return {
|
||||
namespace: tokens[0],
|
||||
key: tokens[1]
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
namespace: '',
|
||||
key: tokens[0]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function identifierToKeystring(identifier) {
|
||||
if (typeof identifier === 'string') {
|
||||
return identifier;
|
||||
} else if (typeof identifier === 'object') {
|
||||
if (identifier.namespace) {
|
||||
return `${identifier.namespace}:${identifier.key}`;
|
||||
} else {
|
||||
return identifier.key;
|
||||
}
|
||||
}
|
||||
}
|
@ -99,6 +99,48 @@ create_replicator_table() {
|
||||
add_index_and_views() {
|
||||
echo "Adding index and views to $OPENMCT_DATABASE_NAME database"
|
||||
|
||||
# Add object names search index
|
||||
response=$(curl --silent --user "${CURL_USERPASS_ARG}" --request PUT "$COUCH_BASE_LOCAL"/"$OPENMCT_DATABASE_NAME"/_design/object_names/\
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{
|
||||
"_id":"_design/object_names",
|
||||
"views":{
|
||||
"object_names":{
|
||||
"map":"function(doc) { if (doc.model && doc.model.name) { const name = doc.model.name.toLowerCase().trim(); if (name.length > 0) { emit(name, doc._id); const tokens = name.split(/[^a-zA-Z0-9]/); tokens.forEach((token) => { if (token.length > 0) { emit(token, doc._id); } }); } } }"
|
||||
}
|
||||
}
|
||||
}');
|
||||
|
||||
if [[ $response =~ "\"ok\":true" ]]; then
|
||||
echo "Successfully created object_names"
|
||||
elif [[ $response =~ "\"error\":\"conflict\"" ]]; then
|
||||
echo "object_names already exists, skipping creation"
|
||||
else
|
||||
echo "Unable to create object_names"
|
||||
echo $response
|
||||
fi
|
||||
|
||||
# Add object types search index
|
||||
response=$(curl --silent --user "${CURL_USERPASS_ARG}" --request PUT "$COUCH_BASE_LOCAL"/"$OPENMCT_DATABASE_NAME"/_design/object_types/\
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{
|
||||
"_id":"_design/object_types",
|
||||
"views":{
|
||||
"object_types":{
|
||||
"map":"function(doc) { if (doc.model && doc.model.type) { const type = doc.model.type.toLowerCase().trim(); if (type.length > 0) { emit(type, null); } } }"
|
||||
}
|
||||
}
|
||||
}')
|
||||
|
||||
if [[ $response =~ "\"ok\":true" ]]; then
|
||||
echo "Successfully created object_types"
|
||||
elif [[ $response =~ "\"error\":\"conflict\"" ]]; then
|
||||
echo "object_types already exists, skipping creation"
|
||||
else
|
||||
echo "Unable to create object_types"
|
||||
echo $response
|
||||
fi
|
||||
|
||||
# Add type_tags_index
|
||||
response=$(curl --silent --user "${CURL_USERPASS_ARG}" --request POST "$COUCH_BASE_LOCAL"/"$OPENMCT_DATABASE_NAME"/_index/\
|
||||
--header 'Content-Type: application/json' \
|
||||
@ -160,6 +202,24 @@ add_index_and_views() {
|
||||
echo "Unable to create annotation_keystring_index"
|
||||
echo $response
|
||||
fi
|
||||
|
||||
# Add auth database for locked objects
|
||||
response=$(curl --silent --user "${CURL_USERPASS_ARG}" --request PUT "$COUCH_BASE_LOCAL"/"$OPENMCT_DATABASE_NAME"/_design/auth \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{
|
||||
"_id": "_design/auth",
|
||||
"language": "javascript",
|
||||
"validate_doc_update": "function (newDoc, oldDoc, userCtx) { if (userCtx.roles.indexOf('\''_admin'\'') !== -1) { return; } else if (oldDoc === null) { return; } else if (oldDoc.model.type === '\''timer'\'' || oldDoc.model.type === '\''notebook'\'' || oldDoc.model.type === '\''restricted-notebook'\'') { if (oldDoc.model.name !== newDoc.model.name) { throw ({ forbidden: '\''Read-only object'\'' }); } else { return; } } else if (oldDoc.model.locked === true && oldDoc.model.disallowUnlock === true) { throw ({ forbidden: '\''Read-only object'\'' }); } else { return; }}"
|
||||
}')
|
||||
|
||||
if [[ $response =~ "\"ok\":true" ]]; then
|
||||
echo "Successfully created _design/auth design document for locked objects"
|
||||
elif [[ $response =~ "\"error\":\"conflict\"" ]]; then
|
||||
echo "_design/auth already exists, skipping creation"
|
||||
else
|
||||
echo "Unable to create _design/auth"
|
||||
echo $response
|
||||
fi
|
||||
}
|
||||
|
||||
# Main script execution
|
||||
|
@ -243,11 +243,12 @@ export default {
|
||||
if (this.planObject) {
|
||||
this.showReplacePlanDialog(domainObject);
|
||||
} else {
|
||||
this.swimlaneVisibility = this.configuration.swimlaneVisibility;
|
||||
this.setupPlan(domainObject);
|
||||
this.swimlaneVisibility = this.configuration.swimlaneVisibility;
|
||||
}
|
||||
},
|
||||
handleConfigurationChange(newConfiguration) {
|
||||
this.configuration = this.planViewConfiguration.getConfiguration();
|
||||
Object.keys(newConfiguration).forEach((key) => {
|
||||
this[key] = newConfiguration[key];
|
||||
});
|
||||
@ -423,7 +424,10 @@ export default {
|
||||
return currentRow || SWIMLANE_PADDING;
|
||||
},
|
||||
generateActivities() {
|
||||
const groupNames = getValidatedGroups(this.domainObject, this.planData);
|
||||
if (!this.planObject) {
|
||||
return;
|
||||
}
|
||||
const groupNames = getValidatedGroups(this.planObject, this.planData);
|
||||
|
||||
if (!groupNames.length) {
|
||||
return;
|
||||
|
@ -164,11 +164,13 @@
|
||||
<div
|
||||
v-show="cursorGuide"
|
||||
ref="cursorGuideVertical"
|
||||
aria-label="Vertical cursor guide"
|
||||
class="c-cursor-guide--v js-cursor-guide--v"
|
||||
></div>
|
||||
<div
|
||||
v-show="cursorGuide"
|
||||
ref="cursorGuideHorizontal"
|
||||
aria-label="Horizontal cursor guide"
|
||||
class="c-cursor-guide--h js-cursor-guide--h"
|
||||
></div>
|
||||
</div>
|
||||
@ -537,6 +539,7 @@ export default {
|
||||
this.followTimeContext();
|
||||
},
|
||||
followTimeContext() {
|
||||
this.updateMode();
|
||||
this.updateDisplayBounds(this.timeContext.getBounds());
|
||||
this.timeContext.on('modeChanged', this.updateMode);
|
||||
this.timeContext.on('boundsChanged', this.updateDisplayBounds);
|
||||
@ -854,13 +857,11 @@ export default {
|
||||
|
||||
this.canvas = this.$refs.chartContainer.querySelectorAll('canvas')[1];
|
||||
|
||||
if (!this.options.compact) {
|
||||
this.listenTo(this.canvas, 'mousemove', this.trackMousePosition, this);
|
||||
this.listenTo(this.canvas, 'mouseleave', this.untrackMousePosition, this);
|
||||
this.listenTo(this.canvas, 'mousedown', this.onMouseDown, this);
|
||||
this.listenTo(this.canvas, 'click', this.selectNearbyAnnotations, this);
|
||||
this.listenTo(this.canvas, 'wheel', this.wheelZoom, this);
|
||||
}
|
||||
this.listenTo(this.canvas, 'mousemove', this.trackMousePosition, this);
|
||||
this.listenTo(this.canvas, 'mouseleave', this.untrackMousePosition, this);
|
||||
this.listenTo(this.canvas, 'mousedown', this.onMouseDown, this);
|
||||
this.listenTo(this.canvas, 'click', this.selectNearbyAnnotations, this);
|
||||
this.listenTo(this.canvas, 'wheel', this.wheelZoom, this);
|
||||
},
|
||||
|
||||
marqueeAnnotations(annotationsToSelect) {
|
||||
@ -1115,19 +1116,21 @@ export default {
|
||||
this.listenTo(window, 'mouseup', this.onMouseUp, this);
|
||||
this.listenTo(window, 'mousemove', this.trackMousePosition, this);
|
||||
|
||||
// track frozen state on mouseDown to be read on mouseUp
|
||||
const isFrozen =
|
||||
this.config.xAxis.get('frozen') === true && this.config.yAxis.get('frozen') === true;
|
||||
this.isFrozenOnMouseDown = isFrozen;
|
||||
if (!this.options.compact) {
|
||||
// track frozen state on mouseDown to be read on mouseUp
|
||||
const isFrozen =
|
||||
this.config.xAxis.get('frozen') === true && this.config.yAxis.get('frozen') === true;
|
||||
this.isFrozenOnMouseDown = isFrozen;
|
||||
|
||||
if (event.altKey && !event.shiftKey) {
|
||||
return this.startPan(event);
|
||||
} else if (event.altKey && event.shiftKey) {
|
||||
this.freeze();
|
||||
if (event.altKey && !event.shiftKey) {
|
||||
return this.startPan(event);
|
||||
} else if (event.altKey && event.shiftKey) {
|
||||
this.freeze();
|
||||
|
||||
return this.startMarquee(event, true);
|
||||
} else {
|
||||
return this.startMarquee(event, false);
|
||||
return this.startMarquee(event, true);
|
||||
} else {
|
||||
return this.startMarquee(event, false);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -1158,11 +1161,15 @@ export default {
|
||||
},
|
||||
|
||||
isMouseClick() {
|
||||
if (!this.marquee) {
|
||||
// We may not have a marquee if we've disabled pan/zoom, but we still need to know if it's a mouse click for highlights and lock points.
|
||||
if (!this.marquee && !this.positionOverPlot) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { start, end } = this.marquee;
|
||||
const { start, end } = this.marquee ?? {
|
||||
start: this.positionOverPlot,
|
||||
end: this.positionOverPlot
|
||||
};
|
||||
const someYPositionOverPlot = start.y.some((y) => y);
|
||||
|
||||
return start.x === end.x && someYPositionOverPlot;
|
||||
|
@ -162,14 +162,6 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
gridLines(newGridLines) {
|
||||
this.gridLines = newGridLines;
|
||||
},
|
||||
cursorGuide(newCursorGuide) {
|
||||
this.cursorGuide = newCursorGuide;
|
||||
}
|
||||
},
|
||||
created() {
|
||||
eventHelpers.extend(this);
|
||||
this.imageExporter = new ImageExporter(this.openmct);
|
||||
|
@ -38,11 +38,11 @@
|
||||
v-for="(tab, index) in tabsList"
|
||||
:ref="tab.keyString"
|
||||
:key="tab.keyString"
|
||||
:aria-label="`${tab.domainObject.name} tab`"
|
||||
:aria-label="`${tab.domainObject.name} tab${tab.keyString === currentTab.keyString ? ' - selected' : ''}`"
|
||||
class="c-tab c-tabs-view__tab js-tab"
|
||||
role="tab"
|
||||
:class="{
|
||||
'is-current': isCurrent(tab)
|
||||
'is-current': tab.keyString === currentTab.keyString
|
||||
}"
|
||||
@click="showTab(tab, index)"
|
||||
@mouseover.ctrl="showToolTip(tab)"
|
||||
@ -74,7 +74,7 @@
|
||||
:key="tab.keyString"
|
||||
:style="getTabStyles(tab)"
|
||||
class="c-tabs-view__object-holder"
|
||||
:class="{ 'c-tabs-view__object-holder--hidden': !isCurrent(tab) }"
|
||||
:class="{ 'c-tabs-view__object-holder--hidden': tab.keyString !== currentTab.keyString }"
|
||||
>
|
||||
<ObjectView
|
||||
v-if="shouldLoadTab(tab)"
|
||||
@ -353,7 +353,10 @@ export default {
|
||||
this.internalDomainObject = domainObject;
|
||||
},
|
||||
persistCurrentTabIndex(index) {
|
||||
this.openmct.objects.mutate(this.internalDomainObject, 'currentTabIndex', index);
|
||||
//only persist if the domain object is not locked. The object mutate API will deal with whether the object is persistable or not.
|
||||
if (!this.internalDomainObject.locked) {
|
||||
this.openmct.objects.mutate(this.internalDomainObject, 'currentTabIndex', index);
|
||||
}
|
||||
},
|
||||
storeCurrentTabIndexInURL(index) {
|
||||
let currentTabIndexInURL = this.openmct.router.getSearchParam(this.searchTabKey);
|
||||
|
@ -25,7 +25,7 @@ import _ from 'lodash';
|
||||
|
||||
import StalenessUtils from '../../utils/staleness.js';
|
||||
import TableRowCollection from './collections/TableRowCollection.js';
|
||||
import { MODE, ORDER } from './constants.js';
|
||||
import { MODE } from './constants.js';
|
||||
import TelemetryTableColumn from './TelemetryTableColumn.js';
|
||||
import TelemetryTableConfiguration from './TelemetryTableConfiguration.js';
|
||||
import TelemetryTableNameColumn from './TelemetryTableNameColumn.js';
|
||||
@ -130,14 +130,7 @@ export default class TelemetryTable extends EventEmitter {
|
||||
createTableRowCollections() {
|
||||
this.tableRows = new TableRowCollection();
|
||||
|
||||
//Fetch any persisted default sort
|
||||
let sortOptions = this.configuration.getConfiguration().sortOptions;
|
||||
|
||||
//If no persisted sort order, default to sorting by time system, descending.
|
||||
sortOptions = sortOptions || {
|
||||
key: this.openmct.time.getTimeSystem().key,
|
||||
direction: ORDER.DESCENDING
|
||||
};
|
||||
const sortOptions = this.configuration.getSortOptions();
|
||||
|
||||
this.updateRowLimit();
|
||||
|
||||
@ -172,8 +165,8 @@ export default class TelemetryTable extends EventEmitter {
|
||||
|
||||
this.removeTelemetryCollection(keyString);
|
||||
|
||||
let sortOptions = this.configuration.getConfiguration().sortOptions;
|
||||
requestOptions.order = sortOptions?.direction ?? ORDER.DESCENDING; // default to descending
|
||||
let sortOptions = this.configuration.getSortOptions();
|
||||
requestOptions.order = sortOptions.direction;
|
||||
|
||||
if (this.telemetryMode === MODE.PERFORMANCE) {
|
||||
requestOptions.size = this.rowLimit;
|
||||
@ -442,12 +435,13 @@ export default class TelemetryTable extends EventEmitter {
|
||||
}
|
||||
|
||||
sortBy(sortOptions) {
|
||||
this.tableRows.sortBy(sortOptions);
|
||||
this.configuration.setSortOptions(sortOptions);
|
||||
|
||||
if (this.openmct.editor.isEditing()) {
|
||||
let configuration = this.configuration.getConfiguration();
|
||||
configuration.sortOptions = sortOptions;
|
||||
this.configuration.updateConfiguration(configuration);
|
||||
if (this.telemetryMode === MODE.PERFORMANCE) {
|
||||
this.tableRows.setSortOptions(sortOptions);
|
||||
this.clearAndResubscribe();
|
||||
} else {
|
||||
this.tableRows.sortBy(sortOptions);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -23,7 +23,11 @@
|
||||
import { EventEmitter } from 'eventemitter3';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { ORDER } from './constants';
|
||||
|
||||
export default class TelemetryTableConfiguration extends EventEmitter {
|
||||
#sortOptions;
|
||||
|
||||
constructor(domainObject, openmct, options) {
|
||||
super();
|
||||
|
||||
@ -44,6 +48,26 @@ export default class TelemetryTableConfiguration extends EventEmitter {
|
||||
this.notPersistable = !this.openmct.objects.isPersistable(this.domainObject.identifier);
|
||||
}
|
||||
|
||||
getSortOptions() {
|
||||
return (
|
||||
this.#sortOptions ||
|
||||
this.getConfiguration().sortOptions || {
|
||||
key: this.openmct.time.getTimeSystem().key,
|
||||
direction: ORDER.DESCENDING
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
setSortOptions(sortOptions) {
|
||||
this.#sortOptions = sortOptions;
|
||||
|
||||
if (this.openmct.editor.isEditing()) {
|
||||
let configuration = this.getConfiguration();
|
||||
configuration.sortOptions = sortOptions;
|
||||
this.updateConfiguration(configuration);
|
||||
}
|
||||
}
|
||||
|
||||
getConfiguration() {
|
||||
let configuration = this.domainObject.configuration || {};
|
||||
configuration.hiddenColumns = configuration.hiddenColumns || {};
|
||||
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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) {
|
||||
@ -273,7 +290,7 @@ export default class TableRowCollection extends EventEmitter {
|
||||
*/
|
||||
sortBy(sortOptions) {
|
||||
if (arguments.length > 0) {
|
||||
this.sortOptions = sortOptions;
|
||||
this.setSortOptions(sortOptions);
|
||||
this.rows = _.orderBy(
|
||||
this.rows,
|
||||
(row) => row.getParsedValue(sortOptions.key),
|
||||
@ -286,6 +303,10 @@ export default class TableRowCollection extends EventEmitter {
|
||||
return Object.assign({}, this.sortOptions);
|
||||
}
|
||||
|
||||
setSortOptions(sortOptions) {
|
||||
this.sortOptions = sortOptions;
|
||||
}
|
||||
|
||||
setColumnFilter(columnKey, filter) {
|
||||
filter = filter.trim().toLowerCase();
|
||||
let wasBlank = this.columnFilters[columnKey] === undefined;
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -11,18 +11,19 @@
|
||||
>
|
||||
<input
|
||||
ref="startDate"
|
||||
v-model="formattedBounds.start"
|
||||
v-model="formattedBounds.startDate"
|
||||
class="c-input--datetime"
|
||||
type="text"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
aria-label="Start date"
|
||||
@input="validateAllBounds('startDate')"
|
||||
@input="validateInput('startDate')"
|
||||
@change="reportValidity('startDate')"
|
||||
/>
|
||||
<DatePicker
|
||||
v-if="isUTCBased"
|
||||
class="c-ctrl-wrapper--menus-right"
|
||||
:default-date-time="formattedBounds.start"
|
||||
:default-date-time="formattedBounds.startDate"
|
||||
:formatter="timeFormatter"
|
||||
@date-selected="startDateSelected"
|
||||
/>
|
||||
@ -37,7 +38,8 @@
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
aria-label="Start time"
|
||||
@input="validateAllBounds('startDate')"
|
||||
@input="validateInput('startTime')"
|
||||
@change="reportValidity('startTime')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -48,18 +50,19 @@
|
||||
>
|
||||
<input
|
||||
ref="endDate"
|
||||
v-model="formattedBounds.end"
|
||||
v-model="formattedBounds.endDate"
|
||||
class="c-input--datetime"
|
||||
type="text"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
aria-label="End date"
|
||||
@input="validateAllBounds('endDate')"
|
||||
@input="validateInput('endDate')"
|
||||
@change="reportValidity('endDate')"
|
||||
/>
|
||||
<DatePicker
|
||||
v-if="isUTCBased"
|
||||
class="c-ctrl-wrapper--menus-left"
|
||||
:default-date-time="formattedBounds.end"
|
||||
:default-date-time="formattedBounds.endDate"
|
||||
:formatter="timeFormatter"
|
||||
@date-selected="endDateSelected"
|
||||
/>
|
||||
@ -74,14 +77,15 @@
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
aria-label="End time"
|
||||
@input="validateAllBounds('endDate')"
|
||||
@input="validateInput('endTime')"
|
||||
@change="reportValidity('endTime')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pr-time-input pr-time-input--buttons">
|
||||
<button
|
||||
class="c-button c-button--major icon-check"
|
||||
:disabled="isDisabled"
|
||||
:disabled="hasInputValidityError"
|
||||
aria-label="Submit time bounds"
|
||||
@click.prevent="handleFormSubmission(true)"
|
||||
></button>
|
||||
@ -125,6 +129,7 @@ export default {
|
||||
return {
|
||||
timeFormatter: this.getFormatter(timeSystem.timeFormat),
|
||||
durationFormatter: this.getFormatter(timeSystem.durationFormat || DEFAULT_DURATION_FORMATTER),
|
||||
timeSystemKey: timeSystem.key,
|
||||
bounds: {
|
||||
start: bounds.start,
|
||||
end: bounds.end
|
||||
@ -136,9 +141,29 @@ export default {
|
||||
endTime: ''
|
||||
},
|
||||
isUTCBased: timeSystem.isUTCBased,
|
||||
isDisabled: false
|
||||
inputValidityMap: {
|
||||
startDate: { valid: true },
|
||||
startTime: { valid: true },
|
||||
endDate: { valid: true },
|
||||
endTime: { valid: true }
|
||||
},
|
||||
logicalValidityMap: {
|
||||
limit: { valid: true },
|
||||
bounds: { valid: true }
|
||||
}
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
hasInputValidityError() {
|
||||
return Object.values(this.inputValidityMap).some((isValid) => !isValid.valid);
|
||||
},
|
||||
hasLogicalValidationErrors() {
|
||||
return Object.values(this.logicalValidityMap).some((isValid) => !isValid.valid);
|
||||
},
|
||||
isValid() {
|
||||
return !this.hasInputValidityError && !this.hasLogicalValidationErrors;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
inputBounds: {
|
||||
handler(newBounds) {
|
||||
@ -168,25 +193,17 @@ export default {
|
||||
this.setBounds(bounds);
|
||||
this.setViewFromBounds(bounds);
|
||||
},
|
||||
clearAllValidation() {
|
||||
[this.$refs.startDate, this.$refs.endDate].forEach(this.clearValidationForInput);
|
||||
},
|
||||
clearValidationForInput(input) {
|
||||
if (input) {
|
||||
input.setCustomValidity('');
|
||||
input.title = '';
|
||||
}
|
||||
},
|
||||
setBounds(bounds) {
|
||||
this.bounds = bounds;
|
||||
},
|
||||
setViewFromBounds(bounds) {
|
||||
this.formattedBounds.start = this.timeFormatter.format(bounds.start).split(' ')[0];
|
||||
this.formattedBounds.end = this.timeFormatter.format(bounds.end).split(' ')[0];
|
||||
this.formattedBounds.startDate = this.timeFormatter.format(bounds.start).split(' ')[0];
|
||||
this.formattedBounds.endDate = this.timeFormatter.format(bounds.end).split(' ')[0];
|
||||
this.formattedBounds.startTime = this.durationFormatter.format(Math.abs(bounds.start));
|
||||
this.formattedBounds.endTime = this.durationFormatter.format(Math.abs(bounds.end));
|
||||
},
|
||||
setTimeSystem(timeSystem) {
|
||||
this.timeSystemKey = timeSystem.key;
|
||||
this.timeFormatter = this.getFormatter(timeSystem.timeFormat);
|
||||
this.durationFormatter = this.getFormatter(
|
||||
timeSystem.durationFormat || DEFAULT_DURATION_FORMATTER
|
||||
@ -201,10 +218,10 @@ export default {
|
||||
setBoundsFromView(dismiss) {
|
||||
if (this.$refs.fixedDeltaInput.checkValidity()) {
|
||||
let start = this.timeFormatter.parse(
|
||||
`${this.formattedBounds.start} ${this.formattedBounds.startTime}`
|
||||
`${this.formattedBounds.startDate} ${this.formattedBounds.startTime}`
|
||||
);
|
||||
let end = this.timeFormatter.parse(
|
||||
`${this.formattedBounds.end} ${this.formattedBounds.endTime}`
|
||||
`${this.formattedBounds.endDate} ${this.formattedBounds.endTime}`
|
||||
);
|
||||
|
||||
this.$emit('update', { start, end });
|
||||
@ -215,96 +232,93 @@ export default {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
handleFormSubmission(shouldDismiss) {
|
||||
this.validateAllBounds('startDate');
|
||||
this.validateAllBounds('endDate');
|
||||
clearAllValidation() {
|
||||
Object.keys(this.inputValidityMap).forEach(this.clearValidation);
|
||||
},
|
||||
clearValidation(refName) {
|
||||
const input = this.getInput(refName);
|
||||
|
||||
if (!this.isDisabled) {
|
||||
input.setCustomValidity('');
|
||||
input.title = '';
|
||||
},
|
||||
handleFormSubmission(shouldDismiss) {
|
||||
this.validateLimit();
|
||||
this.reportValidity('limit');
|
||||
this.validateBounds();
|
||||
this.reportValidity('bounds');
|
||||
|
||||
if (this.isValid) {
|
||||
this.setBoundsFromView(shouldDismiss);
|
||||
}
|
||||
},
|
||||
validateAllBounds(ref) {
|
||||
this.isDisabled = false;
|
||||
validateInput(refName) {
|
||||
this.clearAllValidation();
|
||||
|
||||
if (!this.areBoundsFormatsValid()) {
|
||||
this.isDisabled = true;
|
||||
return false;
|
||||
}
|
||||
const inputType = refName.includes('Date') ? 'Date' : 'Time';
|
||||
const formatter = inputType === 'Date' ? this.timeFormatter : this.durationFormatter;
|
||||
const validationResult = formatter.validate(this.formattedBounds[refName])
|
||||
? { valid: true }
|
||||
: { valid: false, message: `Invalid ${inputType}` };
|
||||
|
||||
let validationResult = { valid: true };
|
||||
const currentInput = this.$refs[ref];
|
||||
this.inputValidityMap[refName] = validationResult;
|
||||
},
|
||||
validateBounds() {
|
||||
const bounds = {
|
||||
start: this.timeFormatter.parse(
|
||||
`${this.formattedBounds.startDate} ${this.formattedBounds.startTime}`
|
||||
),
|
||||
end: this.timeFormatter.parse(
|
||||
`${this.formattedBounds.endDate} ${this.formattedBounds.endTime}`
|
||||
)
|
||||
};
|
||||
|
||||
return [this.$refs.startDate, this.$refs.endDate].every((input) => {
|
||||
let boundsValues = {
|
||||
start: this.timeFormatter.parse(
|
||||
`${this.formattedBounds.start} ${this.formattedBounds.startTime}`
|
||||
),
|
||||
end: this.timeFormatter.parse(
|
||||
`${this.formattedBounds.end} ${this.formattedBounds.endTime}`
|
||||
)
|
||||
this.logicalValidityMap.bounds = this.openmct.time.validateBounds(bounds);
|
||||
},
|
||||
validateLimit(bounds) {
|
||||
const limit = this.configuration?.menuOptions
|
||||
?.filter((option) => option.timeSystem === this.timeSystemKey)
|
||||
?.find((option) => option.limit)?.limit;
|
||||
|
||||
if (this.isUTCBased && limit && bounds.end - bounds.start > limit) {
|
||||
this.logicalValidityMap.limit = {
|
||||
valid: false,
|
||||
message: 'Start and end difference exceeds allowable limit'
|
||||
};
|
||||
//TODO: Do we need limits here? We have conductor limits disabled right now
|
||||
// const limit = this.getBoundsLimit();
|
||||
const limit = false;
|
||||
|
||||
if (this.isUTCBased && limit && boundsValues.end - boundsValues.start > limit) {
|
||||
if (input === currentInput) {
|
||||
validationResult = {
|
||||
valid: false,
|
||||
message: 'Start and end difference exceeds allowable limit'
|
||||
};
|
||||
}
|
||||
} else if (input === currentInput) {
|
||||
validationResult = this.openmct.time.validateBounds(boundsValues);
|
||||
}
|
||||
|
||||
return this.handleValidationResults(input, validationResult);
|
||||
});
|
||||
} else {
|
||||
this.logicalValidityMap.limit = { valid: true };
|
||||
}
|
||||
},
|
||||
areBoundsFormatsValid() {
|
||||
return [this.$refs.startDate, this.$refs.endDate].every((input) => {
|
||||
const formattedDate =
|
||||
input === this.$refs.startDate
|
||||
? `${this.formattedBounds.start} ${this.formattedBounds.startTime}`
|
||||
: `${this.formattedBounds.end} ${this.formattedBounds.endTime}`;
|
||||
reportValidity(refName) {
|
||||
const input = this.getInput(refName);
|
||||
const validationResult = this.inputValidityMap[refName] ?? this.logicalValidityMap[refName];
|
||||
|
||||
const validationResult = this.timeFormatter.validate(formattedDate)
|
||||
? { valid: true }
|
||||
: { valid: false, message: 'Invalid date' };
|
||||
|
||||
return this.handleValidationResults(input, validationResult);
|
||||
});
|
||||
},
|
||||
getBoundsLimit() {
|
||||
const configuration = this.configuration.menuOptions
|
||||
.filter((option) => option.timeSystem === this.timeSystem.key)
|
||||
.find((option) => option.limit);
|
||||
|
||||
const limit = configuration ? configuration.limit : undefined;
|
||||
|
||||
return limit;
|
||||
},
|
||||
handleValidationResults(input, validationResult) {
|
||||
if (validationResult.valid !== true) {
|
||||
input.setCustomValidity(validationResult.message);
|
||||
input.title = validationResult.message;
|
||||
this.isDisabled = true;
|
||||
this.hasLogicalValidationErrors = true;
|
||||
} else {
|
||||
input.setCustomValidity('');
|
||||
input.title = '';
|
||||
}
|
||||
|
||||
this.$refs.fixedDeltaInput.reportValidity();
|
||||
},
|
||||
getInput(refName) {
|
||||
if (Object.keys(this.inputValidityMap).includes(refName)) {
|
||||
return this.$refs[refName];
|
||||
}
|
||||
|
||||
return validationResult.valid;
|
||||
return this.$refs.startDate;
|
||||
},
|
||||
startDateSelected(date) {
|
||||
this.formattedBounds.start = this.timeFormatter.format(date).split(' ')[0];
|
||||
this.validateAllBounds('startDate');
|
||||
this.formattedBounds.startDate = this.timeFormatter.format(date).split(' ')[0];
|
||||
this.validateInput('startDate');
|
||||
this.reportValidity('startDate');
|
||||
},
|
||||
endDateSelected(date) {
|
||||
this.formattedBounds.end = this.timeFormatter.format(date).split(' ')[0];
|
||||
this.validateAllBounds('endDate');
|
||||
this.formattedBounds.endDate = this.timeFormatter.format(date).split(' ')[0];
|
||||
this.validateInput('endDate');
|
||||
this.reportValidity('endDate');
|
||||
},
|
||||
hide($event) {
|
||||
if ($event.target.className.indexOf('c-button icon-x') > -1) {
|
||||
|
@ -243,12 +243,20 @@ export default {
|
||||
this.timeContext.off(TIME_CONTEXT_EVENTS.modeChanged, this.setTimeOptionsMode);
|
||||
},
|
||||
setTimeOptionsClock(clock) {
|
||||
// If the user has persisted any time options, then don't override them with global settings.
|
||||
if (this.independentTCEnabled) {
|
||||
return;
|
||||
}
|
||||
this.setTimeOptionsOffsets();
|
||||
this.timeOptions.clock = clock.key;
|
||||
},
|
||||
setTimeOptionsMode(mode) {
|
||||
this.setTimeOptionsOffsets();
|
||||
this.timeOptions.mode = mode;
|
||||
// If the user has persisted any time options, then don't override them with global settings.
|
||||
if (this.independentTCEnabled) {
|
||||
this.setTimeOptionsOffsets();
|
||||
this.timeOptions.mode = mode;
|
||||
this.isFixed = this.timeOptions.mode === FIXED_MODE_KEY;
|
||||
}
|
||||
},
|
||||
setTimeOptionsOffsets() {
|
||||
this.timeOptions.clockOffsets =
|
||||
|
@ -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
|
||||
|
@ -21,7 +21,9 @@
|
||||
-->
|
||||
<template>
|
||||
<div ref="axisHolder" class="c-timesystem-axis">
|
||||
<div class="nowMarker" :style="nowMarkerStyle"><span class="icon-arrow-down"></span></div>
|
||||
<div class="nowMarker" :style="nowMarkerStyle" aria-label="Now Marker">
|
||||
<span class="icon-arrow-down"></span>
|
||||
</div>
|
||||
<svg :width="svgWidth" :height="svgHeight">
|
||||
<g class="axis" font-size="1.3em" :transform="axisTransform"></g>
|
||||
</svg>
|
||||
@ -79,6 +81,7 @@ export default {
|
||||
const svgWidth = ref(0);
|
||||
const svgHeight = ref(0);
|
||||
const axisTransform = ref('translate(0,20)');
|
||||
const alignmentOffset = ref(0);
|
||||
const nowMarkerStyle = reactive({
|
||||
height: '0px',
|
||||
left: '0px'
|
||||
@ -100,6 +103,7 @@ export default {
|
||||
svgWidth,
|
||||
svgHeight,
|
||||
axisTransform,
|
||||
alignmentOffset,
|
||||
nowMarkerStyle,
|
||||
openmct
|
||||
};
|
||||
@ -114,8 +118,10 @@ export default {
|
||||
this.axisTransform = `translate(${this.alignmentData.leftWidth + leftOffset}, 20)`;
|
||||
|
||||
const rightOffset = this.alignmentData.rightWidth ? AXES_PADDING : 0;
|
||||
|
||||
this.leftAlignmentOffset = this.alignmentData.leftWidth + leftOffset;
|
||||
this.alignmentOffset =
|
||||
this.alignmentData.leftWidth + leftOffset + this.alignmentData.rightWidth + rightOffset;
|
||||
this.leftAlignmentOffset + this.alignmentData.rightWidth + rightOffset;
|
||||
this.refresh();
|
||||
},
|
||||
deep: true
|
||||
@ -173,8 +179,8 @@ export default {
|
||||
this.nowMarkerStyle.height = this.contentHeight + 'px';
|
||||
const nowTimeStamp = this.openmct.time.now();
|
||||
const now = this.xScale(nowTimeStamp);
|
||||
this.nowMarkerStyle.left = `${now + this.alignmentOffset}px`;
|
||||
if (now > this.width) {
|
||||
this.nowMarkerStyle.left = `${now + this.leftAlignmentOffset}px`;
|
||||
if (now < 0 || now > this.width) {
|
||||
nowMarker.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
@ -32,6 +32,7 @@
|
||||
<script>
|
||||
import mount from 'utils/mount';
|
||||
|
||||
import { encode_url } from '../../utils/encoding';
|
||||
import AboutDialog from './AboutDialog.vue';
|
||||
|
||||
export default {
|
||||
@ -39,7 +40,7 @@ export default {
|
||||
mounted() {
|
||||
const branding = this.openmct.branding();
|
||||
if (branding.smallLogoImage) {
|
||||
this.$refs.aboutLogo.style.backgroundImage = `url('${branding.smallLogoImage}')`;
|
||||
this.$refs.aboutLogo.style.backgroundImage = `url('${encode_url(branding.smallLogoImage)}')`;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
@ -20,10 +20,11 @@
|
||||
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"
|
||||
aria-label="Navigate up to parent"
|
||||
class="l-browse-bar__nav-to-parent-button c-icon-button c-icon-button--major icon-arrow-nav-to-parent"
|
||||
title="Navigate up to parent"
|
||||
@click="goToParent"
|
||||
@ -34,9 +35,10 @@
|
||||
</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="isPersistable"
|
||||
:contenteditable="isNameEditable"
|
||||
@blur="updateName"
|
||||
@keydown.enter.prevent
|
||||
@keyup.enter.prevent="updateNameOnEnterKeyPress"
|
||||
@ -78,7 +80,7 @@
|
||||
></button>
|
||||
|
||||
<button
|
||||
v-if="isViewEditable & !isEditing"
|
||||
v-if="shouldShowLock"
|
||||
:aria-label="lockedOrUnlockedTitle"
|
||||
:title="lockedOrUnlockedTitle"
|
||||
:class="{
|
||||
@ -88,6 +90,13 @@
|
||||
@click="toggleLock(!domainObject.locked)"
|
||||
></button>
|
||||
|
||||
<span
|
||||
v-else-if="domainObject?.locked"
|
||||
class="icon-lock"
|
||||
aria-label="Locked for editing, cannot be unlocked."
|
||||
title="Locked for editing, cannot be unlocked."
|
||||
></span>
|
||||
|
||||
<button
|
||||
v-if="isViewEditable && !isEditing && !domainObject.locked"
|
||||
class="l-browse-bar__actions__edit c-button c-button--major icon-pencil"
|
||||
@ -180,6 +189,18 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isNameEditable() {
|
||||
return this.isPersistable && !this.domainObject.locked;
|
||||
},
|
||||
shouldShowLock() {
|
||||
if (this.domainObject === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (this.domainObject.disallowUnlock) {
|
||||
return false;
|
||||
}
|
||||
return this.domainObject.locked || (this.isViewEditable && !this.isEditing);
|
||||
},
|
||||
statusClass() {
|
||||
return this.status ? `is-status--${this.status}` : '';
|
||||
},
|
||||
@ -253,11 +274,19 @@ export default {
|
||||
return false;
|
||||
},
|
||||
lockedOrUnlockedTitle() {
|
||||
let title;
|
||||
if (this.domainObject.locked) {
|
||||
return 'Locked for editing - click to unlock.';
|
||||
if (this.domainObject.lockedBy !== undefined) {
|
||||
title = `Locked for editing by ${this.domainObject.lockedBy}. `;
|
||||
} else {
|
||||
title = 'Locked for editing. ';
|
||||
}
|
||||
title += 'Click to unlock.';
|
||||
} else {
|
||||
return 'Unlocked for editing - click to lock.';
|
||||
title = 'Unlocked for editing, click to lock.';
|
||||
}
|
||||
|
||||
return title;
|
||||
},
|
||||
domainObjectName() {
|
||||
return this.domainObject?.name ?? '';
|
||||
@ -288,7 +317,6 @@ export default {
|
||||
document.addEventListener('click', this.closeViewAndSaveMenu);
|
||||
this.promptUserbeforeNavigatingAway = this.promptUserbeforeNavigatingAway.bind(this);
|
||||
window.addEventListener('beforeunload', this.promptUserbeforeNavigatingAway);
|
||||
|
||||
this.openmct.editor.on('isEditing', (isEditing) => {
|
||||
this.isEditing = isEditing;
|
||||
});
|
||||
@ -421,8 +449,27 @@ export default {
|
||||
this.actionCollection.off('update', this.updateActionItems);
|
||||
delete this.actionCollection;
|
||||
},
|
||||
toggleLock(flag) {
|
||||
this.openmct.objects.mutate(this.domainObject, 'locked', flag);
|
||||
async toggleLock(flag) {
|
||||
if (!this.domainObject.disallowUnlock) {
|
||||
const wasTransactionActive = this.openmct.objects.isTransactionActive();
|
||||
let transaction;
|
||||
|
||||
if (!wasTransactionActive) {
|
||||
transaction = this.openmct.objects.startTransaction();
|
||||
}
|
||||
|
||||
this.openmct.objects.mutate(this.domainObject, 'locked', flag);
|
||||
const user = await this.openmct.user.getCurrentUser();
|
||||
|
||||
if (user !== undefined) {
|
||||
this.openmct.objects.mutate(this.domainObject, 'lockedBy', user.id);
|
||||
}
|
||||
|
||||
if (!wasTransactionActive) {
|
||||
await transaction.commit();
|
||||
this.openmct.objects.endTransaction();
|
||||
}
|
||||
}
|
||||
},
|
||||
setStatus(status) {
|
||||
this.status = status;
|
||||
|
@ -111,9 +111,8 @@ export default {
|
||||
return null;
|
||||
}
|
||||
|
||||
const keyStringForObject = this.openmct.objects.makeKeyString(domainObject.identifier);
|
||||
const originalPathObjects = await this.openmct.objects.getOriginalPath(
|
||||
keyStringForObject,
|
||||
domainObject,
|
||||
[],
|
||||
abortSignal
|
||||
);
|
||||
|
@ -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)) {
|
||||
|
25
src/utils/encoding.js
Normal file
25
src/utils/encoding.js
Normal file
@ -0,0 +1,25 @@
|
||||
/*****************************************************************************
|
||||
* 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.
|
||||
*****************************************************************************/
|
||||
|
||||
export function encode_url(url) {
|
||||
return url ? encodeURI(url) : url;
|
||||
}
|
12
src/utils/random.js
Normal file
12
src/utils/random.js
Normal file
@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Generates a pseudo-random number based on a seed.
|
||||
*
|
||||
* @param {number} seed - The seed value to generate the random number.
|
||||
* @returns {number} A pseudo-random number between 0 (inclusive) and 1 (exclusive).
|
||||
*/
|
||||
function seededRandom(seed = Date.now()) {
|
||||
const x = Math.sin(seed) * 10000;
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
|
||||
export { seededRandom };
|
Reference in New Issue
Block a user