diff options
author | Scott Bell <scott@traclabs.com> | 2022-08-17 20:05:29 +0300 |
---|---|---|
committer | GitHub <noreply@github.com> | 2022-08-17 20:05:29 +0300 |
commit | 4787827705e7b4219b8a5c18b64cdcb07192cefc (patch) | |
tree | b3e90091699537b1351f89f8dfd4039213957374 | |
parent | fd389cfa4d36b0296a6f0e6573c4708241f5bea8 (diff) | |
parent | b47712a0f42dc753c4e52bfc8a1dff72cd214223 (diff) |
Merge branch 'release/2.0.8' into mct5549-fix-composition-errormct5549-fix-composition-error
-rw-r--r-- | e2e/appActions.js | 19 | ||||
-rw-r--r-- | e2e/tests/functional/plugins/notebook/restrictedNotebook.e2e.spec.js | 27 | ||||
-rw-r--r-- | e2e/tests/functional/plugins/timer/timer.e2e.spec.js | 17 | ||||
-rw-r--r-- | e2e/tests/functional/tree.e2e.spec.js | 138 | ||||
-rw-r--r-- | e2e/tests/visual/components/tree.visual.spec.js | 101 | ||||
-rw-r--r-- | package.json | 2 | ||||
-rw-r--r-- | src/ui/layout/Layout.vue | 1 | ||||
-rw-r--r-- | src/ui/layout/mct-tree.vue | 53 | ||||
-rw-r--r-- | src/ui/layout/tree-item.vue | 4 |
9 files changed, 318 insertions, 44 deletions
diff --git a/e2e/appActions.js b/e2e/appActions.js index e4fbf7589..c7a4428d5 100644 --- a/e2e/appActions.js +++ b/e2e/appActions.js @@ -102,20 +102,15 @@ async function createDomainObjectWithDefaults(page, { type, name, parent = 'mine /** * Open the given `domainObject`'s context menu from the object tree. -* Expands the 'My Items' folder if it is not already expanded. +* Expands the path to the object and scrolls to it if necessary. * * @param {import('@playwright/test').Page} page -* @param {string} myItemsFolderName the name of the "My Items" folder -* @param {string} domainObjectName the display name of the `domainObject` +* @param {string} url the url to the object */ -async function openObjectTreeContextMenu(page, myItemsFolderName, domainObjectName) { - const myItemsFolder = page.locator(`text=Open MCT ${myItemsFolderName} >> span`).nth(3); - const className = await myItemsFolder.getAttribute('class'); - if (!className.includes('c-disclosure-triangle--expanded')) { - await myItemsFolder.click(); - } - - await page.locator(`a:has-text("${domainObjectName}")`).click({ +async function openObjectTreeContextMenu(page, url) { + await page.goto(url); + await page.click('button[title="Show selected item in tree"]'); + await page.locator('.is-navigated-object').click({ button: 'right' }); } @@ -129,7 +124,7 @@ async function openObjectTreeContextMenu(page, myItemsFolderName, domainObjectNa async function getFocusedObjectUuid(page) { const UUIDv4Regexp = /[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi; const focusedObjectUuid = await page.evaluate((regexp) => { - return window.location.href.match(regexp).at(-1); + return window.location.href.split('?')[0].match(regexp).at(-1); }, UUIDv4Regexp); return focusedObjectUuid; diff --git a/e2e/tests/functional/plugins/notebook/restrictedNotebook.e2e.spec.js b/e2e/tests/functional/plugins/notebook/restrictedNotebook.e2e.spec.js index c076329d4..568061c89 100644 --- a/e2e/tests/functional/plugins/notebook/restrictedNotebook.e2e.spec.js +++ b/e2e/tests/functional/plugins/notebook/restrictedNotebook.e2e.spec.js @@ -21,7 +21,7 @@ *****************************************************************************/ const { test, expect } = require('../../../../pluginFixtures'); -const { openObjectTreeContextMenu } = require('../../../../appActions'); +const { openObjectTreeContextMenu, createDomainObjectWithDefaults } = require('../../../../appActions'); const path = require('path'); const TEST_TEXT = 'Testing text for entries.'; @@ -30,8 +30,9 @@ const CUSTOM_NAME = 'CUSTOM_NAME'; const NOTEBOOK_DROP_AREA = '.c-notebook__drag-area'; test.describe('Restricted Notebook', () => { + let notebook; test.beforeEach(async ({ page }) => { - await startAndAddRestrictedNotebookObject(page); + notebook = await startAndAddRestrictedNotebookObject(page); }); test('Can be renamed @addInit', async ({ page }) => { @@ -39,9 +40,7 @@ test.describe('Restricted Notebook', () => { }); test('Can be deleted if there are no locked pages @addInit', async ({ page, openmctConfig }) => { - const { myItemsFolderName } = openmctConfig; - - await openObjectTreeContextMenu(page, myItemsFolderName, `Unnamed ${CUSTOM_NAME}`); + await openObjectTreeContextMenu(page, notebook.url); const menuOptions = page.locator('.c-menu ul'); await expect.soft(menuOptions).toContainText('Remove'); @@ -76,9 +75,9 @@ test.describe('Restricted Notebook', () => { }); test.describe('Restricted Notebook with at least one entry and with the page locked @addInit', () => { - + let notebook; test.beforeEach(async ({ page }) => { - await startAndAddRestrictedNotebookObject(page); + notebook = await startAndAddRestrictedNotebookObject(page); await enterTextEntry(page); await lockPage(page); @@ -86,9 +85,8 @@ test.describe('Restricted Notebook with at least one entry and with the page loc await page.locator('button.c-notebook__toggle-nav-button').click(); }); - test('Locked page should now be in a locked state @addInit @unstable', async ({ page, openmctConfig }, testInfo) => { + test('Locked page should now be in a locked state @addInit @unstable', async ({ page }, testInfo) => { test.fixme(testInfo.project === 'chrome-beta', "Test is unreliable on chrome-beta"); - const { myItemsFolderName } = openmctConfig; // 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); @@ -98,7 +96,7 @@ test.describe('Restricted Notebook with at least one entry and with the page loc expect.soft(await pageLockIcon.count()).toEqual(1); // no way to remove a restricted notebook with a locked page - await openObjectTreeContextMenu(page, myItemsFolderName, `Unnamed ${CUSTOM_NAME}`); + await openObjectTreeContextMenu(page, notebook.url); const menuOptions = page.locator('.c-menu ul'); await expect(menuOptions).not.toContainText('Remove'); @@ -178,13 +176,8 @@ async function startAndAddRestrictedNotebookObject(page) { // eslint-disable-next-line no-undef await page.addInitScript({ path: path.join(__dirname, '../../../../helper/', 'addInitRestrictedNotebook.js') }); await page.goto('./', { waitUntil: 'networkidle' }); - await page.click('button:has-text("Create")'); - 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 createDomainObjectWithDefaults(page, { type: CUSTOM_NAME }); } /** diff --git a/e2e/tests/functional/plugins/timer/timer.e2e.spec.js b/e2e/tests/functional/plugins/timer/timer.e2e.spec.js index 0235a1d2c..16c6b5def 100644 --- a/e2e/tests/functional/plugins/timer/timer.e2e.spec.js +++ b/e2e/tests/functional/plugins/timer/timer.e2e.spec.js @@ -24,9 +24,10 @@ const { test, expect } = require('../../../../pluginFixtures'); const { openObjectTreeContextMenu, createDomainObjectWithDefaults } = require('../../../../appActions'); test.describe('Timer', () => { + let timer; test.beforeEach(async ({ page }) => { await page.goto('./', { waitUntil: 'networkidle' }); - await createDomainObjectWithDefaults(page, { type: 'timer' }); + timer = await createDomainObjectWithDefaults(page, { type: 'timer' }); }); test('Can perform actions on the Timer', async ({ page, openmctConfig }) => { @@ -35,13 +36,13 @@ test.describe('Timer', () => { description: 'https://github.com/nasa/openmct/issues/4313' }); - const { myItemsFolderName } = await openmctConfig; + const timerUrl = timer.url; await test.step("From the tree context menu", async () => { - await triggerTimerContextMenuAction(page, myItemsFolderName, 'Start'); - await triggerTimerContextMenuAction(page, myItemsFolderName, 'Pause'); - await triggerTimerContextMenuAction(page, myItemsFolderName, 'Restart at 0'); - await triggerTimerContextMenuAction(page, myItemsFolderName, 'Stop'); + await triggerTimerContextMenuAction(page, timerUrl, 'Start'); + await triggerTimerContextMenuAction(page, timerUrl, 'Pause'); + await triggerTimerContextMenuAction(page, timerUrl, 'Restart at 0'); + await triggerTimerContextMenuAction(page, timerUrl, 'Stop'); }); await test.step("From the 3dot menu", async () => { @@ -74,9 +75,9 @@ test.describe('Timer', () => { * @param {import('@playwright/test').Page} page * @param {TimerAction} action */ -async function triggerTimerContextMenuAction(page, myItemsFolderName, action) { +async function triggerTimerContextMenuAction(page, timerUrl, action) { const menuAction = `.c-menu ul li >> text="${action}"`; - await openObjectTreeContextMenu(page, myItemsFolderName, "Unnamed Timer"); + await openObjectTreeContextMenu(page, timerUrl); await page.locator(menuAction).click(); assertTimerStateAfterAction(page, action); } diff --git a/e2e/tests/functional/tree.e2e.spec.js b/e2e/tests/functional/tree.e2e.spec.js new file mode 100644 index 000000000..691f7f127 --- /dev/null +++ b/e2e/tests/functional/tree.e2e.spec.js @@ -0,0 +1,138 @@ +/***************************************************************************** + * 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, expect } = require('../../pluginFixtures.js'); +const { + createDomainObjectWithDefaults, + openObjectTreeContextMenu +} = require('../../appActions.js'); + +test.describe('Tree operations', () => { + test('Renaming an object reorders the tree @unstable', async ({ page, openmctConfig }) => { + const { myItemsFolderName } = openmctConfig; + await page.goto('./', { waitUntil: 'networkidle' }); + + await createDomainObjectWithDefaults(page, { + type: 'Folder', + name: 'Foo' + }); + + await createDomainObjectWithDefaults(page, { + type: 'Folder', + name: 'Bar' + }); + + await createDomainObjectWithDefaults(page, { + type: 'Folder', + name: 'Baz' + }); + + const clock1 = await createDomainObjectWithDefaults(page, { + type: 'Clock', + name: 'aaa' + }); + + await createDomainObjectWithDefaults(page, { + type: 'Clock', + name: 'www' + }); + + // Expand the root folder + await expandTreePaneItemByName(page, myItemsFolderName); + + await test.step("Reorders objects with the same tree depth", async () => { + await getAndAssertTreeItems(page, ['aaa', 'Bar', 'Baz', 'Foo', 'www']); + await renameObjectFromContextMenu(page, clock1.url, 'zzz'); + await getAndAssertTreeItems(page, ['Bar', 'Baz', 'Foo', 'www', 'zzz']); + }); + + await test.step("Reorders links to objects as well as original objects", async () => { + await page.click('role=treeitem[name=/Bar/]'); + await page.dragAndDrop('role=treeitem[name=/www/]', '.c-object-view'); + await page.dragAndDrop('role=treeitem[name=/zzz/]', '.c-object-view'); + await page.click('role=treeitem[name=/Baz/]'); + await page.dragAndDrop('role=treeitem[name=/www/]', '.c-object-view'); + await page.dragAndDrop('role=treeitem[name=/zzz/]', '.c-object-view'); + await page.click('role=treeitem[name=/Foo/]'); + await page.dragAndDrop('role=treeitem[name=/www/]', '.c-object-view'); + await page.dragAndDrop('role=treeitem[name=/zzz/]', '.c-object-view'); + // Expand the unopened folders + await expandTreePaneItemByName(page, 'Bar'); + await expandTreePaneItemByName(page, 'Baz'); + await expandTreePaneItemByName(page, 'Foo'); + + await renameObjectFromContextMenu(page, clock1.url, '___'); + await getAndAssertTreeItems(page, + [ + "___", + "Bar", + "___", + "www", + "Baz", + "___", + "www", + "Foo", + "___", + "www", + "www" + ]); + }); + }); +}); + +/** + * @param {import('@playwright/test').Page} page + * @param {Array<string>} expected + */ +async function getAndAssertTreeItems(page, expected) { + const treeItems = page.locator('[role="treeitem"]'); + const allTexts = await treeItems.allInnerTexts(); + // Get rid of root folder ('My Items') as its position will not change + allTexts.shift(); + expect(allTexts).toEqual(expected); +} + +/** + * @param {import('@playwright/test').Page} page + * @param {string} name + */ +async function expandTreePaneItemByName(page, name) { + const treePane = page.locator('#tree-pane'); + const treeItem = treePane.locator(`role=treeitem[expanded=false][name=/${name}/]`); + const expandTriangle = treeItem.locator('.c-disclosure-triangle'); + await expandTriangle.click(); +} + +/** + * @param {import('@playwright/test').Page} page + * @param {string} myItemsFolderName + * @param {string} url + * @param {string} newName + */ +async function renameObjectFromContextMenu(page, url, newName) { + await openObjectTreeContextMenu(page, url); + await page.click('li:text("Edit Properties")'); + const nameInput = page.locator('form[name="mctForm"] .first input[type="text"]'); + await nameInput.fill(""); + await nameInput.fill(newName); + await page.click('[aria-label="Save"]'); +} diff --git a/e2e/tests/visual/components/tree.visual.spec.js b/e2e/tests/visual/components/tree.visual.spec.js new file mode 100644 index 000000000..0ad2aca75 --- /dev/null +++ b/e2e/tests/visual/components/tree.visual.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('../../../pluginFixtures.js'); +const { createDomainObjectWithDefaults } = require('../../../appActions.js'); + +const percySnapshot = require('@percy/playwright'); + +test.describe('Visual - Tree Pane', () => { + test('Tree pane in various states @unstable', async ({ page, theme, openmctConfig }) => { + const { myItemsFolderName } = openmctConfig; + await page.goto('./#/browse/mine', { waitUntil: 'networkidle' }); + + const foo = await createDomainObjectWithDefaults(page, { + type: 'Folder', + name: "Foo Folder" + }); + + const bar = await createDomainObjectWithDefaults(page, { + type: 'Folder', + name: "Bar Folder", + parent: foo.uuid + }); + + const baz = await createDomainObjectWithDefaults(page, { + type: 'Folder', + name: "Baz Folder", + parent: bar.uuid + }); + + await createDomainObjectWithDefaults(page, { + type: 'Clock', + name: 'A Clock' + }); + + await createDomainObjectWithDefaults(page, { + type: 'Clock', + name: 'Z Clock' + }); + + const treePane = "#tree-pane"; + + await percySnapshot(page, `Tree Pane w/ collapsed tree (theme: ${theme})`, { + scope: treePane + }); + + await expandTreePaneItemByName(page, myItemsFolderName); + + await page.goto(foo.url); + await page.dragAndDrop('role=treeitem[name=/A Clock/]', '.c-object-view'); + await page.dragAndDrop('role=treeitem[name=/Z Clock/]', '.c-object-view'); + await page.goto(bar.url); + await page.dragAndDrop('role=treeitem[name=/A Clock/]', '.c-object-view'); + await page.dragAndDrop('role=treeitem[name=/Z Clock/]', '.c-object-view'); + await page.goto(baz.url); + await page.dragAndDrop('role=treeitem[name=/A Clock/]', '.c-object-view'); + await page.dragAndDrop('role=treeitem[name=/Z Clock/]', '.c-object-view'); + + await percySnapshot(page, `Tree Pane w/ single level expanded (theme: ${theme})`, { + scope: treePane + }); + + await expandTreePaneItemByName(page, foo.name); + await expandTreePaneItemByName(page, bar.name); + await expandTreePaneItemByName(page, baz.name); + + await percySnapshot(page, `Tree Pane w/ multiple levels expanded (theme: ${theme})`, { + scope: treePane + }); + }); +}); + +/** + * @param {import('@playwright/test').Page} page + * @param {string} name + */ +async function expandTreePaneItemByName(page, name) { + const treePane = page.locator('#tree-pane'); + const treeItem = treePane.locator(`role=treeitem[expanded=false][name=/${name}/]`); + const expandTriangle = treeItem.locator('.c-disclosure-triangle'); + await expandTriangle.click(); +} diff --git a/package.json b/package.json index d1c9ceebb..9148462bd 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "devDependencies": { "@babel/eslint-parser": "7.18.9", "@braintree/sanitize-url": "6.0.0", - "@percy/cli": "1.7.2", + "@percy/cli": "1.8.1", "@percy/playwright": "1.0.4", "@playwright/test": "1.23.0", "@types/eventemitter3": "^1.0.0", diff --git a/src/ui/layout/Layout.vue b/src/ui/layout/Layout.vue index ee5296436..87db0617c 100644 --- a/src/ui/layout/Layout.vue +++ b/src/ui/layout/Layout.vue @@ -53,6 +53,7 @@ type="horizontal" > <pane + id="tree-pane" class="l-shell__pane-tree" handle="after" label="Browse" diff --git a/src/ui/layout/mct-tree.vue b/src/ui/layout/mct-tree.vue index 220f82b42..9bf1b87a4 100644 --- a/src/ui/layout/mct-tree.vue +++ b/src/ui/layout/mct-tree.vue @@ -41,6 +41,8 @@ <div ref="mainTree" class="c-tree-and-search__tree c-tree" + role="tree" + aria-expanded="true" > <div> @@ -467,7 +469,7 @@ export default { } }, scrollEndEvent() { - if (!this.$refs.srcrollable) { + if (!this.$refs.scrollable) { return; } @@ -576,14 +578,17 @@ export default { }; }, addTreeItemObserver(domainObject, parentObjectPath) { - if (this.observers[domainObject.identifier.key]) { - this.observers[domainObject.identifier.key](); + const objectPath = [domainObject].concat(parentObjectPath); + const navigationPath = this.buildNavigationPath(objectPath); + + if (this.observers[navigationPath]) { + this.observers[navigationPath](); } - this.observers[domainObject.identifier.key] = this.openmct.objects.observe( + this.observers[navigationPath] = this.openmct.objects.observe( domainObject, 'name', - this.updateTreeItems.bind(this, parentObjectPath) + this.sortTreeItems.bind(this, parentObjectPath) ); }, async updateTreeItems(parentObjectPath) { @@ -610,6 +615,44 @@ export default { } } }, + sortTreeItems(parentObjectPath) { + const navigationPath = this.buildNavigationPath(parentObjectPath); + const parentItem = this.getTreeItemByPath(navigationPath); + + // If the parent is not sortable, skip sorting + if (!this.isSortable(parentObjectPath)) { + return; + } + + // Sort the renamed object and its siblings (direct descendants of the parent) + const directDescendants = this.getChildrenInTreeFor(parentItem, false); + directDescendants.sort(this.sortNameAscending); + + // Take a copy of the sorted descendants array + const sortedTreeItems = directDescendants.slice(); + + directDescendants.forEach(descendant => { + const parent = this.getTreeItemByPath(descendant.navigationPath); + + // If descendant is not open, skip + if (!this.isTreeItemOpen(parent)) { + return; + } + + // If descendant is open but has no children, skip + const children = this.getChildrenInTreeFor(parent, true); + if (children.length === 0) { + return; + } + + // Splice in the children of the descendant + const parentIndex = sortedTreeItems.map(item => item.navigationPath).indexOf(parent.navigationPath); + sortedTreeItems.splice(parentIndex + 1, 0, ...children); + }); + + // Splice in all of the sorted descendants + this.treeItems.splice(this.treeItems.indexOf(parentItem) + 1, sortedTreeItems.length, ...sortedTreeItems); + }, buildNavigationPath(objectPath) { return '/browse/' + [...objectPath].reverse() .map((object) => this.openmct.objects.makeKeyString(object.identifier)) diff --git a/src/ui/layout/tree-item.vue b/src/ui/layout/tree-item.vue index 7283e3bd3..5c0712f95 100644 --- a/src/ui/layout/tree-item.vue +++ b/src/ui/layout/tree-item.vue @@ -1,7 +1,9 @@ <template> <div - :style="treeItemStyles" class="c-tree__item-h" + role="treeitem" + :style="treeItemStyles" + :aria-expanded="(!activeSearch && hasComposition) ? (isOpen || isLoading) ? 'true' : 'false' : undefined" > <div class="c-tree__item" |