diff --git a/.circleci/config.yml b/.circleci/config.yml index 6125b0089b..16110bae2f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -232,9 +232,9 @@ jobs: workflows: overall-circleci-commit-status: #These jobs run on every commit jobs: - # - lint: - # name: node16-lint - # node-version: lts/gallium + - lint: + name: node16-lint + node-version: lts/gallium - unit-test: name: node18-chrome node-version: lts/hydrogen diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index ab9bcde005..6aa1fd0665 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -4,3 +4,9 @@ # Requires Git > 2.23 # See https://git-scm.com/docs/git-blame#Documentation/git-blame.txt---ignore-revs-fileltfilegt +# Copyright year update 2022 +4a9744e916d24122a81092f6b7950054048ba860 +# Copyright year update 2023 +8040b275fcf2ba71b42cd72d4daa64bb25c19c2d +# Apply `prettier` formatting +caa7bc6faebc204f67aedae3e35fb0d0d3ce27a7 diff --git a/.github/workflows/e2e-couchdb.yml b/.github/workflows/e2e-couchdb.yml index e442800b72..cada516eb8 100644 --- a/.github/workflows/e2e-couchdb.yml +++ b/.github/workflows/e2e-couchdb.yml @@ -7,15 +7,33 @@ on: - opened jobs: e2e-couchdb: - if: ${{ github.event.label.name == 'pr:e2e:couchdb' }} || ${{ github.event.action == 'opened' }} + if: github.event.label.name == 'pr:e2e:couchdb' || github.event.action == 'opened' && github.actor == 'dependabot[bot]' runs-on: ubuntu-latest + timeout-minutes: 60 steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: 'lts/gallium' + node-version: 'lts/hydrogen' + + - name: Cache NPM dependencies + uses: actions/cache@v3 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ hashFiles('**/package.json') }} + restore-keys: | + ${{ runner.os }}-node- + + - run: npm install --cache ~/.npm --prefer-offline --no-audit --progress=false + + - name: Login to DockerHub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - run: npx playwright@1.32.3 install - - run: npm install + - name: Start CouchDB Docker Container and Init with Setup Scripts run: | export $(cat src/plugins/persistence/couch/.env.ci | xargs) @@ -23,26 +41,31 @@ jobs: sleep 3 bash src/plugins/persistence/couch/setup-couchdb.sh bash src/plugins/persistence/couch/replace-localstorage-with-couchdb-indexhtml.sh + - name: Run CouchDB Tests and publish to deploysentinel env: DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }} run: npm run test:e2e:couchdb + - name: Publish Results to Codecov.io env: SUPER_SECRET: ${{ secrets.CODECOV_TOKEN }} run: npm run cov:e2e:full:publish + - name: Archive test results if: success() || failure() uses: actions/upload-artifact@v3 with: path: test-results + - name: Archive html test results if: success() || failure() uses: actions/upload-artifact@v3 with: path: html-test-results + - name: Remove pr:e2e:couchdb label (if present) - if: ${{ contains(github.event.pull_request.labels.*.name, 'pr:e2e:couchdb') }} + if: always() uses: actions/github-script@v6 with: script: | @@ -56,5 +79,5 @@ jobs: name: labelToRemove }); } catch (error) { - core.warning(`Failed to remove 'pr:e2e:couchdb' label: ${error.message}`); + core.warning(`Failed to remove ' + labelToRemove + ' label: ${error.message}`); } diff --git a/.github/workflows/e2e-pr.yml b/.github/workflows/e2e-pr.yml index 62cd32dba9..0e4a8d9a9d 100644 --- a/.github/workflows/e2e-pr.yml +++ b/.github/workflows/e2e-pr.yml @@ -7,31 +7,31 @@ on: - opened jobs: e2e-full: - if: ${{ github.event.label.name == 'pr:e2e' }} + if: github.event.label.name == 'pr:e2e' || github.event.action == 'opened' && github.actor == 'dependabot[bot]' runs-on: ${{ matrix.os }} + timeout-minutes: 60 strategy: matrix: os: - ubuntu-latest - windows-latest steps: - - name: Trigger Success - uses: actions/github-script@v6 - with: - script: | - github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: "nasa", - repo: "openmct", - body: 'Started e2e Run. Follow along: https://github.com/nasa/openmct/actions/runs/' + context.runId - }) - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: '16' + node-version: 'lts/hydrogen' + + - name: Cache NPM dependencies + uses: actions/cache@v3 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ hashFiles('**/package.json') }} + restore-keys: | + ${{ runner.os }}-node- + - run: npx playwright@1.32.3 install - run: npx playwright install chrome-beta - - run: npm install + - run: npm install --cache ~/.npm --prefer-offline --no-audit --progress=false - run: npm run test:e2e:full -- --max-failures=40 - run: npm run cov:e2e:report || true - shell: bash @@ -44,30 +44,9 @@ jobs: uses: actions/upload-artifact@v3 with: path: test-results - - name: Test success - if: ${{ success() }} - uses: actions/github-script@v6 - with: - script: | - github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: "nasa", - repo: "openmct", - body: 'Success ✅ ! Build artifacts are here: https://github.com/nasa/openmct/actions/runs/' + context.runId - }) - - name: Test failure - if: ${{ failure() }} - uses: actions/github-script@v6 - with: - script: | - github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: "nasa", - repo: "openmct", - body: 'Failure ❌ ! Build artifacts are here: https://github.com/nasa/openmct/actions/runs/' + context.runId - }) + - name: Remove pr:e2e label (if present) - if: ${{ contains(github.event.pull_request.labels.*.name, 'pr:e2e') }} + if: always() uses: actions/github-script@v6 with: script: | @@ -81,5 +60,5 @@ jobs: name: labelToRemove }); } catch (error) { - core.warning(`Failed to remove 'pr:e2e' label: ${error.message}`); - } + core.warning(`Failed to remove ' + labelToRemove + ' label: ${error.message}`); + } \ No newline at end of file diff --git a/.github/workflows/npm-prerelease.yml b/.github/workflows/npm-prerelease.yml index a9321ac569..605ebe8bba 100644 --- a/.github/workflows/npm-prerelease.yml +++ b/.github/workflows/npm-prerelease.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: 16 + node-version: lts/hydrogen - run: npm install - run: | echo "//registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN" >> ~/.npmrc @@ -29,7 +29,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: 16 + node-version: lts/hydrogen registry-url: https://registry.npmjs.org/ - run: npm install - run: npm publish --access=public --tag unstable diff --git a/.github/workflows/pr-platform.yml b/.github/workflows/pr-platform.yml index f56ff576c2..1f1cdfbaa1 100644 --- a/.github/workflows/pr-platform.yml +++ b/.github/workflows/pr-platform.yml @@ -2,12 +2,15 @@ name: 'pr-platform' on: workflow_dispatch: pull_request: - types: [labeled] + types: + - labeled + - opened jobs: - e2e-full: - if: ${{ github.event.label.name == 'pr:platform' }} + pr-platform: + if: github.event.label.name == 'pr:platform' || github.event.action == 'opened' && github.actor == 'dependabot[bot]' runs-on: ${{ matrix.os }} + timeout-minutes: 60 strategy: fail-fast: false matrix: @@ -16,18 +19,49 @@ jobs: - macos-latest - windows-latest node_version: - - 16 - - 18 + - lts/gallium + - lts/hydrogen architecture: - x64 + name: Node ${{ matrix.node_version }} - ${{ matrix.architecture }} on ${{ matrix.os }} steps: - uses: actions/checkout@v3 + - name: Setup node uses: actions/setup-node@v3 with: node-version: ${{ matrix.node_version }} architecture: ${{ matrix.architecture }} - - run: npm install + + - name: Cache NPM dependencies + uses: actions/cache@v3 + with: + path: ~/.npm + key: ${{ runner.os }}-${{ matrix.node_version }}-${{ hashFiles('**/package.json') }} + restore-keys: | + ${{ runner.os }}-${{ matrix.node_version }}- + + - run: npm install --cache ~/.npm --prefer-offline --no-audit --progress=false + - run: npm test + - run: npm run lint -- --quiet + + - name: Remove pr:platform label (if present) + if: always() + uses: actions/github-script@v6 + with: + script: | + const { owner, repo, number } = context.issue; + const labelToRemove = 'pr:platform'; + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: number, + name: labelToRemove + }); + } catch (error) { + core.warning(`Failed to remove ' + labelToRemove + ' label: ${error.message}`); + } diff --git a/.prettierrc b/.prettierrc index aaae3c5c5f..479112ec1d 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,5 +1,6 @@ { "trailingComma": "none", "singleQuote": true, - "printWidth": 100 + "printWidth": 100, + "endOfLine": "auto" } diff --git a/.webpack/webpack.common.js b/.webpack/webpack.common.js index 797340753c..a1e53a4dc4 100644 --- a/.webpack/webpack.common.js +++ b/.webpack/webpack.common.js @@ -67,7 +67,6 @@ const config = { MCT: path.join(projectRootDir, 'src/MCT'), testUtils: path.join(projectRootDir, 'src/utils/testUtils.js'), objectUtils: path.join(projectRootDir, 'src/api/objects/object-utils.js'), - kdbush: path.join(projectRootDir, 'node_modules/kdbush/kdbush.min.js'), utils: path.join(projectRootDir, 'src/utils') } }, diff --git a/e2e/tests/functional/planning/timelist.e2e.spec.js b/e2e/tests/functional/planning/timelist.e2e.spec.js new file mode 100644 index 0000000000..54f65019f6 --- /dev/null +++ b/e2e/tests/functional/planning/timelist.e2e.spec.js @@ -0,0 +1,122 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2023, 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. + *****************************************************************************/ + +const { test, expect } = require('../../../pluginFixtures'); +const { createDomainObjectWithDefaults, createPlanFromJSON } = require('../../../appActions'); + +const testPlan = { + TEST_GROUP: [ + { + name: 'Past event 1', + start: 1660320408000, + end: 1660343797000, + type: 'TEST-GROUP', + color: 'orange', + textColor: 'white' + }, + { + name: 'Past event 2', + start: 1660406808000, + end: 1660429160000, + type: 'TEST-GROUP', + color: 'orange', + textColor: 'white' + }, + { + name: 'Past event 3', + start: 1660493208000, + end: 1660503981000, + type: 'TEST-GROUP', + color: 'orange', + textColor: 'white' + }, + { + name: 'Past event 4', + start: 1660579608000, + end: 1660624108000, + type: 'TEST-GROUP', + color: 'orange', + textColor: 'white' + }, + { + name: 'Past event 5', + start: 1660666008000, + end: 1660681529000, + type: 'TEST-GROUP', + color: 'orange', + textColor: 'white' + } + ] +}; + +test.describe('Time List', () => { + test('Create a Time List, add a single Plan to it and verify all the activities are displayed with no milliseconds', async ({ + page + }) => { + // Goto baseURL + await page.goto('./', { waitUntil: 'domcontentloaded' }); + + const timelist = await test.step('Create a Time List', async () => { + const createdTimeList = await createDomainObjectWithDefaults(page, { type: 'Time List' }); + const objectName = await page.locator('.l-browse-bar__object-name').innerText(); + expect(objectName).toBe(createdTimeList.name); + + return createdTimeList; + }); + + await test.step('Create a Plan and add it to the timelist', async () => { + const createdPlan = await createPlanFromJSON(page, { + name: 'Test Plan', + json: testPlan + }); + + await page.goto(timelist.url); + // Expand the tree to show the plan + await page.click("button[title='Show selected item in tree']"); + await page.dragAndDrop(`role=treeitem[name=/${createdPlan.name}/]`, '.c-object-view'); + await page.click("button[title='Save']"); + await page.click("li[title='Save and Finish Editing']"); + const startBound = testPlan.TEST_GROUP[0].start; + const endBound = testPlan.TEST_GROUP[testPlan.TEST_GROUP.length - 1].end; + + // Switch to fixed time mode with all plan events within the bounds + await page.goto( + `${timelist.url}?tc.mode=fixed&tc.startBound=${startBound}&tc.endBound=${endBound}&tc.timeSystem=utc&view=timelist.view` + ); + + // Verify all events are displayed + const eventCount = await page.locator('.js-list-item').count(); + expect(eventCount).toEqual(testPlan.TEST_GROUP.length); + }); + + await test.step('Does not show milliseconds in times', async () => { + // Get the first activity + const row = await page.locator('.js-list-item').first(); + // Verify that none fo the times have milliseconds displayed. + // Example: 2024-11-17T16:00:00Z is correct and 2024-11-17T16:00:00.000Z is wrong + + await expect(row.locator('.--start')).not.toContainText('.'); + await expect(row.locator('.--end')).not.toContainText('.'); + await expect(row.locator('.--duration')).not.toContainText('.'); + }); + }); +}); diff --git a/e2e/tests/functional/plugins/displayLayout/displayLayout.e2e.spec.js b/e2e/tests/functional/plugins/displayLayout/displayLayout.e2e.spec.js index 37c413755b..5c41f2d418 100644 --- a/e2e/tests/functional/plugins/displayLayout/displayLayout.e2e.spec.js +++ b/e2e/tests/functional/plugins/displayLayout/displayLayout.e2e.spec.js @@ -205,6 +205,71 @@ test.describe('Display Layout', () => { expect(await page.locator('.l-layout .l-layout__frame').count()).toEqual(0); }); + + test('When multiple plots are contained in a layout, we only ask for annotations once @couchdb', async ({ + page + }) => { + // Create another Sine Wave Generator + const anotherSineWaveObject = await createDomainObjectWithDefaults(page, { + type: 'Sine Wave Generator' + }); + // Create a Display Layout + await createDomainObjectWithDefaults(page, { + type: 'Display Layout', + name: 'Test Display Layout' + }); + // Edit Display Layout + await page.locator('[title="Edit"]').click(); + + // Expand the 'My Items' folder in the left tree + await page.locator('.c-tree__item__view-control.c-disclosure-triangle').click(); + // Add the Sine Wave Generator to the Display Layout and save changes + const treePane = page.getByRole('tree', { + name: 'Main Tree' + }); + const sineWaveGeneratorTreeItem = treePane.getByRole('treeitem', { + name: new RegExp(sineWaveObject.name) + }); + + let layoutGridHolder = page.locator('.l-layout__grid-holder'); + // eslint-disable-next-line playwright/no-force-option + await sineWaveGeneratorTreeItem.dragTo(layoutGridHolder, { force: true }); + + await page.getByText('View type').click(); + await page.getByText('Overlay Plot').click(); + + const anotherSineWaveGeneratorTreeItem = treePane.getByRole('treeitem', { + name: new RegExp(anotherSineWaveObject.name) + }); + layoutGridHolder = page.locator('.l-layout__grid-holder'); + // eslint-disable-next-line playwright/no-force-option + await anotherSineWaveGeneratorTreeItem.dragTo(layoutGridHolder, { force: true }); + + await page.getByText('View type').click(); + await page.getByText('Overlay Plot').click(); + + await page.locator('button[title="Save"]').click(); + await page.locator('text=Save and Finish Editing').click(); + + // Time to inspect some network traffic + let networkRequests = []; + page.on('request', (request) => { + const searchRequest = request.url().endsWith('_find'); + const fetchRequest = request.resourceType() === 'fetch'; + if (searchRequest && fetchRequest) { + networkRequests.push(request); + } + }); + + await page.reload(); + + // wait for annotations requests to be batched and requested + await page.waitForLoadState('networkidle'); + + // Network requests for the composite telemetry with multiple items should be: + // 1. a single batched request for annotations + expect(networkRequests.length).toBe(1); + }); }); /** diff --git a/e2e/tests/functional/plugins/imagery/exampleImagery.e2e.spec.js b/e2e/tests/functional/plugins/imagery/exampleImagery.e2e.spec.js index b759821c30..16792644d6 100644 --- a/e2e/tests/functional/plugins/imagery/exampleImagery.e2e.spec.js +++ b/e2e/tests/functional/plugins/imagery/exampleImagery.e2e.spec.js @@ -30,6 +30,7 @@ const { test, expect } = require('../../../../pluginFixtures'); const { createDomainObjectWithDefaults } = require('../../../../appActions'); const backgroundImageSelector = '.c-imagery__main-image__background-image'; const panHotkey = process.platform === 'linux' ? ['Shift', 'Alt'] : ['Alt']; +const tagHotkey = ['Shift', 'Alt']; const expectedAltText = process.platform === 'linux' ? 'Shift+Alt drag to pan' : 'Alt drag to pan'; const thumbnailUrlParamsRegexp = /\?w=100&h=100/; @@ -44,7 +45,7 @@ test.describe('Example Imagery Object', () => { // Verify that the created object is focused await expect(page.locator('.l-browse-bar__object-name')).toContainText(exampleImagery.name); - await page.locator(backgroundImageSelector).hover({ trial: true }); + await page.locator('.c-imagery__main-image__bg').hover({ trial: true }); }); test('Can use Mouse Wheel to zoom in and out of latest image', async ({ page }) => { @@ -72,11 +73,11 @@ test.describe('Example Imagery Object', () => { test('Can use alt+drag to move around image once zoomed in', async ({ page }) => { const deltaYStep = 100; //equivalent to 1x zoom - await page.locator(backgroundImageSelector).hover({ trial: true }); + await page.locator('.c-imagery__main-image__bg').hover({ trial: true }); // zoom in await page.mouse.wheel(0, deltaYStep * 2); - await page.locator(backgroundImageSelector).hover({ trial: true }); + await page.locator('.c-imagery__main-image__bg').hover({ trial: true }); const zoomedBoundingBox = await page.locator(backgroundImageSelector).boundingBox(); const imageCenterX = zoomedBoundingBox.x + zoomedBoundingBox.width / 2; const imageCenterY = zoomedBoundingBox.y + zoomedBoundingBox.height / 2; @@ -131,6 +132,36 @@ test.describe('Example Imagery Object', () => { expect(afterDownPanBoundingBox.y).toBeLessThan(afterUpPanBoundingBox.y); }); + test('Can use alt+shift+drag to create a tag', async ({ page }) => { + const canvas = page.locator('canvas'); + await canvas.hover({ trial: true }); + + const canvasBoundingBox = await canvas.boundingBox(); + const canvasCenterX = canvasBoundingBox.x + canvasBoundingBox.width / 2; + const canvasCenterY = canvasBoundingBox.y + canvasBoundingBox.height / 2; + + await Promise.all(tagHotkey.map((x) => page.keyboard.down(x))); + await page.mouse.down(); + // steps not working for me here + await page.mouse.move(canvasCenterX - 20, canvasCenterY - 20); + await page.mouse.move(canvasCenterX - 100, canvasCenterY - 100); + await page.mouse.up(); + await Promise.all(tagHotkey.map((x) => page.keyboard.up(x))); + + //Wait for canvas to stablize. + await canvas.hover({ trial: true }); + + // add some tags + await page.getByText('Annotations').click(); + await page.getByRole('button', { name: /Add Tag/ }).click(); + await page.getByPlaceholder('Type to select tag').click(); + await page.getByText('Driving').click(); + + await page.getByRole('button', { name: /Add Tag/ }).click(); + await page.getByPlaceholder('Type to select tag').click(); + await page.getByText('Science').click(); + }); + test('Can use + - buttons to zoom on the image @unstable', async ({ page }) => { await buttonZoomOnImageAndAssert(page); }); @@ -185,24 +216,7 @@ test.describe('Example Imagery in Display Layout', () => { displayLayout = await createDomainObjectWithDefaults(page, { type: 'Display Layout' }); await page.goto(displayLayout.url); - /* Create Sine Wave Generator with minimum Image Load Delay */ - // Click the Create button - await page.click('button:has-text("Create")'); - - // Click text=Example Imagery - await page.click('li[role="menuitem"]:has-text("Example Imagery")'); - - // Clear and set Image load delay to minimum value - await page.locator('input[type="number"]').fill(''); - await page.locator('input[type="number"]').fill('5000'); - - // Click text=OK - await Promise.all([ - page.waitForNavigation({ waitUntil: 'networkidle' }), - page.click('button:has-text("OK")'), - //Wait for Save Banner to appear - page.waitForSelector('.c-message-banner__message') - ]); + await createImageryView(page); await expect(page.locator('.l-browse-bar__object-name')).toContainText( 'Unnamed Example Imagery' @@ -315,9 +329,47 @@ test.describe('Example Imagery in Display Layout', () => { await page.locator('div[title="Resize object height"] > input').click(); await page.locator('div[title="Resize object height"] > input').fill('100'); - expect(thumbsWrapperLocator.isVisible()).toBeTruthy(); + await expect(thumbsWrapperLocator).toBeVisible(); await expect(thumbsWrapperLocator).not.toHaveClass(/is-small-thumbs/); }); + + /** + * Toggle layer visibility checkbox by clicking on checkbox label + * - should toggle checkbox and layer visibility for that image view + * - should NOT toggle checkbox and layer visibity for the first image view in display + */ + test('Toggle layer visibility by clicking on label', async ({ page }) => { + test.info().annotations.push({ + type: 'issue', + description: 'https://github.com/nasa/openmct/issues/6709' + }); + await createImageryView(page); + await page.goto(displayLayout.url); + + const imageElements = page.locator('.c-imagery__main-image-wrapper'); + + await expect(imageElements).toHaveCount(2); + + const imageOne = page.locator('.c-imagery__main-image-wrapper').nth(0); + const imageTwo = page.locator('.c-imagery__main-image-wrapper').nth(1); + const imageOneWrapper = imageOne.locator('.image-wrapper'); + const imageTwoWrapper = imageTwo.locator('.image-wrapper'); + + await imageTwo.hover(); + + await imageTwo.locator('button[title="Layers"]').click(); + + const imageTwoLayersMenuContent = imageTwo.locator('button[title="Layers"] + div'); + const imageTwoLayersToggleLabel = imageTwoLayersMenuContent.locator('label').last(); + + await imageTwoLayersToggleLabel.click(); + + const imageOneLayers = imageOneWrapper.locator('.layer-image'); + const imageTwoLayers = imageTwoWrapper.locator('.layer-image'); + + await expect(imageOneLayers).toHaveCount(0); + await expect(imageTwoLayers).toHaveCount(1); + }); }); test.describe('Example Imagery in Flexible layout', () => { @@ -692,7 +744,6 @@ async function panZoomAndAssertImageProperties(page) { async function mouseZoomOnImageAndAssert(page, factor = 2) { // Zoom in const originalImageDimensions = await page.locator(backgroundImageSelector).boundingBox(); - await page.locator(backgroundImageSelector).hover({ trial: true }); const deltaYStep = 100; // equivalent to 1x zoom await page.mouse.wheel(0, deltaYStep * factor); const zoomedBoundingBox = await page.locator(backgroundImageSelector).boundingBox(); @@ -703,7 +754,7 @@ async function mouseZoomOnImageAndAssert(page, factor = 2) { await page.mouse.move(imageCenterX, imageCenterY); // Wait for zoom animation to finish - await page.locator(backgroundImageSelector).hover({ trial: true }); + await page.locator('.c-imagery__main-image__bg').hover({ trial: true }); const imageMouseZoomed = await page.locator(backgroundImageSelector).boundingBox(); if (factor > 0) { @@ -819,3 +870,26 @@ async function resetImageryPanAndZoom(page) { await panZoomResetBtn.click(); await waitForAnimations(backgroundImage); } + +/** + * @param {import('@playwright/test').Page} page + */ +async function createImageryView(page) { + // Click the Create button + await page.click('button:has-text("Create")'); + + // Click text=Example Imagery + await page.click('li[role="menuitem"]:has-text("Example Imagery")'); + + // Clear and set Image load delay to minimum value + await page.locator('input[type="number"]').fill(''); + await page.locator('input[type="number"]').fill('5000'); + + // Click text=OK + await Promise.all([ + page.waitForNavigation({ waitUntil: 'networkidle' }), + page.click('button:has-text("OK")'), + //Wait for Save Banner to appear + page.waitForSelector('.c-message-banner__message') + ]); +} diff --git a/package.json b/package.json index c41900879b..bc13c42066 100644 --- a/package.json +++ b/package.json @@ -1,39 +1,40 @@ { "name": "openmct", - "version": "2.2.4-SNAPSHOT", + "version": "2.2.6-SNAPSHOT", "description": "The Open MCT core platform", "devDependencies": { - "@babel/eslint-parser": "7.19.1", + "@babel/eslint-parser": "7.22.5", "@braintree/sanitize-url": "6.0.2", "@deploysentinel/playwright": "0.3.4", - "@percy/cli": "1.24.0", + "@percy/cli": "1.26.0", "@percy/playwright": "1.0.4", "@playwright/test": "1.32.3", "@types/eventemitter3": "1.2.0", - "@types/jasmine": "4.3.1", + "@types/jasmine": "4.3.4", "@types/lodash": "4.14.192", "babel-loader": "9.1.0", "babel-plugin-istanbul": "6.1.1", "codecov": "3.8.3", "comma-separated-values": "3.6.4", "copy-webpack-plugin": "11.0.0", - "css-loader": "6.7.3", + "css-loader": "6.8.1", "d3-axis": "3.0.0", "d3-scale": "3.3.0", "d3-selection": "3.0.0", - "eslint": "8.40.0", + "eslint": "8.43.0", "eslint-plugin-compat": "4.1.4", "eslint-config-prettier": "8.8.0", "eslint-plugin-playwright": "0.12.0", "eslint-plugin-prettier": "4.2.1", - "eslint-plugin-vue": "9.13.0", + "eslint-plugin-vue": "9.15.0", "eslint-plugin-you-dont-need-lodash-underscore": "6.12.0", "eventemitter3": "1.2.0", "file-saver": "2.0.5", + "flatbush": "4.1.0", "git-rev-sync": "3.0.2", "html2canvas": "1.4.1", "imports-loader": "4.0.1", - "jasmine-core": "4.5.0", + "jasmine-core": "5.0.0", "karma": "6.4.2", "karma-chrome-launcher": "3.2.0", "karma-cli": "2.0.0", @@ -44,10 +45,9 @@ "karma-sourcemap-loader": "0.4.0", "karma-spec-reporter": "0.0.36", "karma-webpack": "5.0.0", - "kdbush": "3.0.0", "location-bar": "3.0.1", "lodash": "4.17.21", - "mini-css-extract-plugin": "2.7.5", + "mini-css-extract-plugin": "2.7.6", "moment": "2.29.4", "moment-duration-format": "2.3.2", "moment-timezone": "0.5.41", @@ -60,20 +60,20 @@ "printj": "1.3.1", "resolve-url-loader": "5.0.0", "sanitize-html": "2.10.0", - "sass": "1.62.1", - "sass-loader": "13.2.2", - "sinon": "15.0.1", - "style-loader": "3.3.2", - "typescript": "5.0.4", + "sass": "1.63.4", + "sass-loader": "13.3.2", + "sinon": "15.1.0", + "style-loader": "3.3.3", + "typescript": "5.1.3", "uuid": "9.0.0", "vue": "2.6.14", - "vue-eslint-parser": "9.2.1", + "vue-eslint-parser": "9.3.1", "vue-loader": "15.9.8", "vue-template-compiler": "2.6.14", - "webpack": "5.81.0", - "webpack-cli": "5.0.2", - "webpack-dev-server": "4.13.3", - "webpack-merge": "5.8.0" + "webpack": "5.86.0", + "webpack-cli": "5.1.1", + "webpack-dev-server": "4.15.1", + "webpack-merge": "5.9.0" }, "scripts": { "clean": "rm -rf ./dist ./node_modules ./package-lock.json ./coverage ./html-test-results ./test-results ./.nyc_output ", @@ -111,7 +111,7 @@ "url": "https://github.com/nasa/openmct.git" }, "engines": { - "node": ">=16.19.1" + "node": ">=16.19.1 <20" }, "browserslist": [ "Firefox ESR", diff --git a/src/api/annotation/AnnotationAPI.js b/src/api/annotation/AnnotationAPI.js index 72f3d74d95..f13a26de58 100644 --- a/src/api/annotation/AnnotationAPI.js +++ b/src/api/annotation/AnnotationAPI.js @@ -76,6 +76,9 @@ const ANNOTATION_LAST_CREATED = 'annotationLastCreated'; * @constructor */ export default class AnnotationAPI extends EventEmitter { + /** @type {Map boolean >>} */ + #targetComparatorMap; + /** * @param {OpenMCT} openmct */ @@ -84,6 +87,7 @@ export default class AnnotationAPI extends EventEmitter { this.openmct = openmct; this.availableTags = {}; this.namespaceToSaveAnnotations = ''; + this.#targetComparatorMap = new Map(); this.ANNOTATION_TYPES = ANNOTATION_TYPES; this.ANNOTATION_TYPE = ANNOTATION_TYPE; @@ -246,15 +250,16 @@ export default class AnnotationAPI extends EventEmitter { /** * @method getAnnotations * @param {Identifier} domainObjectIdentifier - The domain object identifier to use to search for annotations. For example, a telemetry object identifier. + * @param {AbortSignal} abortSignal - An abort signal to cancel the search * @returns {DomainObject[]} Returns an array of annotations that match the search query */ - async getAnnotations(domainObjectIdentifier) { + async getAnnotations(domainObjectIdentifier, abortSignal = null) { const keyStringQuery = this.openmct.objects.makeKeyString(domainObjectIdentifier); const searchResults = ( await Promise.all( this.openmct.objects.search( keyStringQuery, - null, + abortSignal, this.openmct.objects.SEARCH_TYPES.ANNOTATIONS ) ) @@ -384,7 +389,8 @@ export default class AnnotationAPI extends EventEmitter { const combinedResults = []; results.forEach((currentAnnotation) => { const existingAnnotation = combinedResults.find((annotationToFind) => { - return _.isEqual(currentAnnotation.targets, annotationToFind.targets); + const { annotationType, targets } = currentAnnotation; + return this.areAnnotationTargetsEqual(annotationType, targets, annotationToFind.targets); }); if (!existingAnnotation) { combinedResults.push(currentAnnotation); @@ -460,4 +466,35 @@ export default class AnnotationAPI extends EventEmitter { return breakApartSeparateTargets; } + + /** + * Adds a comparator function for a given annotation type. + * The comparator functions will be used to determine if two annotations + * have the same target. + * @param {ANNOTATION_TYPES} annotationType + * @param {(t1, t2) => boolean} comparator + */ + addTargetComparator(annotationType, comparator) { + const comparatorList = this.#targetComparatorMap.get(annotationType) ?? []; + comparatorList.push(comparator); + this.#targetComparatorMap.set(annotationType, comparatorList); + } + + /** + * Compare two sets of targets to see if they are equal. First checks if + * any targets comparators evaluate to true, then falls back to a deep + * equality check. + * @param {ANNOTATION_TYPES} annotationType + * @param {*} targets + * @param {*} otherTargets + * @returns true if the targets are equal, false otherwise + */ + areAnnotationTargetsEqual(annotationType, targets, otherTargets) { + const targetComparatorList = this.#targetComparatorMap.get(annotationType); + return ( + (targetComparatorList?.length && + targetComparatorList.some((targetComparator) => targetComparator(targets, otherTargets))) || + _.isEqual(targets, otherTargets) + ); + } } diff --git a/src/api/annotation/AnnotationAPISpec.js b/src/api/annotation/AnnotationAPISpec.js index 7be711d235..d03d93b127 100644 --- a/src/api/annotation/AnnotationAPISpec.js +++ b/src/api/annotation/AnnotationAPISpec.js @@ -265,4 +265,87 @@ describe('The Annotation API', () => { expect(results.length).toEqual(0); }); }); + + describe('Target Comparators', () => { + let targets; + let otherTargets; + let comparator; + + beforeEach(() => { + targets = { + fooTarget: { + foo: 42 + } + }; + otherTargets = { + fooTarget: { + bar: 42 + } + }; + comparator = (t1, t2) => t1.fooTarget.foo === t2.fooTarget.bar; + }); + }); + + it('can add a comparator function', () => { + const notebookAnnotationType = openmct.annotation.ANNOTATION_TYPES.NOTEBOOK; + expect( + openmct.annotation.areAnnotationTargetsEqual(notebookAnnotationType, targets, otherTargets) + ).toBeFalse(); // without a comparator, these should NOT be equal + // Register a comparator function for the notebook annotation type + openmct.annotation.addTargetComparator(notebookAnnotationType, comparator); + expect( + openmct.annotation.areAnnotationTargetsEqual(notebookAnnotationType, targets, otherTargets) + ).toBeTrue(); // the comparator should make these equal + }); + it('can create a tag', async () => { + const annotationObject = await openmct.annotation.create(tagCreationArguments); + expect(annotationObject).toBeDefined(); + expect(annotationObject.type).toEqual('annotation'); + expect(annotationObject.tags).toContain('aWonderfulTag'); + }); + it('can delete a tag', async () => { + const annotationObject = await openmct.annotation.create(tagCreationArguments); + expect(annotationObject).toBeDefined(); + openmct.annotation.deleteAnnotations([annotationObject]); + expect(annotationObject._deleted).toBeTrue(); + }); + it('can remove all tags', async () => { + const annotationObject = await openmct.annotation.create(tagCreationArguments); + expect(annotationObject).toBeDefined(); + expect(() => { + openmct.annotation.deleteAnnotations([annotationObject]); + }).not.toThrow(); + expect(annotationObject._deleted).toBeTrue(); + }); + it('can add/delete/add a tag', async () => { + let annotationObject = await openmct.annotation.create(tagCreationArguments); + expect(annotationObject).toBeDefined(); + expect(annotationObject.type).toEqual('annotation'); + expect(annotationObject.tags).toContain('aWonderfulTag'); + openmct.annotation.deleteAnnotations([annotationObject]); + expect(annotationObject._deleted).toBeTrue(); + annotationObject = await openmct.annotation.create(tagCreationArguments); + expect(annotationObject).toBeDefined(); + expect(annotationObject.type).toEqual('annotation'); + expect(annotationObject.tags).toContain('aWonderfulTag'); + expect(annotationObject._deleted).toBeFalse(); + }); + }); + + it('falls back to deep equality check if no comparator functions', () => { + const annotationTypeWithoutComparator = openmct.annotation.ANNOTATION_TYPES.GEOSPATIAL; + const areEqual = openmct.annotation.areAnnotationTargetsEqual( + annotationTypeWithoutComparator, + targets, + targets + ); + const areNotEqual = openmct.annotation.areAnnotationTargetsEqual( + annotationTypeWithoutComparator, + targets, + otherTargets + ); + expect(areEqual).toBeTrue(); + expect(areNotEqual).toBeFalse(); + }); + }); }); diff --git a/src/api/telemetry/TelemetryAPI.js b/src/api/telemetry/TelemetryAPI.js index d016f06697..6ad0cbcf76 100644 --- a/src/api/telemetry/TelemetryAPI.js +++ b/src/api/telemetry/TelemetryAPI.js @@ -28,6 +28,36 @@ import TelemetryValueFormatter from './TelemetryValueFormatter'; import DefaultMetadataProvider from './DefaultMetadataProvider'; import objectUtils from 'objectUtils'; +/** + * @typedef {import('../time/TimeContext').TimeContext} TimeContext + */ + +/** + * Describes and bounds requests for telemetry data. + * + * @typedef TelemetryRequestOptions + * @property {String} [sort] the key of the property to sort by. This may + * be prefixed with a "+" or a "-" sign to sort in ascending + * or descending order respectively. If no prefix is present, + * ascending order will be used. + * @property {Number} [start] the lower bound for values of the sorting property + * @property {Number} [end] the upper bound for values of the sorting property + * @property {String} [strategy] symbolic identifier for strategies + * (such as `latest` or `minmax`) which may be recognized by providers; + * these will be tried in order until an appropriate provider + * is found + * @property {AbortController} [signal] an AbortController which can be used + * to cancel a telemetry request + * @property {String} [domain] the domain key of the request + * @property {TimeContext} [timeContext] the time context to use for this request + * @memberof module:openmct.TelemetryAPI~ + */ + +/** + * Utilities for telemetry + * @interface TelemetryAPI + * @memberof module:openmct + */ export default class TelemetryAPI { #isGreedyLAD; @@ -169,25 +199,35 @@ export default class TelemetryAPI { } /** - * @private - * Though used in TelemetryCollection as well + * @param {TelemetryRequestOptions} options options for the telemetry request + * @returns {TelemetryRequestOptions} the options, with defaults filled in */ - standardizeRequestOptions(options) { - if (!Object.prototype.hasOwnProperty.call(options, 'start')) { - options.start = this.openmct.time.bounds().start; + standardizeRequestOptions(options = {}) { + if (!Object.hasOwn(options, 'start')) { + if (options.timeContext?.bounds()) { + options.start = options.timeContext.bounds().start; + } else { + options.start = this.openmct.time.bounds().start; + } } - if (!Object.prototype.hasOwnProperty.call(options, 'end')) { - options.end = this.openmct.time.bounds().end; + if (!Object.hasOwn(options, 'end')) { + if (options.timeContext?.bounds()) { + options.end = options.timeContext.bounds().end; + } else { + options.end = this.openmct.time.bounds().end; + } } - if (!Object.prototype.hasOwnProperty.call(options, 'domain')) { + if (!Object.hasOwn(options, 'domain')) { options.domain = this.openmct.time.timeSystem().key; } - if (!Object.prototype.hasOwnProperty.call(options, 'timeContext')) { + if (!Object.hasOwn(options, 'timeContext')) { options.timeContext = this.openmct.time; } + + return options; } /** @@ -265,7 +305,7 @@ export default class TelemetryAPI { * @memberof module:openmct.TelemetryAPI~TelemetryProvider# * @param {module:openmct.DomainObject} domainObject the object * which has associated telemetry - * @param {module:openmct.TelemetryAPI~TelemetryRequest} options + * @param {TelemetryRequestOptions} options * options for this telemetry collection request * @returns {TelemetryCollection} a TelemetryCollection instance */ @@ -283,7 +323,7 @@ export default class TelemetryAPI { * @memberof module:openmct.TelemetryAPI~TelemetryProvider# * @param {module:openmct.DomainObject} domainObject the object * which has associated telemetry - * @param {module:openmct.TelemetryAPI~TelemetryRequest} options + * @param {TelemetryRequestOptions} options * options for this historical request * @returns {Promise.} a promise for an array of * telemetry data @@ -339,6 +379,7 @@ export default class TelemetryAPI { * @memberof module:openmct.TelemetryAPI~TelemetryProvider# * @param {module:openmct.DomainObject} domainObject the object * which has associated telemetry + * @param {TelemetryRequestOptions} options configuration items for subscription * @param {Function} callback the callback to invoke with new data, as * it becomes available * @returns {Function} a function which may be called to terminate diff --git a/src/api/telemetry/TelemetryCollection.js b/src/api/telemetry/TelemetryCollection.js index e052355a7a..97a7dc1621 100644 --- a/src/api/telemetry/TelemetryCollection.js +++ b/src/api/telemetry/TelemetryCollection.js @@ -24,6 +24,22 @@ import _ from 'lodash'; import EventEmitter from 'EventEmitter'; import { LOADED_ERROR, TIMESYSTEM_KEY_NOTIFICATION, TIMESYSTEM_KEY_WARNING } from './constants'; +/** + * @typedef {import('../objects/ObjectAPI').DomainObject} DomainObject + */ + +/** + * @typedef {import('../time/TimeContext').TimeContext} TimeContext + */ + +/** + * @typedef {import('./TelemetryAPI').TelemetryRequestOptions} TelemetryRequestOptions + */ + +/** + * @typedef {import('../../../openmct').OpenMCT} OpenMCT + */ + /** Class representing a Telemetry Collection. */ export default class TelemetryCollection extends EventEmitter { @@ -31,10 +47,10 @@ export default class TelemetryCollection extends EventEmitter { * Creates a Telemetry Collection * * @param {OpenMCT} openmct - Open MCT - * @param {module:openmct.DomainObject} domainObject - Domain Object to use for telemetry collection - * @param {object} options - Any options passed in for request/subscribe + * @param {DomainObject} domainObject - Domain Object to use for telemetry collection + * @param {TelemetryRequestOptions} options - Any options passed in for request/subscribe */ - constructor(openmct, domainObject, options) { + constructor(openmct, domainObject, options = {}) { super(); this.loaded = false; @@ -45,7 +61,7 @@ export default class TelemetryCollection extends EventEmitter { this.parseTime = undefined; this.metadata = this.openmct.telemetry.getMetadata(domainObject); this.unsubscribe = undefined; - this.options = options; + this.options = this.openmct.telemetry.standardizeRequestOptions(options); this.pageState = undefined; this.lastBounds = undefined; this.requestAbort = undefined; @@ -62,8 +78,8 @@ export default class TelemetryCollection extends EventEmitter { this._error(LOADED_ERROR); } - this._setTimeSystem(this.openmct.time.timeSystem()); - this.lastBounds = this.openmct.time.bounds(); + this._setTimeSystem(this.options.timeContext.timeSystem()); + this.lastBounds = this.options.timeContext.bounds(); this._watchBounds(); this._watchTimeSystem(); @@ -106,10 +122,10 @@ export default class TelemetryCollection extends EventEmitter { */ async _requestHistoricalTelemetry() { let options = { ...this.options }; - let historicalProvider; - - this.openmct.telemetry.standardizeRequestOptions(options); - historicalProvider = this.openmct.telemetry.findRequestProvider(this.domainObject, options); + const historicalProvider = this.openmct.telemetry.findRequestProvider( + this.domainObject, + options + ); if (!historicalProvider) { return; @@ -438,7 +454,7 @@ export default class TelemetryCollection extends EventEmitter { * @private */ _watchBounds() { - this.openmct.time.on('bounds', this._bounds, this); + this.options.timeContext.on('bounds', this._bounds, this); } /** @@ -446,7 +462,7 @@ export default class TelemetryCollection extends EventEmitter { * @private */ _unwatchBounds() { - this.openmct.time.off('bounds', this._bounds, this); + this.options.timeContext.off('bounds', this._bounds, this); } /** @@ -454,7 +470,7 @@ export default class TelemetryCollection extends EventEmitter { * @private */ _watchTimeSystem() { - this.openmct.time.on('timeSystem', this._setTimeSystemAndFetchData, this); + this.options.timeContext.on('timeSystem', this._setTimeSystemAndFetchData, this); } /** @@ -462,7 +478,7 @@ export default class TelemetryCollection extends EventEmitter { * @private */ _unwatchTimeSystem() { - this.openmct.time.off('timeSystem', this._setTimeSystemAndFetchData, this); + this.options.timeContext.off('timeSystem', this._setTimeSystemAndFetchData, this); } /** diff --git a/src/api/user/UserAPI.js b/src/api/user/UserAPI.js index 3ff8badde1..2dd6e2dd37 100644 --- a/src/api/user/UserAPI.js +++ b/src/api/user/UserAPI.js @@ -135,7 +135,6 @@ class UserAPI extends EventEmitter { if (!this.hasProvider()) { return null; } - const activeRole = this.getActiveRole(); return this._provider.canProvideStatusForRole?.(activeRole); diff --git a/src/plugins/filters/FiltersInspectorViewProvider.js b/src/plugins/filters/FiltersInspectorViewProvider.js index d71fbd2fa1..16b5029fc1 100644 --- a/src/plugins/filters/FiltersInspectorViewProvider.js +++ b/src/plugins/filters/FiltersInspectorViewProvider.js @@ -49,10 +49,16 @@ define(['./components/FiltersView.vue', 'vue'], function (FiltersView, Vue) { }); }, showTab: function (isEditing) { - const hasPersistedFilters = Boolean(domainObject?.configuration?.filters); - const hasGlobalFilters = Boolean(domainObject?.configuration?.globalFilters); + if (isEditing) { + return true; + } - return hasPersistedFilters || hasGlobalFilters; + const metadata = openmct.telemetry.getMetadata(domainObject); + const metadataWithFilters = metadata + ? metadata.valueMetadatas.filter((value) => value.filters) + : []; + + return metadataWithFilters.length; }, priority: function () { return openmct.priority.DEFAULT; diff --git a/src/plugins/filters/README.md b/src/plugins/filters/README.md new file mode 100644 index 0000000000..321fa30432 --- /dev/null +++ b/src/plugins/filters/README.md @@ -0,0 +1,53 @@ + +# Server side filtering in Open MCT + +## Introduction + +In Open MCT, filters can be constructed to filter out telemetry data on the server side. This is useful for reducing the amount of data that needs to be sent to the client. For example, in [Open MCT for MCWS](https://github.com/NASA-AMMOS/openmct-mcws/blob/e8846d325cc3f659d8ad58d1d24efaafbe2b6bb7/src/constants.js#L115), they can be used to filter realtime data from recorded data. In the [Open MCT YAMCS plugin](https://github.com/akhenry/openmct-yamcs/blob/9c4ed03e23848db3215fdb6a988ba34b445a3989/src/providers/events.js#L44), we can use them to filter incoming event data by severity. + +## Installing the filter plugin + +You'll need to install the filter plugin first. For example: + +```js +openmct.install(openmct.plugins.Filters(['telemetry.plot.overlay', 'table'])); +``` + +will install the filters plugin and have it apply to overlay plots and tables. You can see an example of this in the [Open MCT YAMCS plugin](https://github.com/akhenry/openmct-yamcs/blob/9c4ed03e23848db3215fdb6a988ba34b445a3989/example/index.js#L58). + +## Defining a filter + +To define a filter, you'll need to add a new `filter` property to the domain object's `telemetry` metadata underneath the `values` array. For example, if you have a domain object with a `telemetry` metadata that looks like this: + +```js +{ + key: 'fruit', + name: 'Types of fruit', + filters: [{ + singleSelectionThreshold: true, + comparator: 'equals', + possibleValues: [ + { name: 'Apple', value: 'apple' }, + { name: 'Banana', value: 'banana' }, + { name: 'Orange', value: 'orange' } + ] + }] +} +``` + +This will define a filter that allows an operator to choose one (due to `singleSelectionThreshold` being `true`) of the three possible values. The `comparator` property defines how the filter will be applied to the telemetry data. +Setting `singleSelectionThreshold` to `false` will render the `possibleValues` as a series of checkboxes. Removing the `possibleValues` property will render the filter as a text box, allowing the operator to enter a value to filter on. + +Note that how the filter is interpreted is ultimately decided by the individual telemetry providers. + +## Implementing a filter in a telemetry provider + +Implementing a filter requires two parts: + +- First, one needs to add the filter implementation to the [subscribe](https://github.com/nasa/openmct/blob/5df7971438acb9e8b933edda2aed432b1b8bb27d/src/api/telemetry/TelemetryAPI.js#L366) method in your telemetry provider. The filter will be passed to you in the `options` argument. You can either add the filter to your telemetry subscription request, or filter manually as new messages appears. An example of the latter is [shown in the YAMCS plugin for Open MCT](https://github.com/akhenry/openmct-yamcs/blob/9c4ed03e23848db3215fdb6a988ba34b445a3989/src/providers/events.js#L95). + +- Second, one needs to add the filter implementation to the [request](https://github.com/nasa/openmct/blob/5df7971438acb9e8b933edda2aed432b1b8bb27d/src/api/telemetry/TelemetryAPI.js#L318) method in your telemetry provider. The filter again will be passed to you in the `options` argument. You can either add the filter to your telemetry request, or filter manually after the request is made. An example of the former is [shown in the YAMCS plugin for Open MCT](https://github.com/akhenry/openmct-yamcs/blob/9c4ed03e23848db3215fdb6a988ba34b445a3989/src/providers/historical-telemetry-provider.js#L171). + +## Using filters + +If you installed the plugin to have it apply to `table`, create a Telemetry Table in Open MCT and drag your telemetry object that contains the filter to it. Then click "Edit", and notice the "Filter" tab in the inspector. It allows operator to either select a "Global Filter", or a regular filter. The "Global Filter" will apply for all telemetry objects in the table, while the regular filter will only apply to the telemetry object that it is defined on. \ No newline at end of file diff --git a/src/plugins/filters/components/FilterField.vue b/src/plugins/filters/components/FilterField.vue index d8edc53fff..40daab0a71 100644 --- a/src/plugins/filters/components/FilterField.vue +++ b/src/plugins/filters/components/FilterField.vue @@ -37,14 +37,37 @@ :id="`${filter}filterControl`" class="c-input--flex" type="text" + :aria-label="label" :disabled="useGlobal" :value="persistedValue(filter)" - @change="updateFilterValue($event, filter)" + @change="updateFilterValueFromString($event, filter)" /> + + + -