diff --git a/.circleci/config.yml b/.circleci/config.yml index 3268c660a6..8863b9e057 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -31,7 +31,7 @@ commands: type: string steps: - when: - condition: + condition: equal: [false, << pipeline.parameters.BUST_CACHE >> ] steps: - restore_cache: @@ -41,7 +41,7 @@ commands: parameters: node-version: type: string - steps: + steps: - save_cache: key: deps-{{ .Branch }}--<< parameters.node-version >>--{{ checksum "package.json" }}-{{ checksum ".circleci/config.yml" }} paths: @@ -61,7 +61,7 @@ commands: upload_code_covio: description: "Command to upload code coverage reports to codecov.io" steps: - - run: curl -Os https://uploader.codecov.io/latest/linux/codecov;chmod +x codecov;./codecov + - run: curl -Os https://uploader.codecov.io/latest/linux/codecov;chmod +x codecov;./codecov orbs: node: circleci/node@4.9.0 browser-tools: circleci/browser-tools@1.3.0 @@ -101,7 +101,7 @@ jobs: equal: [ "FirefoxESR", <> ] steps: - browser-tools/install-firefox: - version: "91.7.1esr" #https://archive.mozilla.org/pub/firefox/releases/ + version: "91.7.1esr" #https://archive.mozilla.org/pub/firefox/releases/ - when: condition: equal: [ "FirefoxHeadless", <> ] @@ -158,7 +158,7 @@ workflows: - lint: name: node16-lint node-version: lts/gallium - - unit-test: + - unit-test: name: node14-chrome node-version: lts/fermium browser: ChromeHeadless diff --git a/e2e/playwright-performance.config.js b/e2e/playwright-performance.config.js index a718f1df03..1d3b849ac4 100644 --- a/e2e/playwright-performance.config.js +++ b/e2e/playwright-performance.config.js @@ -4,9 +4,9 @@ /** @type {import('@playwright/test').PlaywrightTestConfig} */ const config = { - retries: 0, + retries: 1, //Only for debugging purposes testDir: 'tests/performance/', - timeout: 30 * 1000, + timeout: 60 * 1000, workers: 1, //Only run in serial with 1 worker webServer: { command: 'npm run start', @@ -20,7 +20,7 @@ const config = { headless: Boolean(process.env.CI), //Only if running locally ignoreHTTPSErrors: true, screenshot: 'off', - trace: 'off', + trace: 'on-first-retry', video: 'off' }, projects: [ diff --git a/e2e/test-data/PerformanceDisplayLayout.json b/e2e/test-data/PerformanceDisplayLayout.json index eebc7635ad..de81d7b4ca 100644 --- a/e2e/test-data/PerformanceDisplayLayout.json +++ b/e2e/test-data/PerformanceDisplayLayout.json @@ -1 +1 @@ -{"openmct":{"21338566-d472-4377-aed1-21b79272c8de":{"identifier":{"key":"21338566-d472-4377-aed1-21b79272c8de","namespace":""},"name":"Performance Display Layout","type":"layout","composition":[{"key":"644c2e47-2903-475f-8a4a-6be1588ee02f","namespace":""}],"configuration":{"items":[{"width":32,"height":18,"x":1,"y":1,"identifier":{"key":"644c2e47-2903-475f-8a4a-6be1588ee02f","namespace":""},"hasFrame":true,"fontSize":"default","font":"default","type":"subobject-view","id":"5aeb5a71-3149-41ed-9d8a-d34b0a18b053"}],"layoutGrid":[10,10]},"modified":1652228997384,"location":"mine","persisted":1652228997384},"644c2e47-2903-475f-8a4a-6be1588ee02f":{"identifier":{"key":"644c2e47-2903-475f-8a4a-6be1588ee02f","namespace":""},"name":"Performance Example Imagery","type":"example.imagery","configuration":{"imageLocation":"","imageLoadDelayInMilliSeconds":20000,"imageSamples":[]},"telemetry":{"values":[{"name":"Name","key":"name"},{"name":"Time","key":"utc","format":"utc","hints":{"domain":2}},{"name":"Local Time","key":"local","format":"local-format","hints":{"domain":1}},{"name":"Image","key":"url","format":"image","hints":{"image":1}},{"name":"Image Download Name","key":"imageDownloadName","format":"imageDownloadName","hints":{"imageDownloadName":1}}]},"modified":1652228997375,"location":"21338566-d472-4377-aed1-21b79272c8de","persisted":1652228997375}},"rootId":"21338566-d472-4377-aed1-21b79272c8de"} \ No newline at end of file +{"openmct":{"b3cee102-86dd-4c0a-8eec-4d5d276f8691":{"identifier":{"key":"b3cee102-86dd-4c0a-8eec-4d5d276f8691","namespace":""},"name":"Performance Display Layout","type":"layout","composition":[{"key":"9666e7b4-be0c-47a5-94b8-99accad7155e","namespace":""}],"configuration":{"items":[{"width":32,"height":18,"x":12,"y":9,"identifier":{"key":"9666e7b4-be0c-47a5-94b8-99accad7155e","namespace":""},"hasFrame":true,"fontSize":"default","font":"default","type":"subobject-view","id":"23ca351d-a67d-46aa-a762-290eb742d2f1"}],"layoutGrid":[10,10]},"modified":1654299875432,"location":"mine","persisted":1654299878751},"9666e7b4-be0c-47a5-94b8-99accad7155e":{"identifier":{"key":"9666e7b4-be0c-47a5-94b8-99accad7155e","namespace":""},"name":"Performance Example Imagery","type":"example.imagery","configuration":{"imageLocation":"","imageLoadDelayInMilliSeconds":20000,"imageSamples":[],"layers":[{"source":"dist/imagery/example-imagery-layer-16x9.png","name":"16:9","visible":false},{"source":"dist/imagery/example-imagery-layer-safe.png","name":"Safe","visible":false},{"source":"dist/imagery/example-imagery-layer-scale.png","name":"Scale","visible":false}]},"telemetry":{"values":[{"name":"Name","key":"name"},{"name":"Time","key":"utc","format":"utc","hints":{"domain":2}},{"name":"Local Time","key":"local","format":"local-format","hints":{"domain":1}},{"name":"Image","key":"url","format":"image","hints":{"image":1},"layers":[{"source":"dist/imagery/example-imagery-layer-16x9.png","name":"16:9"},{"source":"dist/imagery/example-imagery-layer-safe.png","name":"Safe"},{"source":"dist/imagery/example-imagery-layer-scale.png","name":"Scale"}]},{"name":"Image Download Name","key":"imageDownloadName","format":"imageDownloadName","hints":{"imageDownloadName":1}}]},"modified":1654299840077,"location":"b3cee102-86dd-4c0a-8eec-4d5d276f8691","persisted":1654299840078}},"rootId":"b3cee102-86dd-4c0a-8eec-4d5d276f8691"} \ No newline at end of file diff --git a/e2e/tests/performance/imagery.perf.spec.js b/e2e/tests/performance/imagery.perf.spec.js index 0e75e52af1..433bc1699d 100644 --- a/e2e/tests/performance/imagery.perf.spec.js +++ b/e2e/tests/performance/imagery.perf.spec.js @@ -103,10 +103,10 @@ test.describe('Performance tests', () => { await page.goto('/'); // Search Available after Launch - await page.locator('input[type="search"]').click(); + await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').click(); await page.evaluate(() => window.performance.mark("search-available")); // Fill Search input - await page.locator('input[type="search"]').fill('Performance Display Layout'); + await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('Performance Display Layout'); await page.evaluate(() => window.performance.mark("search-entered")); //Search Result Appears and is clicked await Promise.all([ @@ -164,7 +164,7 @@ test.describe('Performance tests', () => { console.log('jpgResourceTiming ' + JSON.stringify(jpgResourceTiming)); // Click Close Icon - await page.locator('.c-click-icon').click(); + await page.locator('[aria-label="Close"]').click(); await page.evaluate(() => window.performance.mark("view-large-close-button")); //await client.send('HeapProfiler.enable'); diff --git a/e2e/tests/performance/memleak-imagery.perf.spec.js b/e2e/tests/performance/memleak-imagery.perf.spec.js index a611a7c6a1..6ce14f5533 100644 --- a/e2e/tests/performance/memleak-imagery.perf.spec.js +++ b/e2e/tests/performance/memleak-imagery.perf.spec.js @@ -64,9 +64,9 @@ test.describe.skip('Memory Performance tests', () => { await page.goto('/', {waitUntil: 'networkidle'}); // To to Search Available after Launch - await page.locator('input[type="search"]').click(); + await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').click(); // Fill Search input - await page.locator('input[type="search"]').fill('Performance Display Layout'); + await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('Performance Display Layout'); //Search Result Appears and is clicked await Promise.all([ page.waitForNavigation(), diff --git a/e2e/tests/performance/notebook.perf.spec.js b/e2e/tests/performance/notebook.perf.spec.js index 14c74bd32d..1c10ad6ba5 100644 --- a/e2e/tests/performance/notebook.perf.spec.js +++ b/e2e/tests/performance/notebook.perf.spec.js @@ -98,10 +98,10 @@ test.describe('Performance tests', () => { await page.goto('/'); // To to Search Available after Launch - await page.locator('input[type="search"]').click(); + await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').click(); await page.evaluate(() => window.performance.mark("search-available")); // Fill Search input - await page.locator('input[type="search"]').fill('Performance Notebook'); + await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('Performance Notebook'); await page.evaluate(() => window.performance.mark("search-entered")); //Search Result Appears and is clicked await Promise.all([ diff --git a/e2e/tests/plugins/clock/Clock.e2e.spec.js b/e2e/tests/plugins/clock/Clock.e2e.spec.js index bdf8843bc2..645770a55d 100644 --- a/e2e/tests/plugins/clock/Clock.e2e.spec.js +++ b/e2e/tests/plugins/clock/Clock.e2e.spec.js @@ -46,22 +46,22 @@ test.describe('Clock Generator', () => { // Click .icon-arrow-down await page.locator('.icon-arrow-down').click(); //verify if the autocomplete dropdown is visible - await expect(page.locator(".optionPreSelected")).toBeVisible(); + await expect(page.locator(".c-input--autocomplete__options")).toBeVisible(); // Click .icon-arrow-down await page.locator('.icon-arrow-down').click(); // Verify clicking on the autocomplete arrow collapses the dropdown - await expect(page.locator(".optionPreSelected")).not.toBeVisible(); + await expect(page.locator(".c-input--autocomplete__options")).not.toBeVisible(); // Click timezone input to open dropdown - await page.locator('.autocompleteInput').click(); + await page.locator('.c-input--autocomplete__input').click(); //verify if the autocomplete dropdown is visible - await expect(page.locator(".optionPreSelected")).toBeVisible(); + await expect(page.locator(".c-input--autocomplete__options")).toBeVisible(); // Verify clicking outside the autocomplete dropdown collapses it await page.locator('text=Timezone').click(); // Verify clicking on the autocomplete arrow collapses the dropdown - await expect(page.locator(".optionPreSelected")).not.toBeVisible(); + await expect(page.locator(".c-input--autocomplete__options")).not.toBeVisible(); }); }); diff --git a/e2e/tests/plugins/condition/condition.e2e.spec.js b/e2e/tests/plugins/condition/condition.e2e.spec.js index 68f31eed01..a2ace18f75 100644 --- a/e2e/tests/plugins/condition/condition.e2e.spec.js +++ b/e2e/tests/plugins/condition/condition.e2e.spec.js @@ -32,42 +32,40 @@ const { expect } = require('@playwright/test'); let conditionSetUrl; let getConditionSetIdentifierFromUrl; -test('Create new Condition Set object and store @localStorage', async ({ page, context }) => { - //Go to baseURL - await page.goto('/', { waitUntil: 'networkidle' }); - - //Click the Create button - await page.click('button:has-text("Create")'); - - // Click text=Condition Set - await page.click('text=Condition Set'); - - // Click on My Items in Tree. Workaround for https://github.com/nasa/openmct/issues/5184 - await page.click('form[name="mctForm"] a:has-text("My Items")'); - - // Click text=OK - await Promise.all([ - page.waitForNavigation(), - page.click('text=OK') - ]); - - await expect(page.locator('.l-browse-bar__object-name')).toContainText('Unnamed Condition Set'); - //Save localStorage for future test execution - await context.storageState({ path: './e2e/tests/recycled_storage.json' }); - - //Set object identifier from url - conditionSetUrl = await page.url(); - console.log('conditionSetUrl ' + conditionSetUrl); - - getConditionSetIdentifierFromUrl = await conditionSetUrl.split('/').pop().split('?')[0]; - console.log('getConditionSetIdentifierFromUrl ' + getConditionSetIdentifierFromUrl); - -}); - test.describe.serial('Condition Set CRUD Operations on @localStorage', () => { + test.beforeAll(async ({ browser }) => { + const context = await browser.newContext(); + const page = await context.newPage(); + //Go to baseURL + await page.goto('/', { waitUntil: 'networkidle' }); + + //Click the Create button + await page.click('button:has-text("Create")'); + + // Click text=Condition Set + await page.locator('li:has-text("Condition Set")').click(); + + // Click text=OK + await Promise.all([ + page.waitForNavigation(), + page.click('text=OK') + ]); + + //Save localStorage for future test execution + await context.storageState({ path: './e2e/tests/recycled_storage.json' }); + + //Set object identifier from url + conditionSetUrl = await page.url(); + console.log('conditionSetUrl ' + conditionSetUrl); + + getConditionSetIdentifierFromUrl = await conditionSetUrl.split('/').pop().split('?')[0]; + console.debug('getConditionSetIdentifierFromUrl ' + getConditionSetIdentifierFromUrl); + }); + test.afterAll(async ({ browser }) => { + await browser.close(); + }); //Load localStorage for subsequent tests test.use({ storageState: './e2e/tests/recycled_storage.json' }); - //Begin suite of tests again localStorage test('Condition set object properties persist in main view and inspector', async ({ page }) => { //Navigate to baseURL with injected localStorage @@ -124,7 +122,7 @@ test.describe.serial('Condition Set CRUD Operations on @localStorage', () => { // Verify Condition Set Object is renamed in Tree await expect(page.locator('a:has-text("Renamed Condition Set")')).toBeTruthy(); // Verify Search Tree reflects renamed Name property - await page.locator('input[type="search"]').fill('Renamed'); + await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('Renamed'); await expect(page.locator('a:has-text("Renamed Condition Set")')).toBeTruthy(); //Reload Page @@ -148,35 +146,31 @@ test.describe.serial('Condition Set CRUD Operations on @localStorage', () => { // Verify Condition Set Object is renamed in Tree await expect(page.locator('a:has-text("Renamed Condition Set")')).toBeTruthy(); // Verify Search Tree reflects renamed Name property - await page.locator('input[type="search"]').fill('Renamed'); + await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('Renamed'); await expect(page.locator('a:has-text("Renamed Condition Set")')).toBeTruthy(); }); test('condition set object can be deleted by Search Tree Actions menu on @localStorage', async ({ page }) => { //Navigate to baseURL await page.goto('/', { waitUntil: 'networkidle' }); + const numberOfConditionSetsToStart = await page.locator('a:has-text("Unnamed Condition Set Condition Set")').count(); //Expect Unnamed Condition Set to be visible in Main View - await expect(page.locator('a:has-text("Unnamed Condition Set Condition Set")')).toBeVisible(); + await expect(page.locator('a:has-text("Unnamed Condition Set Condition Set") >> nth=0')).toBeVisible(); // Search for Unnamed Condition Set - await page.locator('input[type="search"]').fill('Unnamed Condition Set'); - // Right Click to Open Actions Menu - await page.locator('a:has-text("Unnamed Condition Set")').click({ - button: 'right' - }); - // Click Remove Action + await page.locator('[aria-label="OpenMCT Search"] input[type="search"]').fill('Unnamed Condition Set'); + // Click Search Result + await page.locator('[aria-label="OpenMCT Search"] >> text=Unnamed Condition Set').first().click(); + // Click hamburger button + await page.locator('[title="More options"]').click(); + // Click text=Remove await page.locator('text=Remove').click(); await page.locator('text=OK').click(); //Expect Unnamed Condition Set to be removed in Main View - await expect(page.locator('a:has-text("Unnamed Condition Set Condition Set")')).not.toBeVisible(); - - await page.locator('.c-search__clear-input').click(); - // Search for Unnamed Condition Set - await page.locator('input[type="search"]').fill('Unnamed Condition Set'); - // Expect Unnamed Condition Set to be removed - await expect(page.locator('a:has-text("Unnamed Condition Set")')).not.toBeVisible(); + const numberOfConditionSetsAtEnd = await page.locator('a:has-text("Unnamed Condition Set Condition Set")').count(); + expect(numberOfConditionSetsAtEnd).toEqual(numberOfConditionSetsToStart - 1); //Feature? //Domain Object is still available by direct URL after delete diff --git a/e2e/tests/plugins/imagery/exampleImagery.e2e.spec.js b/e2e/tests/plugins/imagery/exampleImagery.e2e.spec.js index d5c204cdb2..0a570e5c2a 100644 --- a/e2e/tests/plugins/imagery/exampleImagery.e2e.spec.js +++ b/e2e/tests/plugins/imagery/exampleImagery.e2e.spec.js @@ -32,7 +32,6 @@ const { expect } = require('@playwright/test'); test.describe('Example Imagery', () => { test.beforeEach(async ({ page }) => { - page.on('console', msg => console.log(msg.text())); //Go to baseURL await page.goto('/', { waitUntil: 'networkidle' }); @@ -61,19 +60,19 @@ test.describe('Example Imagery', () => { test('Can use Mouse Wheel to zoom in and out of latest image', async ({ page }) => { const bgImageLocator = page.locator(backgroundImageSelector); const deltaYStep = 100; //equivalent to 1x zoom - await bgImageLocator.hover(); + await bgImageLocator.hover({trial: true}); const originalImageDimensions = await page.locator(backgroundImageSelector).boundingBox(); // zoom in - await bgImageLocator.hover(); + await bgImageLocator.hover({trial: true}); await page.mouse.wheel(0, deltaYStep * 2); // wait for zoom animation to finish - await bgImageLocator.hover(); + await bgImageLocator.hover({trial: true}); const imageMouseZoomedIn = await page.locator(backgroundImageSelector).boundingBox(); // zoom out - await bgImageLocator.hover(); + await bgImageLocator.hover({trial: true}); await page.mouse.wheel(0, -deltaYStep); // wait for zoom animation to finish - await bgImageLocator.hover(); + await bgImageLocator.hover({trial: true}); const imageMouseZoomedOut = await page.locator(backgroundImageSelector).boundingBox(); expect(imageMouseZoomedIn.height).toBeGreaterThan(originalImageDimensions.height); @@ -88,11 +87,11 @@ test.describe('Example Imagery', () => { const panHotkey = process.platform === 'linux' ? ['Control', 'Alt'] : ['Alt']; const bgImageLocator = page.locator(backgroundImageSelector); - await bgImageLocator.hover(); + await bgImageLocator.hover({trial: true}); // zoom in await page.mouse.wheel(0, deltaYStep * 2); - await bgImageLocator.hover(); + await bgImageLocator.hover({trial: true}); const zoomedBoundingBox = await bgImageLocator.boundingBox(); const imageCenterX = zoomedBoundingBox.x + zoomedBoundingBox.width / 2; const imageCenterY = zoomedBoundingBox.y + zoomedBoundingBox.height / 2; @@ -151,22 +150,22 @@ test.describe('Example Imagery', () => { test('Can use + - buttons to zoom on the image', async ({ page }) => { const bgImageLocator = page.locator(backgroundImageSelector); - await bgImageLocator.hover(); - const zoomInBtn = page.locator('.t-btn-zoom-in'); - const zoomOutBtn = page.locator('.t-btn-zoom-out'); + await bgImageLocator.hover({trial: true}); + const zoomInBtn = page.locator('.t-btn-zoom-in').nth(0); + const zoomOutBtn = page.locator('.t-btn-zoom-out').nth(0); const initialBoundingBox = await bgImageLocator.boundingBox(); await zoomInBtn.click(); await zoomInBtn.click(); // wait for zoom animation to finish - await bgImageLocator.hover(); + await bgImageLocator.hover({trial: true}); const zoomedInBoundingBox = await bgImageLocator.boundingBox(); expect(zoomedInBoundingBox.height).toBeGreaterThan(initialBoundingBox.height); expect(zoomedInBoundingBox.width).toBeGreaterThan(initialBoundingBox.width); await zoomOutBtn.click(); // wait for zoom animation to finish - await bgImageLocator.hover(); + await bgImageLocator.hover({trial: true}); const zoomedOutBoundingBox = await bgImageLocator.boundingBox(); expect(zoomedOutBoundingBox.height).toBeLessThan(zoomedInBoundingBox.height); expect(zoomedOutBoundingBox.width).toBeLessThan(zoomedInBoundingBox.width); @@ -176,18 +175,18 @@ test.describe('Example Imagery', () => { test('Can use the reset button to reset the image', async ({ page }) => { const bgImageLocator = page.locator(backgroundImageSelector); // wait for zoom animation to finish - await bgImageLocator.hover(); + await bgImageLocator.hover({trial: true}); - const zoomInBtn = page.locator('.t-btn-zoom-in'); - const zoomResetBtn = page.locator('.t-btn-zoom-reset'); + const zoomInBtn = page.locator('.t-btn-zoom-in').nth(0); + const zoomResetBtn = page.locator('.t-btn-zoom-reset').nth(0); const initialBoundingBox = await bgImageLocator.boundingBox(); await zoomInBtn.click(); // wait for zoom animation to finish - await bgImageLocator.hover(); + await bgImageLocator.hover({trial: true}); await zoomInBtn.click(); // wait for zoom animation to finish - await bgImageLocator.hover(); + await bgImageLocator.hover({trial: true}); const zoomedInBoundingBox = await bgImageLocator.boundingBox(); expect.soft(zoomedInBoundingBox.height).toBeGreaterThan(initialBoundingBox.height); @@ -195,7 +194,7 @@ test.describe('Example Imagery', () => { await zoomResetBtn.click(); // wait for zoom animation to finish - await bgImageLocator.hover(); + await bgImageLocator.hover({trial: true}); const resetBoundingBox = await bgImageLocator.boundingBox(); expect.soft(resetBoundingBox.height).toBeLessThan(zoomedInBoundingBox.height); @@ -209,18 +208,18 @@ test.describe('Example Imagery', () => { const bgImageLocator = page.locator(backgroundImageSelector); const pausePlayButton = page.locator('.c-button.pause-play'); // wait for zoom animation to finish - await bgImageLocator.hover(); + await bgImageLocator.hover({trial: true}); // open the time conductor drop down - await page.locator('.c-conductor__controls button.c-mode-button').click(); + await page.locator('button:has-text("Fixed Timespan")').click(); // Click local clock - await page.locator('.icon-clock >> text=Local Clock').click(); + await page.locator('[data-testid="conductor-modeOption-realtime"]').click(); await expect.soft(pausePlayButton).not.toHaveClass(/is-paused/); - const zoomInBtn = page.locator('.t-btn-zoom-in'); + const zoomInBtn = page.locator('.t-btn-zoom-in').nth(0); await zoomInBtn.click(); // wait for zoom animation to finish - await bgImageLocator.hover(); + await bgImageLocator.hover({trial: true}); return expect(pausePlayButton).not.toHaveClass(/is-paused/); }); @@ -267,7 +266,7 @@ test('Example Imagery in Display layout', async ({ page }) => { await page.waitForSelector('.c-message-banner__message', { state: 'detached'}); await expect(page.locator('.l-browse-bar__object-name')).toContainText('Unnamed Example Imagery'); const bgImageLocator = page.locator(backgroundImageSelector); - await bgImageLocator.hover(); + await bgImageLocator.hover({trial: true}); // Click previous image button const previousImageButton = page.locator('.c-nav--prev'); @@ -279,7 +278,7 @@ test('Example Imagery in Display layout', async ({ page }) => { // Zoom in const originalImageDimensions = await page.locator(backgroundImageSelector).boundingBox(); - await bgImageLocator.hover(); + await bgImageLocator.hover({trial: true}); const deltaYStep = 100; // equivalent to 1x zoom await page.mouse.wheel(0, deltaYStep * 2); const zoomedBoundingBox = await bgImageLocator.boundingBox(); @@ -287,7 +286,7 @@ test('Example Imagery in Display layout', async ({ page }) => { const imageCenterY = zoomedBoundingBox.y + zoomedBoundingBox.height / 2; // Wait for zoom animation to finish - await bgImageLocator.hover(); + await bgImageLocator.hover({trial: true}); const imageMouseZoomedIn = await page.locator(backgroundImageSelector).boundingBox(); expect(imageMouseZoomedIn.height).toBeGreaterThan(originalImageDimensions.height); expect(imageMouseZoomedIn.width).toBeGreaterThan(originalImageDimensions.width); @@ -311,11 +310,11 @@ test('Example Imagery in Display layout', async ({ page }) => { await page.locator('[data-testid=conductor-modeOption-realtime]').click(); // Zoom in on next image - await bgImageLocator.hover(); + await bgImageLocator.hover({trial: true}); await page.mouse.wheel(0, deltaYStep * 2); // Wait for zoom animation to finish - await bgImageLocator.hover(); + await bgImageLocator.hover({trial: true}); const imageNextMouseZoomedIn = await page.locator(backgroundImageSelector).boundingBox(); expect(imageNextMouseZoomedIn.height).toBeGreaterThan(originalImageDimensions.height); expect(imageNextMouseZoomedIn.width).toBeGreaterThan(originalImageDimensions.width); diff --git a/e2e/tests/plugins/notebook/addInitRestrictedNotebook.js b/e2e/tests/plugins/notebook/addInitRestrictedNotebook.js new file mode 100644 index 0000000000..dd303fb521 --- /dev/null +++ b/e2e/tests/plugins/notebook/addInitRestrictedNotebook.js @@ -0,0 +1,30 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2022, 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 will be called from the test suite with +// await page.addInitScript({ path: path.join(__dirname, 'addInitRestrictedNotebook.js') }); +// it will install the RestrictedNotebook since it is not installed by default + +document.addEventListener('DOMContentLoaded', () => { + const openmct = window.openmct; + openmct.install(openmct.plugins.RestrictedNotebook('CUSTOM_NAME')); +}); diff --git a/e2e/tests/plugins/notebook/notebook.e2e.spec.js b/e2e/tests/plugins/notebook/notebook.e2e.spec.js new file mode 100644 index 0000000000..021946c875 --- /dev/null +++ b/e2e/tests/plugins/notebook/notebook.e2e.spec.js @@ -0,0 +1,198 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2022, 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 tests which verify the basic operations surrounding Notebooks. +*/ + +const { test } = require('../../../fixtures'); + +test.describe('Notebook CRUD Operations', () => { + test.fixme('Can create a Notebook Object', async ({ page }) => { + //Create domain object + //Newly created notebook should have one Section and one page, 'Unnamed Section'/'Unnamed Page' + }); + test.fixme('Can update a Notebook Object', async ({ page }) => {}); + test.fixme('Can view a perviously created Notebook Object', async ({ page }) => {}); + test.fixme('Can Delete a Notebook Object', async ({ page }) => { + // Other than non-persistible objects + }); +}); + +test.describe('Default Notebook', () => { + // General Default Notebook statements + // ## Useful commands: + // 1. - To check default notebook: + // `JSON.parse(localStorage.getItem('notebook-storage'));` + // 1. - Clear default notebook: + // `localStorage.setItem('notebook-storage', null);` + test.fixme('A newly created Notebook is automatically set as the default notebook if no other notebooks exist', async ({ page }) => { + //Create new notebook + //Verify Default Notebook Characteristics + }); + test.fixme('A newly created Notebook is automatically set as the default notebook if at least one other notebook exists', async ({ page }) => { + //Create new notebook A + //Create second notebook B + //Verify Non-Default Notebook A Characteristics + //Verify Default Notebook B Characteristics + }); + test.fixme('If a default notebook is deleted, the second most recent notebook becomes the default', async ({ page }) => { + //Create new notebook A + //Create second notebook B + //Delete Notebook B + //Verify Default Notebook A Characteristics + }); +}); + +test.describe('Notebook section tests', () => { + //The following test cases are associated with Notebook Sections + test.fixme('New sections are automatically named Unnamed Section with Unnamed Page', async ({ page }) => { + //Create new notebook A + //Add section + //Verify new section and new page details + }); + test.fixme('Section selection operations and associated behavior', async ({ page }) => { + //Create new notebook A + //Add Sections until 6 total with no default section/page + //Select 3rd section + //Delete 4th section + //3rd section is still selected + //Delete 3rd section + //1st section is selected + //Set 3rd section as default + //Delete 2nd section + //3rd section is still default + //Delete 3rd section + //1st is selected and there is no default notebook + }); +}); + +test.describe('Notebook page tests', () => { + //The following test cases are associated with Notebook Pages + test.fixme('Page selection operations and associated behavior', async ({ page }) => { + //Create new notebook A + //Delete existing Page + //New 'Unnamed Page' automatically created + //Create 6 total Pages without a default page + //Select 3rd + //Delete 3rd + //First is now selected + //Set 3rd as default + //Select 2nd page + //Delete 2nd page + //3rd (default) is now selected + //Set 3rd as default page + //Select 3rd (default) page + //Delete 3rd page + //First is now selected and there is no default notebook + }); +}); + +test.describe('Notebook search tests', () => { + test.fixme('Can search for a single result', async ({ page }) => {}); + test.fixme('Can search for many results', async ({ page }) => {}); + test.fixme('Can search for new and recently modified entries', async ({ page }) => {}); + test.fixme('Can search for section text', async ({ page }) => {}); + test.fixme('Can search for page text', async ({ page }) => {}); + test.fixme('Can search for entry text', async ({ page }) => {}); +}); + +test.describe('Notebook entry tests', () => { + test.fixme('When a new entry is created, it should be focused', async ({ page }) => {}); + test.fixme('When a telemetry object is dropped into a notebook, a new entry is created and it should be focused', async ({ page }) => { + // Drag and drop any telmetry object on 'drop object' + // new entry gets created with telemtry object + }); + test.fixme('When a telemetry object is dropped into a notebooks existing entry, it should be focused', async ({ page }) => { + // Drag and drop any telemetry object onto existing entry + // Entry updated with object and snapshot + }); + test.fixme('new entries persist through navigation events without save', async ({ page }) => {}); + test.fixme('previous and new entries can be deleted', async ({ page }) => {}); +}); + +test.describe('Snapshot Menu tests', () => { + test.fixme('When no default notebook is selected, Snapshot Menu dropdown should only have a single option', async ({ page }) => { + // There should be no default notebook + // Clear default notebook if exists using `localStorage.setItem('notebook-storage', null);` + // refresh page + // Click on 'Notebook Snaphot Menu' + // 'save to Notebook Snapshots' should be only option there + }); + test.fixme('When default notebook is updated selected, Snapshot Menu dropdown should list it as the newest option', async ({ page }) => { + // Create 2a notebooks + // Set Notebook A as Default + // Open Snapshot Menu and note that Notebook A is listed + // Close Snapshot Menu + // Set Default Notebook to Notebook B + // Open Snapshot Notebook and note that Notebook B is listed + // Select Default Notebook Option and verify that Snapshot is added to Notebook B + }); + test.fixme('Can add Snapshots via Snapshot Menu and details are correct', async ({ page }) => { + //Note this should be a visual test, too + // Create Telemetry object + // Create A notebook with many pages and sections. + // Set page and section defaults to be between first and last of many. i.e. 3 of 5 + // Navigate to Telemetry object + // Select Default Notebook Option and verify that Snapshot is added to Notebook A + // Verify Snapshot Details appear correctly + }); + test.fixme('Snapshots adjust time conductor', async ({ page }) => { + // Create Telemetry object + // Set Telemetry object's timeconductor to Fixed time with Start and Endtimes are recorded + // Embed Telemetry object into notebook + // Set Time Conductor to Local clock + // Click into embedded telemetry object and verify object appears with same fixed time from record + }); +}); + +test.describe('Snapshot Container tests', () => { + test.fixme('5 Snapshots can be added to a container', async ({ page }) => {}); + test.fixme('5 Snapshots can be added to a container and Deleted with Delete All action', async ({ page }) => {}); + test.fixme('A snapshot can be Deleted from Container', async ({ page }) => {}); + test.fixme('A snapshot can be Previewed from Container', async ({ page }) => {}); + test.fixme('A snapshot Container can be open and closed', async ({ page }) => {}); + test.fixme('Can add object to Snapshot container and pull into notebook and create a new entry', async ({ page }) => { + //Create Notebook + //Create Telemetry Object + //From Telemetry Object, use 'save to Notebook Snapshots' + //Snapshots indicator should blink, click on it to view snapshots + //Navigate to Notebook + //Drag and Drop onto droppable area for new entry + //New Entry created with given snapshot added + //Snapshot removed from container? + }); + test.fixme('Can add object to Snapshot container and pull into notebook and existing entry', async ({ page }) => { + //Create Notebook + //Create Telemetry Object + //From Telemetry Object, use 'save to Notebook Snapshots' + //Snapshots indicator should blink, click on it to view snapshots + //Navigate to Notebook + //Drag and Drop into exiting entry + //Existing Entry updated with given snapshot + //Snapshot removed from container? + }); + test.fixme('Verify Embedded options for PNG, JPG, and Annotate work correctly', async ({ page }) => { + //Add snapshot to container + //Verify PNG, JPG, and Annotate buttons work correctly + }); +}); diff --git a/e2e/tests/plugins/notebook/restrictedNotebook.e2e.spec.js b/e2e/tests/plugins/notebook/restrictedNotebook.e2e.spec.js new file mode 100644 index 0000000000..12e1cc3220 --- /dev/null +++ b/e2e/tests/plugins/notebook/restrictedNotebook.e2e.spec.js @@ -0,0 +1,264 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2022, 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 } = require('../../../fixtures'); +const { expect } = require('@playwright/test'); +const path = require('path'); + +const TEST_TEXT = 'Testing text for entries.'; +const TEST_TEXT_NAME = 'Test Page'; +const CUSTOM_NAME = 'CUSTOM_NAME'; +const COMMIT_BUTTON_TEXT = 'button:has-text("Commit Entries")'; +const SINE_WAVE_GENERATOR = 'text=Unnamed Sine Wave Generator'; +const NOTEBOOK_DROP_AREA = '.c-notebook__drag-area'; + +/** + * @param {import('@playwright/test').Page} page + */ +async function startAndAddNotebookObject(page) { + // eslint-disable-next-line no-undef + await page.addInitScript({ path: path.join(__dirname, 'addInitRestrictedNotebook.js') }); + //Go to baseURL + await page.goto('/', { waitUntil: 'networkidle' }); + //Click the Create button + await page.click('button:has-text("Create")'); + // Click text=CUSTOME_NAME + await page.click(`text=${CUSTOM_NAME}`); // secondarily tests renamability also + // Click text=OK + await Promise.all([ + page.waitForNavigation({waitUntil: 'networkidle'}), + page.click('text=OK') + ]); + + return; +} + +/** + * @param {import('@playwright/test').Page} page + */ +async function enterTextEntry(page) { + // Click .c-notebook__drag-area + await page.locator(NOTEBOOK_DROP_AREA).click(); + + // enter text + await page.locator('div.c-ne__text').click(); + await page.locator('div.c-ne__text').fill(TEST_TEXT); + await page.locator('div.c-ne__text').press('Enter'); + + return; +} + +/** + * @param {import('@playwright/test').Page} page + */ +async function dragAndDropEmbed(page) { + // Click button:has-text("Create") + await page.locator('button:has-text("Create")').click(); + // Click li:has-text("Sine Wave Generator") + await page.locator('li:has-text("Sine Wave Generator")').click(); + // Click form[name="mctForm"] >> text=My Items + await page.locator('form[name="mctForm"] >> text=My Items').click(); + // Click text=OK + await page.locator('text=OK').click(); + // Click text=Open MCT My Items >> span >> nth=3 + await page.locator('text=Open MCT My Items >> span').nth(3).click(); + // Click text=Unnamed CUSTOM_NAME + await Promise.all([ + page.waitForNavigation(), + page.locator('text=Unnamed CUSTOM_NAME').click() + ]); + + await page.dragAndDrop(SINE_WAVE_GENERATOR, NOTEBOOK_DROP_AREA); + + return; +} + +/** + * @param {import('@playwright/test').Page} page + */ +async function lockPage(page) { + const commitButton = page.locator(COMMIT_BUTTON_TEXT); + await commitButton.click(); + + // confirmation dialog click + await page.locator('text=Lock Page').click(); + + // waiting for mutation of locked page + await new Promise((resolve, reject) => { + setTimeout(resolve, 1000); + }); + + return; +} + +/** + * @param {import('@playwright/test').Page} page + */ +async function openContextMenuRestrictedNotebook(page) { + // Click text=Open MCT My Items (This expands the My Items folder to show it's chilren in the tree) + await page.locator('text=Open MCT My Items >> span').nth(3).click(); + + // Click a:has-text("Unnamed CUSTOM_NAME") + await page.locator(`a:has-text("Unnamed ${CUSTOM_NAME}")`).click({ + button: 'right' + }); + + return; +} + +test.describe('Restricted Notebook', () => { + + test.beforeEach(async ({ page }) => { + await startAndAddNotebookObject(page); + }); + + test('Can be renamed', async ({ page }) => { + await expect.soft(page.locator('.l-browse-bar__object-name')).toContainText(`Unnamed ${CUSTOM_NAME}`); + }); + + test('Can be deleted if there are no locked pages', async ({ page }) => { + await openContextMenuRestrictedNotebook(page); + + const menuOptions = page.locator('.c-menu ul'); + await expect.soft(menuOptions).toContainText('Remove'); + + const restrictedNotebookTreeObject = page.locator(`a:has-text("Unnamed ${CUSTOM_NAME}")`); + + // notbook tree object exists + expect.soft(await restrictedNotebookTreeObject.count()).toEqual(1); + + // Click text=Remove + await page.locator('text=Remove').click(); + // Click text=OK + await Promise.all([ + page.waitForNavigation(/*{ url: 'http://localhost:8080/#/browse/mine?tc.mode=fixed&tc.startBound=1653671067340&tc.endBound=1653672867340&tc.timeSystem=utc&view=grid' }*/), + page.locator('text=OK').click() + ]); + + // has been deleted + expect.soft(await restrictedNotebookTreeObject.count()).toEqual(0); + }); + + test('Can be locked if at least one page has one entry', async ({ page }) => { + + await enterTextEntry(page); + + const commitButton = page.locator(COMMIT_BUTTON_TEXT); + expect.soft(await commitButton.count()).toEqual(1); + }); + +}); + +test.describe('Restricted Notebook with at least one entry and with the page locked', () => { + + test.beforeEach(async ({ page }) => { + await startAndAddNotebookObject(page); + await enterTextEntry(page); + await lockPage(page); + + // open sidebar + await page.locator('button.c-notebook__toggle-nav-button').click(); + }); + + test('Locked page should now be in a locked state', async ({ page }) => { + // main lock message on page + const lockMessage = page.locator('text=This page has been committed and cannot be modified or removed'); + expect.soft(await lockMessage.count()).toEqual(1); + + // lock icon on page in sidebar + const pageLockIcon = page.locator('ul.c-notebook__pages li div.icon-lock'); + expect.soft(await pageLockIcon.count()).toEqual(1); + + // no way to remove a restricted notebook with a locked page + await openContextMenuRestrictedNotebook(page); + + const menuOptions = page.locator('.c-menu ul'); + + await expect.soft(menuOptions).not.toContainText('Remove'); + + }); + + test('Can still: add page, rename, add entry, delete unlocked pages', async ({ page }) => { + // Click text=Page Add >> button + await Promise.all([ + page.waitForNavigation(), + page.locator('text=Page Add >> button').click() + ]); + // Click text=Unnamed Page >> nth=1 + await page.locator('text=Unnamed Page').nth(1).click(); + // Press a with modifiers + await page.locator('text=Unnamed Page').nth(1).fill(TEST_TEXT_NAME); + + // expect to be able to rename unlocked pages + const newPageElement = page.locator(`text=${TEST_TEXT_NAME}`); + const newPageCount = await newPageElement.count(); + await newPageElement.press('Enter'); // exit contenteditable state + expect.soft(newPageCount).toEqual(1); + + // enter test text + await enterTextEntry(page); + + // expect new page to be lockable + const commitButton = page.locator(COMMIT_BUTTON_TEXT); + expect.soft(await commitButton.count()).toEqual(1); + + // Click text=Unnamed PageTest Page >> button + await page.locator('text=Unnamed PageTest Page >> button').click(); + // Click text=Delete Page + await page.locator('text=Delete Page').click(); + // Click text=Ok + await Promise.all([ + page.waitForNavigation(), + page.locator('text=Ok').click() + ]); + + // deleted page, should no longer exist + const deletedPageElement = page.locator(`text=${TEST_TEXT_NAME}`); + expect.soft(await deletedPageElement.count()).toEqual(0); + }); +}); + +test.describe('Restricted Notebook with a page locked and with an embed', () => { + + test.beforeEach(async ({ page }) => { + await startAndAddNotebookObject(page); + await dragAndDropEmbed(page); + }); + + test('Allows embeds to be deleted if page unlocked', async ({ page }) => { + // Click .c-ne__embed__name .c-popup-menu-button + await page.locator('.c-ne__embed__name .c-popup-menu-button').click(); // embed popup menu + + const embedMenu = page.locator('body >> .c-menu'); + await expect.soft(embedMenu).toContainText('Remove This Embed'); + }); + + test('Disallows embeds to be deleted if page locked', async ({ page }) => { + await lockPage(page); + // Click .c-ne__embed__name .c-popup-menu-button + await page.locator('.c-ne__embed__name .c-popup-menu-button').click(); // embed popup menu + + const embedMenu = page.locator('body >> .c-menu'); + await expect.soft(embedMenu).not.toContainText('Remove This Embed'); + }); + +}); diff --git a/e2e/tests/plugins/telemetryTable/telemetryTable.e2e.spec.js b/e2e/tests/plugins/telemetryTable/telemetryTable.e2e.spec.js new file mode 100644 index 0000000000..ceffa3ae5f --- /dev/null +++ b/e2e/tests/plugins/telemetryTable/telemetryTable.e2e.spec.js @@ -0,0 +1,101 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2022, 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 } = require('../../../fixtures'); +const { expect } = require('@playwright/test'); + +test.describe('Telemetry Table', () => { + test('unpauses when paused by button and user changes bounds', async ({ page }) => { + test.info().annotations.push({ + type: 'issue', + description: 'https://github.com/nasa/openmct/issues/5113' + }); + + const bannerMessage = '.c-message-banner__message'; + const createButton = 'button:has-text("Create")'; + + await page.goto('/', { waitUntil: 'networkidle' }); + + // Click create button + await page.locator(createButton).click(); + await page.locator('li:has-text("Telemetry Table")').click(); + + // Click on My Items in Tree. Workaround for https://github.com/nasa/openmct/issues/5184 + await page.click('form[name="mctForm"] a:has-text("My Items")'); + + await Promise.all([ + page.waitForNavigation(), + page.locator('text=OK').click(), + // Wait for Save Banner to appear + page.waitForSelector(bannerMessage) + ]); + + // Save (exit edit mode) + await page.locator('text=Snapshot Save and Finish Editing Save and Continue Editing >> button').nth(3).click(); + await page.locator('text=Save and Finish Editing').click(); + + // Click create button + await page.locator(createButton).click(); + + // add Sine Wave Generator with defaults + await page.locator('li:has-text("Sine Wave Generator")').click(); + + // Click on My Items in Tree. Workaround for https://github.com/nasa/openmct/issues/5184 + await page.click('form[name="mctForm"] a:has-text("My Items")'); + + await Promise.all([ + page.waitForNavigation(), + page.locator('text=OK').click(), + // Wait for Save Banner to appear + page.waitForSelector(bannerMessage) + ]); + + // focus the Telemetry Table + await page.locator('text=Open MCT My Items >> span').nth(3).click(); + await Promise.all([ + page.waitForNavigation(), + page.locator('text=Unnamed Telemetry Table').first().click() + ]); + + // Click pause button + const pauseButton = await page.locator('button.c-button.icon-pause'); + await pauseButton.click(); + + const tableWrapper = await page.locator('div.c-table-wrapper'); + await expect(tableWrapper).toHaveClass(/is-paused/); + + // Arbitrarily change end date to some time in the future + const endTimeInput = page.locator('input[type="text"].c-input--datetime').nth(1); + await endTimeInput.click(); + + let endDate = await endTimeInput.inputValue(); + endDate = new Date(endDate); + endDate.setUTCDate(endDate.getUTCDate() + 1); + endDate = endDate.toISOString().replace(/T.*/, ''); + + await endTimeInput.fill(''); + await endTimeInput.fill(endDate); + await page.keyboard.press('Enter'); + + await expect(tableWrapper).not.toHaveClass(/is-paused/); + }); +}); diff --git a/e2e/tests/recycled_storage.json b/e2e/tests/recycled_storage.json index 86c3f906bc..c20b7ee73d 100644 --- a/e2e/tests/recycled_storage.json +++ b/e2e/tests/recycled_storage.json @@ -6,11 +6,11 @@ "localStorage": [ { "name": "tcHistory", - "value": "{\"utc\":[{\"start\":1651513945533,\"end\":1651515745533}]}" + "value": "{\"utc\":[{\"start\":1652301954635,\"end\":1652303754635}]}" }, { "name": "mct", - "value": "{\"mine\":{\"identifier\":{\"key\":\"mine\",\"namespace\":\"\"},\"name\":\"My Items\",\"type\":\"folder\",\"composition\":[{\"key\":\"c0f99e39-85e7-4ef7-99b1-ef52d4ed69b2\",\"namespace\":\"\"}],\"location\":\"ROOT\",\"persisted\":1651515746374,\"modified\":1651515746374},\"c0f99e39-85e7-4ef7-99b1-ef52d4ed69b2\":{\"name\":\"Unnamed Condition Set\",\"type\":\"conditionSet\",\"identifier\":{\"key\":\"c0f99e39-85e7-4ef7-99b1-ef52d4ed69b2\",\"namespace\":\"\"},\"configuration\":{\"conditionTestData\":[],\"conditionCollection\":[{\"isDefault\":true,\"id\":\"e35a066b-eb0e-4b05-a4c9-cc31dc202572\",\"configuration\":{\"name\":\"Default\",\"output\":\"Default\",\"trigger\":\"all\",\"criteria\":[]},\"summary\":\"Default condition\"}]},\"composition\":[],\"telemetry\":{},\"modified\":1651515746373,\"location\":\"mine\",\"persisted\":1651515746373}}" + "value": "{\"mine\":{\"identifier\":{\"key\":\"mine\",\"namespace\":\"\"},\"name\":\"My Items\",\"type\":\"folder\",\"composition\":[{\"key\":\"f64bea3b-58a7-4586-8c05-8b651e5f0bfd\",\"namespace\":\"\"}],\"location\":\"ROOT\",\"persisted\":1652303756008,\"modified\":1652303756007},\"f64bea3b-58a7-4586-8c05-8b651e5f0bfd\":{\"name\":\"Unnamed Condition Set\",\"type\":\"conditionSet\",\"identifier\":{\"key\":\"f64bea3b-58a7-4586-8c05-8b651e5f0bfd\",\"namespace\":\"\"},\"configuration\":{\"conditionTestData\":[],\"conditionCollection\":[{\"isDefault\":true,\"id\":\"73f2d9ae-d1f3-4561-b7fc-ecd5df557249\",\"configuration\":{\"name\":\"Default\",\"output\":\"Default\",\"trigger\":\"all\",\"criteria\":[]},\"summary\":\"Default condition\"}]},\"composition\":[],\"telemetry\":{},\"modified\":1652303755999,\"location\":\"mine\",\"persisted\":1652303756002}}" }, { "name": "mct-tree-expanded", diff --git a/e2e/tests/visual/default.visual.spec.js b/e2e/tests/visual/default.visual.spec.js index e90e5d38ba..b069f2e95f 100644 --- a/e2e/tests/visual/default.visual.spec.js +++ b/e2e/tests/visual/default.visual.spec.js @@ -192,5 +192,17 @@ test('Visual - Save Successful Banner', async ({ page }) => { //Wait until Save Banner is gone await page.waitForSelector('.c-message-banner__message', { state: 'detached'}); await percySnapshot(page, 'Banner message gone'); +}); + +test('Visual - Display Layout Icon is correct', async ({ page }) => { + //Go to baseURL + await page.goto('/', { waitUntil: 'networkidle' }); + + //Click the Create button + await page.click('button:has-text("Create")'); + + //Hover on Display Layout option. + await page.locator('text=Display Layout').hover(); + await percySnapshot(page, 'Display Layout Create Menu'); }); diff --git a/example/exampleTags/plugin.js b/example/exampleTags/plugin.js new file mode 100644 index 0000000000..b78ad89eb1 --- /dev/null +++ b/example/exampleTags/plugin.js @@ -0,0 +1,33 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2022, 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 availableTags from './tags.json'; +/** + * @returns {function} The plugin install function + */ +export default function exampleTagsPlugin() { + return function install(openmct) { + Object.keys(availableTags.tags).forEach(tagKey => { + const tagDefinition = availableTags.tags[tagKey]; + openmct.annotation.defineTag(tagKey, tagDefinition); + }); + }; +} diff --git a/example/exampleTags/tags.json b/example/exampleTags/tags.json new file mode 100644 index 0000000000..31a1b823a9 --- /dev/null +++ b/example/exampleTags/tags.json @@ -0,0 +1,19 @@ +{ + "tags": { + "46a62ad1-bb86-4f88-9a17-2a029e12669d": { + "label": "Science", + "backgroundColor": "#cc0000", + "foregroundColor": "#ffffff" + }, + "65f150ef-73b7-409a-b2e8-258cbd8b7323": { + "label": "Driving", + "backgroundColor": "#ffad32", + "foregroundColor": "#333333" + }, + "f156b038-c605-46db-88a6-67cf2489a371": { + "label": "Drilling", + "backgroundColor": "#b0ac4e", + "foregroundColor": "#FFFFFF" + } + } +} diff --git a/example/exampleUser/ExampleUserProvider.js b/example/exampleUser/ExampleUserProvider.js index 7e17de98ef..2926f0ba92 100644 --- a/example/exampleUser/ExampleUserProvider.js +++ b/example/exampleUser/ExampleUserProvider.js @@ -24,16 +24,53 @@ import EventEmitter from 'EventEmitter'; import { v4 as uuid } from 'uuid'; import createExampleUser from './exampleUserCreator'; +const STATUSES = [{ + key: "NO_STATUS", + label: "Not set", + iconClass: "icon-question-mark", + iconClassPoll: "icon-status-poll-question-mark" +}, { + key: "GO", + label: "GO", + iconClass: "icon-check", + iconClassPoll: "icon-status-poll-question-mark", + statusClass: "s-status-ok", + statusBgColor: "#33cc33", + statusFgColor: "#000" +}, { + key: "MAYBE", + label: "MAYBE", + iconClass: "icon-alert-triangle", + iconClassPoll: "icon-status-poll-question-mark", + statusClass: "s-status-warning", + statusBgColor: "#ffb66c", + statusFgColor: "#000" +}, { + key: "NO_GO", + label: "NO GO", + iconClass: "icon-circle-slash", + iconClassPoll: "icon-status-poll-question-mark", + statusClass: "s-status-error", + statusBgColor: "#9900cc", + statusFgColor: "#fff" +}]; +/** + * @implements {StatusUserProvider} + */ export default class ExampleUserProvider extends EventEmitter { - constructor(openmct) { + constructor(openmct, {defaultStatusRole} = {defaultStatusRole: undefined}) { super(); this.openmct = openmct; this.user = undefined; this.loggedIn = false; this.autoLoginUser = undefined; + this.status = STATUSES[1]; + this.pollQuestion = undefined; + this.defaultStatusRole = defaultStatusRole; this.ExampleUser = createExampleUser(this.openmct.user.User); + this.loginPromise = undefined; } isLoggedIn() { @@ -45,11 +82,19 @@ export default class ExampleUserProvider extends EventEmitter { } getCurrentUser() { - if (this.loggedIn) { - return Promise.resolve(this.user); + if (!this.loginPromise) { + this.loginPromise = this._login().then(() => this.user); } - return this._login().then(() => this.user); + return this.loginPromise; + } + + canProvideStatusForRole() { + return Promise.resolve(true); + } + + canSetPollQuestion() { + return Promise.resolve(true); } hasRole(roleId) { @@ -60,6 +105,55 @@ export default class ExampleUserProvider extends EventEmitter { return Promise.resolve(this.user.getRoles().includes(roleId)); } + getStatusRoleForCurrentUser() { + return Promise.resolve(this.defaultStatusRole); + } + + getAllStatusRoles() { + return Promise.resolve([this.defaultStatusRole]); + } + + getStatusForRole(role) { + return Promise.resolve(this.status); + } + + async getDefaultStatusForRole(role) { + const allRoles = await this.getPossibleStatuses(); + + return allRoles?.[0]; + } + + setStatusForRole(role, status) { + this.status = status; + this.emit('statusChange', { + role, + status + }); + + return true; + } + + getPollQuestion() { + return Promise.resolve({ + question: 'Set "GO" if your position is ready for a boarding action on the Klingon cruiser', + timestamp: Date.now() + }); + } + + setPollQuestion(pollQuestion) { + this.pollQuestion = { + question: pollQuestion, + timestamp: Date.now() + }; + this.emit("pollQuestionChange", this.pollQuestion); + + return true; + } + + getPossibleStatuses() { + return Promise.resolve(STATUSES); + } + _login() { const id = uuid(); @@ -108,3 +202,6 @@ export default class ExampleUserProvider extends EventEmitter { ); } } +/** + * @typedef {import('@/api/user/StatusUserProvider').default} StatusUserProvider + */ diff --git a/example/exampleUser/plugin.js b/example/exampleUser/plugin.js index f7094131e6..af533f098b 100644 --- a/example/exampleUser/plugin.js +++ b/example/exampleUser/plugin.js @@ -22,8 +22,19 @@ import ExampleUserProvider from './ExampleUserProvider'; -export default function ExampleUserPlugin() { +export default function ExampleUserPlugin({autoLoginUser, defaultStatusRole} = { + autoLoginUser: 'guest', + defaultStatusRole: 'test-role' +}) { return function install(openmct) { - openmct.user.setProvider(new ExampleUserProvider(openmct)); + const userProvider = new ExampleUserProvider(openmct, { + defaultStatusRole + }); + + if (autoLoginUser !== undefined) { + userProvider.autoLogin(autoLoginUser); + } + + openmct.user.setProvider(userProvider); }; } diff --git a/example/exampleUser/pluginSpec.js b/example/exampleUser/pluginSpec.js index dd8ea6bba5..02719d99d5 100644 --- a/example/exampleUser/pluginSpec.js +++ b/example/exampleUser/pluginSpec.js @@ -26,7 +26,7 @@ import { } from '../../src/utils/testing'; import ExampleUserProvider from './ExampleUserProvider'; -xdescribe("The Example User Plugin", () => { +describe("The Example User Plugin", () => { let openmct; beforeEach(() => { @@ -47,9 +47,4 @@ xdescribe("The Example User Plugin", () => { }); openmct.install(openmct.plugins.example.ExampleUser()); }); - - // The rest of the functionality of the ExampleUser Plugin is - // tested in both the UserAPISpec.js and in the UserIndicatorPlugin spec. - // If that changes, those tests can be moved here. - }); diff --git a/example/faultManagment/exampleFaultSource.js b/example/faultManagment/exampleFaultSource.js new file mode 100644 index 0000000000..338f0903b5 --- /dev/null +++ b/example/faultManagment/exampleFaultSource.js @@ -0,0 +1,83 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2022, 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 default function () { + return function install(openmct) { + openmct.install(openmct.plugins.FaultManagement()); + + openmct.faults.addProvider({ + request(domainObject, options) { + const faults = JSON.parse(localStorage.getItem('faults')); + + return Promise.resolve(faults.alarms); + }, + subscribe(domainObject, callback) { + const faultsData = JSON.parse(localStorage.getItem('faults')).alarms; + + function getRandomIndex(start, end) { + return Math.floor(start + (Math.random() * (end - start + 1))); + } + + let id = setInterval(() => { + const index = getRandomIndex(0, faultsData.length - 1); + const randomFaultData = faultsData[index]; + const randomFault = randomFaultData.fault; + randomFault.currentValueInfo.value = Math.random(); + callback({ + fault: randomFault, + type: 'alarms' + }); + }, 300); + + return () => { + clearInterval(id); + }; + }, + supportsRequest(domainObject) { + const faults = localStorage.getItem('faults'); + + return faults && domainObject.type === 'faultManagement'; + }, + supportsSubscribe(domainObject) { + const faults = localStorage.getItem('faults'); + + return faults && domainObject.type === 'faultManagement'; + }, + acknowledgeFault(fault, { comment = '' }) { + console.log('acknowledgeFault', fault); + console.log('comment', comment); + + return Promise.resolve({ + success: true + }); + }, + shelveFault(fault, shelveData) { + console.log('shelveFault', fault); + console.log('shelveData', shelveData); + + return Promise.resolve({ + success: true + }); + } + }); + }; +} diff --git a/example/faultManagment/pluginSpec.js b/example/faultManagment/pluginSpec.js new file mode 100644 index 0000000000..b7a0fa6801 --- /dev/null +++ b/example/faultManagment/pluginSpec.js @@ -0,0 +1,47 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2022, 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 { + createOpenMct, + resetApplicationState +} from '../../src/utils/testing'; + +describe("The Example Fault Source Plugin", () => { + let openmct; + + beforeEach(() => { + openmct = createOpenMct(); + }); + + afterEach(() => { + return resetApplicationState(openmct); + }); + + it('is not installed by default', () => { + expect(openmct.faults.provider).toBeUndefined(); + }); + + it('can be installed', () => { + openmct.install(openmct.plugins.example.ExampleFaultSource()); + expect(openmct.faults.provider).not.toBeUndefined(); + }); +}); diff --git a/example/generator/GeneratorMetadataProvider.js b/example/generator/GeneratorMetadataProvider.js index 7a8cd9832a..f274d2d53d 100644 --- a/example/generator/GeneratorMetadataProvider.js +++ b/example/generator/GeneratorMetadataProvider.js @@ -29,12 +29,12 @@ define([ } }, { - key: "cos", - name: "Cosine", - unit: "deg", - formatString: '%0.2f', + key: "wavelengths", + name: "Wavelength", + unit: "nm", + format: 'string[]', hints: { - domain: 3 + range: 4 } }, // Need to enable "LocalTimeSystem" plugin to make use of this @@ -64,6 +64,14 @@ define([ hints: { range: 2 } + }, + { + key: "intensities", + name: "Intensities", + format: 'number[]', + hints: { + range: 3 + } } ] }, diff --git a/example/generator/WorkerInterface.js b/example/generator/WorkerInterface.js index 2ddb3ee181..1573800fff 100644 --- a/example/generator/WorkerInterface.js +++ b/example/generator/WorkerInterface.js @@ -23,7 +23,7 @@ define([ 'uuid' ], function ( - uuid + { v4: uuid } ) { function WorkerInterface(openmct) { // eslint-disable-next-line no-undef diff --git a/example/generator/generatorWorker.js b/example/generator/generatorWorker.js index 6cf8730634..02807e06f2 100644 --- a/example/generator/generatorWorker.js +++ b/example/generator/generatorWorker.js @@ -77,7 +77,8 @@ utc: nextStep, yesterday: nextStep - 60 * 60 * 24 * 1000, sin: sin(nextStep, data.period, data.amplitude, data.offset, data.phase, data.randomness), - wavelength: wavelength(start, nextStep), + wavelengths: wavelengths(), + intensities: intensities(), cos: cos(nextStep, data.period, data.amplitude, data.offset, data.phase, data.randomness) } }); @@ -126,7 +127,8 @@ utc: nextStep, yesterday: nextStep - 60 * 60 * 24 * 1000, sin: sin(nextStep, period, amplitude, offset, phase, randomness), - wavelength: wavelength(start, nextStep), + wavelengths: wavelengths(), + intensities: intensities(), cos: cos(nextStep, period, amplitude, offset, phase, randomness) }); } @@ -154,8 +156,28 @@ * Math.sin(phase + (timestamp / period / 1000 * Math.PI * 2)) + (amplitude * Math.random() * randomness) + offset; } - function wavelength(start, nextStep) { - return (nextStep - start) / 10; + function wavelengths() { + let values = []; + while (values.length < 5) { + const randomValue = Math.random() * 100; + if (!values.includes(randomValue)) { + values.push(String(randomValue)); + } + } + + return values; + } + + function intensities() { + let values = []; + while (values.length < 5) { + const randomValue = Math.random() * 10; + if (!values.includes(randomValue)) { + values.push(String(randomValue)); + } + } + + return values; } function sendError(error, message) { diff --git a/example/imagery/plugin.js b/example/imagery/plugin.js index 6823ede509..47d6f4ef70 100644 --- a/example/imagery/plugin.js +++ b/example/imagery/plugin.js @@ -59,7 +59,8 @@ export default function () { object.configuration = { imageLocation: '', imageLoadDelayInMilliSeconds: DEFAULT_IMAGE_LOAD_DELAY_IN_MILISECONDS, - imageSamples: [] + imageSamples: [], + layers: [] }; object.telemetry = { @@ -90,7 +91,21 @@ export default function () { format: 'image', hints: { image: 1 - } + }, + layers: [ + { + source: 'dist/imagery/example-imagery-layer-16x9.png', + name: '16:9' + }, + { + source: 'dist/imagery/example-imagery-layer-safe.png', + name: 'Safe' + }, + { + source: 'dist/imagery/example-imagery-layer-scale.png', + name: 'Scale' + } + ] }, { name: 'Image Download Name', diff --git a/index.html b/index.html index 4cfcacb001..d8ec226c49 100644 --- a/index.html +++ b/index.html @@ -75,12 +75,12 @@ const TWO_HOURS = ONE_HOUR * 2; const ONE_DAY = ONE_HOUR * 24; - openmct.install(openmct.plugins.LocalStorage()); openmct.install(openmct.plugins.example.Generator()); openmct.install(openmct.plugins.example.EventGeneratorPlugin()); openmct.install(openmct.plugins.example.ExampleImagery()); + openmct.install(openmct.plugins.example.ExampleTags()); openmct.install(openmct.plugins.Espresso()); openmct.install(openmct.plugins.MyItems()); @@ -191,7 +191,7 @@ openmct.install(openmct.plugins.ObjectMigration()); openmct.install(openmct.plugins.ClearData( ['table', 'telemetry.plot.overlay', 'telemetry.plot.stacked', 'example.imagery'], - {indicator: true} + { indicator: true } )); openmct.install(openmct.plugins.Clock({ enableClockIndicator: true })); openmct.install(openmct.plugins.Timer()); diff --git a/package.json b/package.json index 0588500276..4a91bc8ac5 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,9 @@ { "name": "openmct", - "version": "2.0.4-SNAPSHOT", + "version": "2.0.5-SNAPSHOT", "description": "The Open MCT core platform", "devDependencies": { - "@babel/eslint-parser": "7.16.3", + "@babel/eslint-parser": "7.18.2", "@braintree/sanitize-url": "6.0.0", "@percy/cli": "1.2.1", "@percy/playwright": "1.0.4", @@ -25,10 +25,9 @@ "eslint": "8.13.0", "eslint-plugin-compat": "4.0.2", "eslint-plugin-playwright": "0.9.0", - "eslint-plugin-vue": "8.5.0", + "eslint-plugin-vue": "9.1.0", "eslint-plugin-you-dont-need-lodash-underscore": "6.12.0", "eventemitter3": "1.2.0", - "exports-loader": "0.7.0", "express": "4.13.1", "file-saver": "2.0.5", "git-rev-sync": "3.0.2", @@ -61,21 +60,20 @@ "printj": "1.3.1", "request": "2.88.2", "resolve-url-loader": "5.0.0", - "sass": "1.49.9", + "sass": "1.52.2", "sass-loader": "12.6.0", "sinon": "14.0.0", "style-loader": "^1.0.1", "uuid": "8.3.2", "vue": "2.6.14", - "vue-eslint-parser": "8.3.0", + "vue-eslint-parser": "9.0.2", "vue-loader": "15.9.8", "vue-template-compiler": "2.6.14", "webpack": "5.68.0", "webpack-cli": "4.9.2", "webpack-dev-middleware": "5.3.3", "webpack-hot-middleware": "2.25.1", - "webpack-merge": "5.8.0", - "zepto": "1.2.0" + "webpack-merge": "5.8.0" }, "scripts": { "clean": "rm -rf ./dist ./node_modules ./package-lock.json", diff --git a/src/MCT.js b/src/MCT.js index 0e46eab52e..7fa54e7ad1 100644 --- a/src/MCT.js +++ b/src/MCT.js @@ -42,6 +42,7 @@ define([ './plugins/duplicate/plugin', './plugins/importFromJSONAction/plugin', './plugins/exportAsJSONAction/plugin', + './ui/components/components', 'vue' ], function ( EventEmitter, @@ -65,6 +66,7 @@ define([ DuplicateActionPlugin, ImportFromJSONAction, ExportAsJSONAction, + components, Vue ) { /** @@ -236,10 +238,20 @@ define([ this.priority = api.PriorityAPI; this.router = new ApplicationRouter(this); + this.faults = new api.FaultManagementAPI.default(this); this.forms = new api.FormsAPI.default(this); this.branding = BrandingAPI.default; + /** + * MCT's annotation API that enables + * human-created comments and categorization linked to data products + * @type {module:openmct.AnnotationAPI} + * @memberof module:openmct.MCT# + * @name annotation + */ + this.annotation = new api.AnnotationAPI(this); + // Plugins that are installed by default this.install(this.plugins.Plot()); this.install(this.plugins.TelemetryTable.default()); @@ -377,6 +389,7 @@ define([ }; MCT.prototype.plugins = plugins; + MCT.prototype.components = components.default; return MCT; }); diff --git a/src/api/annotation/AnnotationAPI.js b/src/api/annotation/AnnotationAPI.js new file mode 100644 index 0000000000..a856114957 --- /dev/null +++ b/src/api/annotation/AnnotationAPI.js @@ -0,0 +1,275 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2022, 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 { v4 as uuid } from 'uuid'; +import EventEmitter from 'EventEmitter'; + +/** + * @readonly + * @enum {String} AnnotationType + * @property {String} NOTEBOOK The notebook annotation type + * @property {String} GEOSPATIAL The geospatial annotation type + * @property {String} PIXEL_SPATIAL The pixel-spatial annotation type + * @property {String} TEMPORAL The temporal annotation type + * @property {String} PLOT_SPATIAL The plot-spatial annotation type + */ +const ANNOTATION_TYPES = Object.freeze({ + NOTEBOOK: 'NOTEBOOK', + GEOSPATIAL: 'GEOSPATIAL', + PIXEL_SPATIAL: 'PIXEL_SPATIAL', + TEMPORAL: 'TEMPORAL', + PLOT_SPATIAL: 'PLOT_SPATIAL' +}); + +/** + * @typedef {Object} Tag + * @property {String} key a unique identifier for the tag + * @property {String} backgroundColor eg. "#cc0000" + * @property {String} foregroundColor eg. "#ffffff" + */ +export default class AnnotationAPI extends EventEmitter { + constructor(openmct) { + super(); + this.openmct = openmct; + this.availableTags = {}; + + this.ANNOTATION_TYPES = ANNOTATION_TYPES; + + this.openmct.types.addType('annotation', { + name: 'Annotation', + description: 'A user created note or comment about time ranges, pixel space, and geospatial features.', + creatable: false, + cssClass: 'icon-notebook', + initialize: function (domainObject) { + domainObject.targets = domainObject.targets || {}; + domainObject.originalContextPath = domainObject.originalContextPath || ''; + domainObject.tags = domainObject.tags || []; + domainObject.contentText = domainObject.contentText || ''; + domainObject.annotationType = domainObject.annotationType || 'plotspatial'; + } + }); + } + + /** + * Create the a generic annotation + * @typedef {Object} CreateAnnotationOptions + * @property {String} name a name for the new parameter + * @property {import('../objects/ObjectAPI').DomainObject} domainObject the domain object to create + * @property {ANNOTATION_TYPES} annotationType the type of annotation to create + * @property {Tag[]} tags + * @property {String} contentText + * @property {import('../objects/ObjectAPI').Identifier[]} targets + */ + /** + * @method create + * @param {CreateAnnotationOptions} options + * @returns {Promise} a promise which will resolve when the domain object + * has been created, or be rejected if it cannot be saved + */ + async create({name, domainObject, annotationType, tags, contentText, targets}) { + if (!Object.keys(this.ANNOTATION_TYPES).includes(annotationType)) { + throw new Error(`Unknown annotation type: ${annotationType}`); + } + + if (!Object.keys(targets).length) { + throw new Error(`At least one target is required to create an annotation`); + } + + const domainObjectKeyString = this.openmct.objects.makeKeyString(domainObject.identifier); + const originalPathObjects = await this.openmct.objects.getOriginalPath(domainObjectKeyString); + const originalContextPath = this.openmct.objects.getRelativePath(originalPathObjects); + const namespace = domainObject.identifier.namespace; + const type = 'annotation'; + const typeDefinition = this.openmct.types.get(type); + const definition = typeDefinition.definition; + + const createdObject = { + name, + type, + identifier: { + key: uuid(), + namespace + }, + tags, + annotationType, + contentText, + originalContextPath + }; + + if (definition.initialize) { + definition.initialize(createdObject); + } + + createdObject.targets = targets; + createdObject.originalContextPath = originalContextPath; + + const success = await this.openmct.objects.save(createdObject); + if (success) { + this.emit('annotationCreated', createdObject); + + return createdObject; + } else { + throw new Error('Failed to create object'); + } + } + + defineTag(tagKey, tagsDefinition) { + this.availableTags[tagKey] = tagsDefinition; + } + + getAvailableTags() { + if (this.availableTags) { + const rearrangedToArray = Object.keys(this.availableTags).map(tagKey => { + return { + id: tagKey, + ...this.availableTags[tagKey] + }; + }); + + return rearrangedToArray; + } else { + return []; + } + } + + async getAnnotation(query, searchType) { + let foundAnnotation = null; + + const searchResults = (await Promise.all(this.openmct.objects.search(query, null, searchType))).flat(); + if (searchResults) { + foundAnnotation = searchResults[0]; + } + + return foundAnnotation; + } + + async addAnnotationTag(existingAnnotation, targetDomainObject, targetSpecificDetails, annotationType, tag) { + if (!existingAnnotation) { + const targets = {}; + const targetKeyString = this.openmct.objects.makeKeyString(targetDomainObject.identifier); + targets[targetKeyString] = targetSpecificDetails; + const contentText = `${annotationType} tag`; + const annotationCreationArguments = { + name: contentText, + domainObject: targetDomainObject, + annotationType, + tags: [], + contentText, + targets + }; + existingAnnotation = await this.create(annotationCreationArguments); + } + + const tagArray = [tag, ...existingAnnotation.tags]; + this.openmct.objects.mutate(existingAnnotation, 'tags', tagArray); + + return existingAnnotation; + } + + removeAnnotationTag(existingAnnotation, tagToRemove) { + if (existingAnnotation && existingAnnotation.tags.includes(tagToRemove)) { + const cleanedArray = existingAnnotation.tags.filter(extantTag => extantTag !== tagToRemove); + this.openmct.objects.mutate(existingAnnotation, 'tags', cleanedArray); + } else { + throw new Error(`Asked to remove tag (${tagToRemove}) that doesn't exist`, existingAnnotation); + } + } + + removeAnnotationTags(existingAnnotation) { + // just removes tags on the annotation as we can't really delete objects + if (existingAnnotation && existingAnnotation.tags) { + this.openmct.objects.mutate(existingAnnotation, 'tags', []); + } + } + + #getMatchingTags(query) { + if (!query) { + return []; + } + + const matchingTags = Object.keys(this.availableTags).filter(tagKey => { + if (this.availableTags[tagKey] && this.availableTags[tagKey].label) { + return this.availableTags[tagKey].label.toLowerCase().includes(query.toLowerCase()); + } + + return false; + }); + + return matchingTags; + } + + #addTagMetaInformationToResults(results, matchingTagKeys) { + const tagsAddedToResults = results.map(result => { + const fullTagModels = result.tags.map(tagKey => { + const tagModel = this.availableTags[tagKey]; + tagModel.tagID = tagKey; + + return tagModel; + }); + + return { + fullTagModels, + matchingTagKeys, + ...result + }; + }); + + return tagsAddedToResults; + } + + async #addTargetModelsToResults(results) { + const modelAddedToResults = await Promise.all(results.map(async result => { + const targetModels = await Promise.all(Object.keys(result.targets).map(async (targetID) => { + const targetModel = await this.openmct.objects.get(targetID); + const targetKeyString = this.openmct.objects.makeKeyString(targetModel.identifier); + const originalPathObjects = await this.openmct.objects.getOriginalPath(targetKeyString); + + return { + originalPath: originalPathObjects, + ...targetModel + }; + })); + + return { + targetModels, + ...result + }; + })); + + return modelAddedToResults; + } + + /** + * @method searchForTags + * @param {String} query A query to match against tags. E.g., "dr" will match the tags "drilling" and "driving" + * @param {Object} abortController An optional abort method to stop the query + * @returns {Promise} returns a model of matching tags with their target domain objects attached + */ + async searchForTags(query, abortController) { + const matchingTagKeys = this.#getMatchingTags(query); + const searchResults = (await Promise.all(this.openmct.objects.search(matchingTagKeys, abortController, this.openmct.objects.SEARCH_TYPES.TAGS))).flat(); + const appliedTagSearchResults = this.#addTagMetaInformationToResults(searchResults, matchingTagKeys); + const appliedTargetsModels = await this.#addTargetModelsToResults(appliedTagSearchResults); + + return appliedTargetsModels; + } +} diff --git a/src/api/annotation/AnnotationAPISpec.js b/src/api/annotation/AnnotationAPISpec.js new file mode 100644 index 0000000000..731aead3cc --- /dev/null +++ b/src/api/annotation/AnnotationAPISpec.js @@ -0,0 +1,176 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2022, 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 { createOpenMct, resetApplicationState } from '../../utils/testing'; +import ExampleTagsPlugin from "../../../example/exampleTags/plugin"; + +describe("The Annotation API", () => { + let openmct; + let mockObjectProvider; + let mockDomainObject; + let mockAnnotationObject; + + beforeEach((done) => { + openmct = createOpenMct(); + openmct.install(new ExampleTagsPlugin()); + const availableTags = openmct.annotation.getAvailableTags(); + mockDomainObject = { + type: 'notebook', + name: 'fooRabbitNotebook', + identifier: { + key: 'some-object', + namespace: 'fooNameSpace' + } + }; + mockAnnotationObject = { + type: 'annotation', + name: 'Some Notebook Annotation', + annotationType: openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, + tags: [availableTags[0].id, availableTags[1].id], + identifier: { + key: 'anAnnotationKey', + namespace: 'fooNameSpace' + }, + targets: { + 'fooNameSpace:some-object': { + entryId: 'fooBarEntry' + } + } + }; + + mockObjectProvider = jasmine.createSpyObj("mock provider", [ + "create", + "update", + "get" + ]); + // eslint-disable-next-line require-await + mockObjectProvider.get = async (identifier) => { + if (identifier.key === mockDomainObject.identifier.key) { + return mockDomainObject; + } else if (identifier.key === mockAnnotationObject.identifier.key) { + return mockAnnotationObject; + } else { + return null; + } + }; + + mockObjectProvider.create.and.returnValue(Promise.resolve(true)); + mockObjectProvider.update.and.returnValue(Promise.resolve(true)); + + openmct.objects.addProvider('fooNameSpace', mockObjectProvider); + openmct.on('start', done); + openmct.startHeadless(); + }); + afterEach(async () => { + openmct.objects.providers = {}; + await resetApplicationState(openmct); + }); + it("is defined", () => { + expect(openmct.annotation).toBeDefined(); + }); + + describe("Creation", () => { + it("can create annotations", async () => { + const annotationCreationArguments = { + name: 'Test Annotation', + domainObject: mockDomainObject, + annotationType: openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, + tags: ['sometag'], + contentText: "fooContext", + targets: {'fooTarget': {}} + }; + const annotationObject = await openmct.annotation.create(annotationCreationArguments); + expect(annotationObject).toBeDefined(); + expect(annotationObject.type).toEqual('annotation'); + }); + it("fails if annotation is an unknown type", async () => { + try { + await openmct.annotation.create('Garbage Annotation', mockDomainObject, 'garbageAnnotation', ['sometag'], "fooContext", {'fooTarget': {}}); + } catch (error) { + expect(error).toBeDefined(); + } + }); + }); + + describe("Tagging", () => { + it("can create a tag", async () => { + const annotationObject = await openmct.annotation.addAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag'); + expect(annotationObject).toBeDefined(); + expect(annotationObject.type).toEqual('annotation'); + expect(annotationObject.tags).toContain('aWonderfulTag'); + }); + it("can delete a tag", async () => { + const originalAnnotationObject = await openmct.annotation.addAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag'); + const annotationObject = await openmct.annotation.addAnnotationTag(originalAnnotationObject, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'anotherTagToRemove'); + expect(annotationObject).toBeDefined(); + openmct.annotation.removeAnnotationTag(annotationObject, 'anotherTagToRemove'); + expect(annotationObject.tags).toEqual(['aWonderfulTag']); + openmct.annotation.removeAnnotationTag(annotationObject, 'aWonderfulTag'); + expect(annotationObject.tags).toEqual([]); + }); + it("throws an error if deleting non-existent tag", async () => { + const annotationObject = await openmct.annotation.addAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag'); + expect(annotationObject).toBeDefined(); + expect(() => { + openmct.annotation.removeAnnotationTag(annotationObject, 'ThisTagShouldNotExist'); + }).toThrow(); + }); + it("can remove all tags", async () => { + const annotationObject = await openmct.annotation.addAnnotationTag(null, mockDomainObject, {entryId: 'foo'}, openmct.annotation.ANNOTATION_TYPES.NOTEBOOK, 'aWonderfulTag'); + expect(annotationObject).toBeDefined(); + expect(() => { + openmct.annotation.removeAnnotationTags(annotationObject); + }).not.toThrow(); + expect(annotationObject.tags).toEqual([]); + }); + }); + + describe("Search", () => { + let sharedWorkerToRestore; + beforeEach(async () => { + // use local worker + sharedWorkerToRestore = openmct.objects.inMemorySearchProvider.worker; + openmct.objects.inMemorySearchProvider.worker = null; + await openmct.objects.inMemorySearchProvider.index(mockDomainObject); + await openmct.objects.inMemorySearchProvider.index(mockAnnotationObject); + }); + afterEach(() => { + openmct.objects.inMemorySearchProvider.worker = sharedWorkerToRestore; + }); + it("can search for tags", async () => { + const results = await openmct.annotation.searchForTags('S'); + expect(results).toBeDefined(); + expect(results.length).toEqual(1); + }); + it("can get notebook annotations", async () => { + const targetKeyString = openmct.objects.makeKeyString(mockDomainObject.identifier); + const query = { + targetKeyString, + entryId: 'fooBarEntry' + }; + + const results = await openmct.annotation.getAnnotation(query, openmct.objects.SEARCH_TYPES.NOTEBOOK_ANNOTATIONS); + expect(results).toBeDefined(); + expect(results.tags.length).toEqual(2); + }); + }); +}); diff --git a/src/api/api.js b/src/api/api.js index 1a0174d574..505213476a 100644 --- a/src/api/api.js +++ b/src/api/api.js @@ -24,6 +24,7 @@ define([ './actions/ActionsAPI', './composition/CompositionAPI', './Editor', + './faultmanagement/FaultManagementAPI', './forms/FormsAPI', './indicators/IndicatorAPI', './menu/MenuAPI', @@ -34,11 +35,13 @@ define([ './telemetry/TelemetryAPI', './time/TimeAPI', './types/TypeRegistry', - './user/UserAPI' + './user/UserAPI', + './annotation/AnnotationAPI' ], function ( ActionsAPI, CompositionAPI, EditorAPI, + FaultManagementAPI, FormsAPI, IndicatorAPI, MenuAPI, @@ -49,14 +52,16 @@ define([ TelemetryAPI, TimeAPI, TypeRegistry, - UserAPI + UserAPI, + AnnotationAPI ) { return { ActionsAPI: ActionsAPI.default, CompositionAPI: CompositionAPI, EditorAPI: EditorAPI, + FaultManagementAPI: FaultManagementAPI, FormsAPI: FormsAPI, - IndicatorAPI: IndicatorAPI, + IndicatorAPI: IndicatorAPI.default, MenuAPI: MenuAPI.default, NotificationAPI: NotificationAPI.default, ObjectAPI: ObjectAPI, @@ -65,6 +70,7 @@ define([ TelemetryAPI: TelemetryAPI, TimeAPI: TimeAPI.default, TypeRegistry: TypeRegistry, - UserAPI: UserAPI.default + UserAPI: UserAPI.default, + AnnotationAPI: AnnotationAPI.default }; }); diff --git a/src/api/faultmanagement/FaultManagementAPI.js b/src/api/faultmanagement/FaultManagementAPI.js new file mode 100644 index 0000000000..b22a85c093 --- /dev/null +++ b/src/api/faultmanagement/FaultManagementAPI.js @@ -0,0 +1,106 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2022, 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 default class FaultManagementAPI { + constructor(openmct) { + this.openmct = openmct; + } + + addProvider(provider) { + this.provider = provider; + } + + supportsActions() { + return this.provider?.acknowledgeFault !== undefined && this.provider?.shelveFault !== undefined; + } + + request(domainObject) { + if (!this.provider?.supportsRequest(domainObject)) { + return Promise.reject(); + } + + return this.provider.request(domainObject); + } + + subscribe(domainObject, callback) { + if (!this.provider?.supportsSubscribe(domainObject)) { + return Promise.reject(); + } + + return this.provider.subscribe(domainObject, callback); + } + + acknowledgeFault(fault, ackData) { + return this.provider.acknowledgeFault(fault, ackData); + } + + shelveFault(fault, shelveData) { + return this.provider.shelveFault(fault, shelveData); + } +} + +/** @typedef {object} Fault + * @property {string} type + * @property {object} fault + * @property {boolean} fault.acknowledged + * @property {object} fault.currentValueInfo + * @property {number} fault.currentValueInfo.value + * @property {string} fault.currentValueInfo.rangeCondition + * @property {string} fault.currentValueInfo.monitoringResult + * @property {string} fault.id + * @property {string} fault.name + * @property {string} fault.namespace + * @property {number} fault.seqNum + * @property {string} fault.severity + * @property {boolean} fault.shelved + * @property {string} fault.shortDescription + * @property {string} fault.triggerTime + * @property {object} fault.triggerValueInfo + * @property {number} fault.triggerValueInfo.value + * @property {string} fault.triggerValueInfo.rangeCondition + * @property {string} fault.triggerValueInfo.monitoringResult + * @example + * { + * "type": "", + * "fault": { + * "acknowledged": true, + * "currentValueInfo": { + * "value": 0, + * "rangeCondition": "", + * "monitoringResult": "" + * }, + * "id": "", + * "name": "", + * "namespace": "", + * "seqNum": 0, + * "severity": "", + * "shelved": true, + * "shortDescription": "", + * "triggerTime": "", + * "triggerValueInfo": { + * "value": 0, + * "rangeCondition": "", + * "monitoringResult": "" + * } + * } + * } + */ diff --git a/src/api/faultmanagement/FaultManagementAPISpec.js b/src/api/faultmanagement/FaultManagementAPISpec.js new file mode 100644 index 0000000000..61c87402e7 --- /dev/null +++ b/src/api/faultmanagement/FaultManagementAPISpec.js @@ -0,0 +1,144 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2022, 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 { + createOpenMct, + resetApplicationState +} from '../../utils/testing'; + +const faultName = 'super duper fault'; +const aFault = { + type: '', + fault: { + acknowledged: true, + currentValueInfo: { + value: 0, + rangeCondition: '', + monitoringResult: '' + }, + id: '', + name: faultName, + namespace: '', + seqNum: 0, + severity: '', + shelved: true, + shortDescription: '', + triggerTime: '', + triggerValueInfo: { + value: 0, + rangeCondition: '', + monitoringResult: '' + } + } +}; +const faultDomainObject = { + name: 'it is not your fault', + type: 'faultManagement', + identifier: { + key: 'nobodies', + namespace: 'fault' + } +}; +const aComment = 'THIS is my fault.'; +const faultManagementProvider = { + request() { + return Promise.resolve([aFault]); + }, + subscribe(domainObject, callback) { + return () => {}; + }, + supportsRequest(domainObject) { + return domainObject.type === 'faultManagement'; + }, + supportsSubscribe(domainObject) { + return domainObject.type === 'faultManagement'; + }, + acknowledgeFault(fault, { comment = '' }) { + return Promise.resolve({ + success: true + }); + }, + shelveFault(fault, shelveData) { + return Promise.resolve({ + success: true + }); + } +}; + +describe('The Fault Management API', () => { + let openmct; + + beforeEach(() => { + openmct = createOpenMct(); + openmct.install(openmct.plugins.FaultManagement()); + // openmct.install(openmct.plugins.example.ExampleFaultSource()); + openmct.faults.addProvider(faultManagementProvider); + }); + + afterEach(() => { + return resetApplicationState(openmct); + }); + + it('allows you to request a fault', async () => { + spyOn(faultManagementProvider, 'supportsRequest').and.callThrough(); + + let faultResponse = await openmct.faults.request(faultDomainObject); + + expect(faultManagementProvider.supportsRequest).toHaveBeenCalledWith(faultDomainObject); + expect(faultResponse[0].fault.name).toEqual(faultName); + }); + + it('allows you to subscribe to a fault', () => { + spyOn(faultManagementProvider, 'subscribe').and.callThrough(); + spyOn(faultManagementProvider, 'supportsSubscribe').and.callThrough(); + + let unsubscribe = openmct.faults.subscribe(faultDomainObject, () => {}); + + expect(unsubscribe).toEqual(jasmine.any(Function)); + expect(faultManagementProvider.supportsSubscribe).toHaveBeenCalledWith(faultDomainObject); + expect(faultManagementProvider.subscribe).toHaveBeenCalledOnceWith(faultDomainObject, jasmine.any(Function)); + + }); + + it('will tell you if the fault management provider supports actions', () => { + expect(openmct.faults.supportsActions()).toBeTrue(); + }); + + it('will allow you to acknowledge a fault', async () => { + spyOn(faultManagementProvider, 'acknowledgeFault').and.callThrough(); + + let ackResponse = await openmct.faults.acknowledgeFault(aFault, aComment); + + expect(faultManagementProvider.acknowledgeFault).toHaveBeenCalledWith(aFault, aComment); + expect(ackResponse.success).toBeTrue(); + }); + + it('will allow you to shelve a fault', async () => { + spyOn(faultManagementProvider, 'shelveFault').and.callThrough(); + + let shelveResponse = await openmct.faults.shelveFault(aFault, aComment); + + expect(faultManagementProvider.shelveFault).toHaveBeenCalledWith(aFault, aComment); + expect(shelveResponse.success).toBeTrue(); + }); + +}); diff --git a/src/api/forms/components/FormProperties.vue b/src/api/forms/components/FormProperties.vue index eac28b2f7e..f6c5a62c8b 100644 --- a/src/api/forms/components/FormProperties.vue +++ b/src/api/forms/components/FormProperties.vue @@ -44,18 +44,14 @@ > {{ section.name }} -
- -
+ :css-class="row.cssClass" + :first="index < 1" + :row="row" + @onChange="onChange" + /> diff --git a/src/api/forms/components/FormRow.vue b/src/api/forms/components/FormRow.vue index c6e3aba454..a7e9ac5b4c 100644 --- a/src/api/forms/components/FormRow.vue +++ b/src/api/forms/components/FormRow.vue @@ -23,7 +23,10 @@