From 4801dc4f327d446eebee2b8e134e3d0bfc9108d3 Mon Sep 17 00:00:00 2001 From: Jamie V <jamie.j.vigliotta@nasa.gov> Date: Mon, 24 Aug 2020 13:47:56 -0700 Subject: [PATCH] New tree refactor (#3098) * revised new tree refactor, moved most of the logic to mct-tree instead of tree-item * scrollTo set for sync, bug fixes, window resize handling * removing console logs * checking domainobject composition for length to verify children instead of composition object itself * added scrollTo on load if in viewed objects directory * loading, sync bug, search issues, opitmization * initial PR review updates * modified so search now uses the same container and virtual scroll * eslint fix * Adding new glyphs - Multiple new glyphs cherrypicked from branch `add-new-glyphs-062320`; * Styling for new-tree-refactor WIP - WIP! - New glyphs, markup changes in BrowseBar.vue; - Refinements to tree items, WIP; - TODO: move hard-coded CSS values into _constants, make theme-compatible; * Styling for new-tree-refactor WIP - WIP! - Added new `c-click-link` CSS class; - Move tree sync button into tree pane area; - Added named "controls" slot to pane.vue; - _up and _down arrows now use visibility instead of opacity to prevent accidental clicks; * Styling for new-tree-refactor WIP - WIP! - Significant mods and simplification in pane.vue and assoc CSS for expand/collapse functionality; - Wait spinner when in tree: cleanups, simplification; * More new glyphs, updated art - New glyphs: icon-unlocked and icon-target; - Updated art for icon-lock glyph; * remove arrows for search results, hightlight "my items" correctly, added empty folder notic * Styling for new-tree-refactor WIP - WIP! - Refinements to "empty" object element; - Changed sync-tree icon glyph; * Styling for new-tree-refactor WIP - Nav up arrows now left-align properly; * Styling for new-tree-refactor - Significant consolidation and cleanups in mct-tree.scss; - Normalize base and hover styles across new tree, legacy tree, list-items (used in Notebook) and Folder List View; - Class naming normalization, change `c-list-item__name-value` to `c-list-item__name`; - Add styling to override and remove `<a> outline: dotted` coming from normalize-min; - Removed too-broad `<a>` coloring in tables; * Styling for new-tree-refactor - Fix styles for Snow theme; - Sync Maelstrom and Espresso themes; - Remove too-broad `<a>` hover styling from global.scss; - Disallow pointer-events on `is-navigated` object's label (click on c-nav__down element still allowed); * Styling for new-tree-refactor - Normalizing status area expand/collapse iconography to match new approach in panes; * Adding new glyphs - Added `icon-items-collapse` and `icon-items-expand`; * Styling for new-tree-refactor - Using new glyphs for items expand/collapse in Status area; * dynamic item height for desktop and mobile views * lint fixes * updated addChild and removeChild functions as they were not working at all * some PR comment updates!; * Remove unneeded hard-coded CSS color property * fixed issues when multiple root children exist, added plugin to change the name of the default root object * removing "my other items" testing references * linting fixes * updating karma timeouts for testing purposes * eslint fixes * WIP: fixing linting issues * updating for testing * set root object provider to update root registry if called more than once * tweaking tests so that it passes both locally and on the serve tests * removing old css code preventing context clicks on active menu items * fixing testing errors * backwards compatible storage fix Co-authored-by: charlesh88 <charlesh88@gmail.com> Co-authored-by: Deep Tailor <deep.j.tailor@nasa.gov> --- karma.conf.js | 2 +- src/api/objects/ObjectAPI.js | 2 +- src/api/objects/RootObjectProvider.js | 56 +- .../objects/test/RootObjectProviderSpec.js | 51 +- src/plugins/defaultRootName/plugin.js | 29 + src/plugins/defaultRootName/pluginSpec.js | 99 +++ .../folderView/components/list-view.scss | 2 +- src/plugins/plugins.js | 7 +- src/styles/_animations.scss | 4 +- src/styles/_constants-espresso.scss | 11 +- src/styles/_constants-maelstrom.scss | 13 +- src/styles/_constants-snow.scss | 9 +- src/styles/_constants.scss | 3 +- src/styles/_controls.scss | 12 +- src/styles/_global.scss | 17 +- src/ui/components/viewControl.vue | 14 +- src/ui/layout/BrowseBar.vue | 3 +- src/ui/layout/Layout.vue | 23 +- src/ui/layout/layout.scss | 57 +- src/ui/layout/mct-tree.scss | 178 ++++- src/ui/layout/mct-tree.vue | 639 ++++++++++++++++-- src/ui/layout/pane.scss | 78 +-- src/ui/layout/pane.vue | 9 +- src/ui/layout/tree-item.vue | 185 ++--- 24 files changed, 1141 insertions(+), 362 deletions(-) create mode 100644 src/plugins/defaultRootName/plugin.js create mode 100644 src/plugins/defaultRootName/pluginSpec.js diff --git a/karma.conf.js b/karma.conf.js index 8875b78284..daf2a9d873 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -100,6 +100,6 @@ module.exports = (config) => { }, concurrency: 1, singleRun: true, - browserNoActivityTimeout: 90000 + browserNoActivityTimeout: 400000 }); }; diff --git a/src/api/objects/ObjectAPI.js b/src/api/objects/ObjectAPI.js index ab85477ee5..de753dac9c 100644 --- a/src/api/objects/ObjectAPI.js +++ b/src/api/objects/ObjectAPI.js @@ -46,7 +46,7 @@ define([ this.eventEmitter = new EventEmitter(); this.providers = {}; this.rootRegistry = new RootRegistry(); - this.rootProvider = new RootObjectProvider(this.rootRegistry); + this.rootProvider = new RootObjectProvider.default(this.rootRegistry); } /** diff --git a/src/api/objects/RootObjectProvider.js b/src/api/objects/RootObjectProvider.js index a15b4c41f7..00c43a215b 100644 --- a/src/api/objects/RootObjectProvider.js +++ b/src/api/objects/RootObjectProvider.js @@ -20,28 +20,42 @@ * at runtime from the About dialog for additional information. *****************************************************************************/ -define([ -], function ( -) { +class RootObjectProvider { + constructor(rootRegistry) { + if (!RootObjectProvider.instance) { + this.rootRegistry = rootRegistry; + this.rootObject = { + identifier: { + key: "ROOT", + namespace: "" + }, + name: 'The root object', + type: 'root', + composition: [] + }; + RootObjectProvider.instance = this; + } else { + // if called twice, update instance rootRegistry + RootObjectProvider.instance.rootRegistry = rootRegistry; + } - function RootObjectProvider(rootRegistry) { - this.rootRegistry = rootRegistry; + return RootObjectProvider.instance; // eslint-disable-line no-constructor-return } - RootObjectProvider.prototype.get = function () { - return this.rootRegistry.getRoots() - .then(function (roots) { - return { - identifier: { - key: "ROOT", - namespace: "" - }, - name: 'The root object', - type: 'root', - composition: roots - }; - }); - }; + updateName(name) { + this.rootObject.name = name; + } - return RootObjectProvider; -}); + async get() { + let roots = await this.rootRegistry.getRoots(); + this.rootObject.composition = roots; + + return this.rootObject; + } +} + +function instance(rootRegistry) { + return new RootObjectProvider(rootRegistry); +} + +export default instance; diff --git a/src/api/objects/test/RootObjectProviderSpec.js b/src/api/objects/test/RootObjectProviderSpec.js index 5cc83a4c2f..9536f845d5 100644 --- a/src/api/objects/test/RootObjectProviderSpec.js +++ b/src/api/objects/test/RootObjectProviderSpec.js @@ -19,34 +19,33 @@ * this source code distribution or the Licensing information page available * at runtime from the About dialog for additional information. *****************************************************************************/ -define([ - '../RootObjectProvider' -], function ( - RootObjectProvider -) { - describe('RootObjectProvider', function () { - let rootRegistry; - let rootObjectProvider; +import RootObjectProvider from '../RootObjectProvider'; - beforeEach(function () { - rootRegistry = jasmine.createSpyObj('rootRegistry', ['getRoots']); - rootRegistry.getRoots.and.returnValue(Promise.resolve(['some root'])); - rootObjectProvider = new RootObjectProvider(rootRegistry); - }); +describe('RootObjectProvider', function () { + // let rootRegistry; + let rootObjectProvider; + let roots = ['some root']; + let rootRegistry = { + getRoots: () => { + return Promise.resolve(roots); + } + }; - it('supports fetching root', function () { - return rootObjectProvider.get() - .then(function (root) { - expect(root).toEqual({ - identifier: { - key: "ROOT", - namespace: "" - }, - name: 'The root object', - type: 'root', - composition: ['some root'] - }); - }); + beforeEach(function () { + rootObjectProvider = new RootObjectProvider(rootRegistry); + }); + + it('supports fetching root', async () => { + let root = await rootObjectProvider.get(); + + expect(root).toEqual({ + identifier: { + key: "ROOT", + namespace: "" + }, + name: 'The root object', + type: 'root', + composition: ['some root'] }); }); }); diff --git a/src/plugins/defaultRootName/plugin.js b/src/plugins/defaultRootName/plugin.js new file mode 100644 index 0000000000..a8edf2eeb4 --- /dev/null +++ b/src/plugins/defaultRootName/plugin.js @@ -0,0 +1,29 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2018, 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 RootObjectProvider from '../../api/objects/RootObjectProvider.js'; + +export default function (name) { + return function (openmct) { + let rootObjectProvider = new RootObjectProvider(); + rootObjectProvider.updateName(name); + }; +} diff --git a/src/plugins/defaultRootName/pluginSpec.js b/src/plugins/defaultRootName/pluginSpec.js new file mode 100644 index 0000000000..00c88a81b3 --- /dev/null +++ b/src/plugins/defaultRootName/pluginSpec.js @@ -0,0 +1,99 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2020, 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'; + +xdescribe("the plugin", () => { + let openmct; + let compositionAPI; + let newFolderAction; + let mockObjectPath; + let mockDialogService; + let mockComposition; + let mockPromise; + let newFolderName = 'New Folder'; + + beforeEach((done) => { + openmct = createOpenMct(); + + openmct.on('start', done); + openmct.startHeadless(); + + newFolderAction = openmct.contextMenu._allActions.filter(action => { + return action.key === 'newFolder'; + })[0]; + }); + + afterEach(() => { + resetApplicationState(openmct); + }); + + it('installs the new folder action', () => { + expect(newFolderAction).toBeDefined(); + }); + + describe('when invoked', () => { + + beforeEach((done) => { + compositionAPI = openmct.composition; + mockObjectPath = [{ + name: 'mock folder', + type: 'folder', + identifier: { + key: 'mock-folder', + namespace: '' + } + }]; + mockPromise = { + then: (callback) => { + callback({name: newFolderName}); + done(); + } + }; + + mockDialogService = jasmine.createSpyObj('dialogService', ['getUserInput']); + mockComposition = jasmine.createSpyObj('composition', ['add']); + + mockDialogService.getUserInput.and.returnValue(mockPromise); + + spyOn(openmct.$injector, 'get').and.returnValue(mockDialogService); + spyOn(compositionAPI, 'get').and.returnValue(mockComposition); + spyOn(openmct.objects, 'mutate'); + + newFolderAction.invoke(mockObjectPath); + }); + + it('gets user input for folder name', () => { + expect(mockDialogService.getUserInput).toHaveBeenCalled(); + }); + + it('creates a new folder object', () => { + expect(openmct.objects.mutate).toHaveBeenCalled(); + }); + + it('adds new folder object to parent composition', () => { + expect(mockComposition.add).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/plugins/folderView/components/list-view.scss b/src/plugins/folderView/components/list-view.scss index bb4ba0ace8..21aced062d 100644 --- a/src/plugins/folderView/components/list-view.scss +++ b/src/plugins/folderView/components/list-view.scss @@ -13,7 +13,7 @@ cursor: pointer; &:hover { - background: $colorItemTreeHoverBg; + background: $colorListItemBgHov; filter: $filterHov; transition: $transIn; } diff --git a/src/plugins/plugins.js b/src/plugins/plugins.js index 8dc1e82777..d03612e2c4 100644 --- a/src/plugins/plugins.js +++ b/src/plugins/plugins.js @@ -54,7 +54,8 @@ define([ './themes/snow', './URLTimeSettingsSynchronizer/plugin', './notificationIndicator/plugin', - './newFolderAction/plugin' + './newFolderAction/plugin', + './defaultRootName/plugin' ], function ( _, UTCTimeSystem, @@ -89,7 +90,8 @@ define([ Snow, URLTimeSettingsSynchronizer, NotificationIndicator, - NewFolderAction + NewFolderAction, + DefaultRootName ) { const bundleMap = { LocalStorage: 'platform/persistence/local', @@ -201,6 +203,7 @@ define([ plugins.URLTimeSettingsSynchronizer = URLTimeSettingsSynchronizer.default; plugins.NotificationIndicator = NotificationIndicator.default; plugins.NewFolderAction = NewFolderAction.default; + plugins.DefaultRootName = DefaultRootName.default; return plugins; }); diff --git a/src/styles/_animations.scss b/src/styles/_animations.scss index 1d06cf7f9e..f95e73560c 100644 --- a/src/styles/_animations.scss +++ b/src/styles/_animations.scss @@ -3,8 +3,8 @@ } @keyframes rotation-centered { - 0% { transform: translate(-50%, -50%) rotate(0deg); } - 100% { transform: translate(-50%, -50%) rotate(360deg); } + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } } @keyframes clock-hands { diff --git a/src/styles/_constants-espresso.scss b/src/styles/_constants-espresso.scss index 4098bd20a0..96b41a62f8 100644 --- a/src/styles/_constants-espresso.scss +++ b/src/styles/_constants-espresso.scss @@ -80,8 +80,8 @@ $uiColor: #0093ff; // Resize bars, splitter bars, etc. $colorInteriorBorder: rgba($colorBodyFg, 0.2); $colorA: #ccc; $colorAHov: #fff; -$filterHov: brightness(1.3); // Tree, location items -$colorSelectedBg: pushBack($colorKey, 10%); +$filterHov: brightness(1.3) contrast(1.5); // Tree, location items +$colorSelectedBg: rgba($colorKey, 0.3); $colorSelectedFg: pullForward($colorBodyFg, 20%); // Object labels @@ -361,13 +361,14 @@ $legendTableHeadBg: $colorTabHeaderBg; // Tree $colorTreeBg: transparent; -$colorItemTreeHoverBg: rgba(white, 0.07); -$colorItemTreeHoverFg: pullForward($colorBodyFg, 20%); +$colorItemTreeHoverBg: rgba(#fff, 0.03); +$colorItemTreeHoverFg: #fff; $colorItemTreeIcon: $colorKey; // Used $colorItemTreeIconHover: $colorItemTreeIcon; // Used $colorItemTreeFg: $colorBodyFg; $colorItemTreeSelectedBg: $colorSelectedBg; $colorItemTreeSelectedFg: $colorItemTreeHoverFg; +$filterItemTreeSelected: $filterHov; $colorItemTreeSelectedIcon: $colorItemTreeSelectedFg; $colorItemTreeEditingBg: pushBack($editUIColor, 20%); $colorItemTreeEditingFg: $editUIColor; @@ -402,7 +403,7 @@ $splitterBtnColorBg: $colorBtnBg; $splitterBtnColorFg: #999; $splitterBtnLabelColorFg: #666; $splitterCollapsedBtnColorBg: #222; -$splitterCollapsedBtnColorFg: #666; +$splitterCollapsedBtnColorFg: #555; $splitterCollapsedBtnColorBgHov: $colorKey; $splitterCollapsedBtnColorFgHov: $colorKeyFg; diff --git a/src/styles/_constants-maelstrom.scss b/src/styles/_constants-maelstrom.scss index 9c6593acc3..e4d1992612 100644 --- a/src/styles/_constants-maelstrom.scss +++ b/src/styles/_constants-maelstrom.scss @@ -80,12 +80,12 @@ $colorKeyHov: #26d8ff; $colorKeyFilter: invert(36%) sepia(76%) saturate(2514%) hue-rotate(170deg) brightness(99%) contrast(101%); $colorKeyFilterHov: invert(63%) sepia(88%) saturate(3029%) hue-rotate(154deg) brightness(101%) contrast(100%); $colorKeySelectedBg: $colorKey; -$uiColor: #00b2ff; // Resize bars, splitter bars, etc. +$uiColor: #0093ff; // Resize bars, splitter bars, etc. $colorInteriorBorder: rgba($colorBodyFg, 0.2); $colorA: #ccc; $colorAHov: #fff; -$filterHov: brightness(1.3); // Tree, location items -$colorSelectedBg: pushBack($colorKey, 10%); +$filterHov: brightness(1.3) contrast(1.5); // Tree, location items +$colorSelectedBg: rgba($colorKey, 0.3); $colorSelectedFg: pullForward($colorBodyFg, 20%); // Object labels @@ -365,13 +365,14 @@ $legendTableHeadBg: rgba($colorBodyFg, 0.15); // Tree $colorTreeBg: transparent; -$colorItemTreeHoverBg: rgba(white, 0.07); -$colorItemTreeHoverFg: pullForward($colorBodyFg, 20%); +$colorItemTreeHoverBg: rgba(#fff, 0.03); +$colorItemTreeHoverFg: #fff; $colorItemTreeIcon: $colorKey; // Used $colorItemTreeIconHover: $colorItemTreeIcon; // Used $colorItemTreeFg: $colorBodyFg; $colorItemTreeSelectedBg: $colorSelectedBg; $colorItemTreeSelectedFg: $colorItemTreeHoverFg; +$filterItemTreeSelected: $filterHov; $colorItemTreeSelectedIcon: $colorItemTreeSelectedFg; $colorItemTreeEditingBg: pushBack($editUIColor, 20%); $colorItemTreeEditingFg: $editUIColor; @@ -406,7 +407,7 @@ $splitterBtnColorBg: $colorBtnBg; $splitterBtnColorFg: #999; $splitterBtnLabelColorFg: #666; $splitterCollapsedBtnColorBg: #222; -$splitterCollapsedBtnColorFg: #666; +$splitterCollapsedBtnColorFg: #555; $splitterCollapsedBtnColorBgHov: $colorKey; $splitterCollapsedBtnColorFgHov: $colorKeyFg; diff --git a/src/styles/_constants-snow.scss b/src/styles/_constants-snow.scss index d3d1e0908e..aa6541064f 100644 --- a/src/styles/_constants-snow.scss +++ b/src/styles/_constants-snow.scss @@ -78,10 +78,10 @@ $colorKeyFilterHov: invert(69%) sepia(87%) saturate(3243%) hue-rotate(151deg) br $colorKeySelectedBg: $colorKey; $uiColor: #289fec; // Resize bars, splitter bars, etc. $colorInteriorBorder: rgba($colorBodyFg, 0.2); -$colorA: #999; +$colorA: $colorBodyFg; $colorAHov: $colorKey; $filterHov: brightness(0.8) contrast(2); // Tree, location items -$colorSelectedBg: pushBack($colorKey, 40%); +$colorSelectedBg: rgba($colorKey, 0.2); $colorSelectedFg: pullForward($colorBodyFg, 10%); // Object labels @@ -94,7 +94,7 @@ $shellPanePad: $interiorMargin, 7px; $drawerBg: darken($colorBodyBg, 5%); $drawerFg: darken($colorBodyFg, 5%); $sideBarBg: $drawerBg; -$sideBarHeaderBg: rgba(black, 0.25); +$sideBarHeaderBg: rgba(black, 0.1); $sideBarHeaderFg: rgba($colorBodyFg, 0.7); // Status colors, mainly used for messaging and item ancillary symbols @@ -368,7 +368,8 @@ $colorItemTreeIconHover: $colorItemTreeIcon; // Used $colorItemTreeFg: $colorBodyFg; $colorItemTreeSelectedBg: $colorSelectedBg; $colorItemTreeSelectedFg: $colorItemTreeHoverFg; -$colorItemTreeSelectedIcon: $colorItemTreeSelectedFg; +$filterItemTreeSelected: contrast(1.4); +$colorItemTreeSelectedIcon: $colorItemTreeIcon; $colorItemTreeEditingBg: pushBack($editUIColor, 20%); $colorItemTreeEditingFg: $editUIColor; $colorItemTreeEditingIcon: $editUIColor; diff --git a/src/styles/_constants.scss b/src/styles/_constants.scss index ce5954741b..5a5c20a4a9 100755 --- a/src/styles/_constants.scss +++ b/src/styles/_constants.scss @@ -44,6 +44,7 @@ $overlayOuterMarginFullscreen: 0%; $overlayOuterMarginDialog: 20%; $overlayInnerMargin: 25px; $mainViewPad: 0px; +$treeNavArrowD: 20px; /*************** Items */ $itemPadLR: 5px; $gridItemDesk: 175px; @@ -81,8 +82,8 @@ $formLabelMinW: 120px; $formLabelW: 30%; /*************** Wait Spinner */ $waitSpinnerD: 32px; -$waitSpinnerTreeD: 20px; $waitSpinnerBorderW: 5px; +$waitSpinnerTreeD: 20px; $waitSpinnerTreeBorderW: 3px; /*************** Messages */ $messageIconD: 80px; diff --git a/src/styles/_controls.scss b/src/styles/_controls.scss index bb47dcb6fb..dccff7b0dc 100644 --- a/src/styles/_controls.scss +++ b/src/styles/_controls.scss @@ -97,7 +97,8 @@ button { } } -.c-click-link { +.c-click-link, +.c-icon-link { // A clickable element, typically inline, with an icon and label @include cControl(); cursor: pointer; @@ -112,8 +113,15 @@ button { } } +.c-icon-link { + &:before { + // Icon + //color: $colorBtnMajorBg; + } +} + .c-icon-button { - .c-icon-button__label { + &__label { margin-left: $interiorMargin; } diff --git a/src/styles/_global.scss b/src/styles/_global.scss index 72fbb53d53..b786bbfcac 100644 --- a/src/styles/_global.scss +++ b/src/styles/_global.scss @@ -101,8 +101,9 @@ a { color: $colorA; cursor: pointer; text-decoration: none; - &:hover { - color: $colorAHov; + + &:focus { + outline: none !important; } } @@ -280,19 +281,23 @@ body.desktop .has-local-controls { display: flex; align-items: center; - padding-left: $spinnerL + $d/2 + $interiorMargin; - background: $colorLoadingBg; + margin-left: $treeNavArrowD + $interiorMargin; min-height: 5px + $d; .c-tree__item__label { font-style: italic; + margin-left: $interiorMargin; opacity: 0.6; } &:before { + left: auto; + top: auto; + transform: translate(0); height: $d; width: $d; - border-width: 4px; - left: $spinnerL; + border-width: 3px; + //left: $spinnerL; + position: relative; } &:after { display: none; diff --git a/src/ui/components/viewControl.vue b/src/ui/components/viewControl.vue index c32693a809..2a54a0bb5b 100644 --- a/src/ui/components/viewControl.vue +++ b/src/ui/components/viewControl.vue @@ -1,10 +1,10 @@ <template> <span - class="c-disclosure-triangle" - :class="{ - 'c-disclosure-triangle--expanded' : value, - 'is-enabled' : enabled - }" + :class="[ + controlClass, + { 'c-disclosure-triangle--expanded' : value }, + {'is-enabled' : enabled } + ]" @click="handleClick" ></span> </template> @@ -25,6 +25,10 @@ export default { propagate: { type: Boolean, default: true + }, + controlClass: { + type: String, + default: 'c-disclosure-triangle' } }, methods: { diff --git a/src/ui/layout/BrowseBar.vue b/src/ui/layout/BrowseBar.vue index 87912e1b0b..8ef379ad75 100644 --- a/src/ui/layout/BrowseBar.vue +++ b/src/ui/layout/BrowseBar.vue @@ -3,7 +3,8 @@ <div class="l-browse-bar__start"> <button v-if="hasParent" - class="l-browse-bar__nav-to-parent-button c-icon-button c-icon-button--major icon-pointer-left" + class="l-browse-bar__nav-to-parent-button c-icon-button c-icon-button--major icon-arrow-nav-to-parent" + title="Navigate up to parent" @click="goToParent" ></button> <div diff --git a/src/ui/layout/Layout.vue b/src/ui/layout/Layout.vue index 3884570cd7..75e4b4c96f 100644 --- a/src/ui/layout/Layout.vue +++ b/src/ui/layout/Layout.vue @@ -15,7 +15,9 @@ <CreateButton class="l-shell__create-button" /> <indicators class="l-shell__head-section l-shell__indicators" /> <button - class="l-shell__head__collapse-button c-button" + class="l-shell__head__collapse-button c-icon-button" + :class="headExpanded ? 'l-shell__head__collapse-button--collapse' : 'l-shell__head__collapse-button--expand'" + :title="`Click to ${headExpanded ? 'collapse' : 'expand'} items`" @click="toggleShellHead" ></button> <notification-banner /> @@ -47,12 +49,23 @@ label="Browse" collapsable > - <mct-tree class="l-shell__tree" /> + <button + slot="controls" + class="c-icon-button l-shell__sync-tree-button icon-target" + title="Show selected item in tree" + @click="handleSyncTreeNavigation" + > + </button> + <mct-tree + :sync-tree-navigation="triggerSync" + class="l-shell__tree" + /> </pane> <pane class="l-shell__pane-main"> <browse-bar ref="browseBar" class="l-shell__main-view-browse-bar" + @sync-tree-navigation="handleSyncTreeNavigation" /> <toolbar v-if="toolbar" @@ -126,7 +139,8 @@ export default { conductorComponent: undefined, isEditing: false, hasToolbar: false, - headExpanded + headExpanded, + triggerSync: false }; }, computed: { @@ -200,6 +214,9 @@ export default { } this.hasToolbar = structure.length > 0; + }, + handleSyncTreeNavigation() { + this.triggerSync = !this.triggerSync; } } }; diff --git a/src/ui/layout/layout.scss b/src/ui/layout/layout.scss index 4d7944838e..527de3d6c0 100644 --- a/src/ui/layout/layout.scss +++ b/src/ui/layout/layout.scss @@ -52,7 +52,7 @@ color: $colorKey !important; position: absolute; right: -18px; - top: 0; + top: $interiorMarginSm; transform: translateX(100%); width: $mobileMenuIconD; z-index: 2; @@ -100,6 +100,11 @@ &__pane-tree { background: linear-gradient(90deg, transparent 70%, rgba(black, 0.2) 99%, rgba(black, 0.3)); + [class*="expand-button"], + [class*="sync-tree-button"] { + display: none; + } + &[class*="--collapsed"] { [class*="collapse-button"] { right: -8px; @@ -153,7 +158,7 @@ } &__head { - align-items: stretch; + align-items: center; background: $colorHeadBg; justify-content: space-between; padding: $interiorMargin $interiorMargin + 2; @@ -162,14 +167,21 @@ margin-left: $interiorMargin; } - [class*='__head__collapse-button'] { - align-self: start; + .l-shell__head__collapse-button { + color: $colorBtnMajorBg; flex: 0 0 auto; - margin-top: 6px; + font-size: 0.9em; - &:before { - content: $glyph-icon-arrow-down; - font-size: 1.1em; + &--collapse { + &:before { + content: $glyph-icon-items-collapse; + } + } + + &--expand { + &:before { + content: $glyph-icon-items-expand; + } } } @@ -184,12 +196,6 @@ .c-indicator__label { transition: none !important; } - - [class*='__head__collapse-button'] { - &:before { - transform: rotate(180deg); - } - } } } @@ -304,6 +310,10 @@ display: inline-flex; } + > * + * { + margin-left: $interiorMarginSm; + } + &__start, &__end, &__actions { @@ -327,8 +337,12 @@ &__start { flex: 1 1 auto; - margin-right: $interiorMargin; + //margin-right: $interiorMargin; min-width: 0; // Forces interior to compress when pushed on + + [class*='button'] { + flex: 0 0 auto; + } } &__end { @@ -337,15 +351,15 @@ &__nav-to-parent-button, &__disclosure-button { - flex: 0 0 auto; + //flex: 0 0 auto; } &__nav-to-parent-button { // This is an icon-button - $p: $interiorMargin; - margin-right: $interiorMargin; - padding-left: $p; - padding-right: $p; + //$p: $interiorMargin; + //margin-right: $interiorMargin; + //padding-left: $p; + //padding-right: $p; .is-editing & { display: none; @@ -362,7 +376,8 @@ } &__object-name--w { - @include headerFont(1.4em); + @include headerFont(1.5em); + margin-left: $interiorMarginLg; min-width: 0; .is-missing__indicator { diff --git a/src/ui/layout/mct-tree.scss b/src/ui/layout/mct-tree.scss index 3defe59e60..b0fb8ec02c 100644 --- a/src/ui/layout/mct-tree.scss +++ b/src/ui/layout/mct-tree.scss @@ -12,10 +12,6 @@ flex: 0 0 auto; } - &__loading { - flex: 1 1 auto; - } - &__no-results { font-style: italic; opacity: 0.6; @@ -26,6 +22,33 @@ height: 0; // Chrome 73 overflow bug fix padding-right: $interiorMarginSm; } + + .c-tree { + flex: 1 1 auto; + overflow: hidden; + transition: all; + + .scrollable-children { + .c-tree__item-h { + width: 100%; + } + } + + &__item--empty { + // Styling for empty tree items + // Indent should allow for c-nav view-control width and icon spacing + font-style: italic; + padding: $interiorMarginSm * 2 1px; + opacity: 0.7; + pointer-events: none; + + &:before { + content: ''; + display: inline-block; + width: $treeNavArrowD + $interiorMarginLg; + } + } + } } .c-tree, @@ -43,7 +66,6 @@ } &__item { - $aPad: $interiorMarginSm; border-radius: $controlCr; display: flex; align-items: center; @@ -82,22 +104,9 @@ margin-left: $interiorMarginSm; } - &.is-navigated-object, - &.is-selected { - .c-tree__item__type-icon:before { - color: $colorItemTreeIconHover; - } - } - - &.is-being-edited { - background: $colorItemTreeEditingBg; - .c-tree__item__type-icon:before { - color: $colorItemTreeEditingIcon; - } - - .c-tree__item__name { - color: $colorItemTreeEditingFg; - font-style: italic; + @include desktop { + &:hover { + background: $colorItemTreeHoverBg; } } @@ -106,10 +115,6 @@ flex: 1 1 auto; } - &__name { - color: $colorItemTreeFg; - } - &.is-alias { // Object is an alias to an original. [class*='__type-icon'] { @@ -125,6 +130,55 @@ width: ceil($mobileTreeItemH * 0.5); } } + + &.is-navigated-object, + &.is-selected { + background: $colorItemTreeSelectedBg; + + [class*="__label"], + [class*="__name"] { + color: $colorItemTreeSelectedFg; + } + + [class*="__type-icon"]:before { + color: $colorItemTreeSelectedIcon; + } + } + } + + &__item__label { + @include desktop { + &:hover { + filter: $filterHov; + } + } + } +} + +.is-editing .is-navigated-object { + a[class*="__item__label"] { + opacity: 0.4; + + [class*="__name"] { + font-style: italic; + } + } +} + +.c-tree { + &__item { + body.mobile & { + @include button($bg: $colorMobilePaneLeftTreeItemBg, $fg: $colorMobilePaneLeftTreeItemFg); + height: $mobileTreeItemH; + margin-bottom: $interiorMarginSm; + [class*="view-control"] { + width: ceil($mobileTreeItemH * 0.5); + } + } + } + + .c-tree { + margin-left: $treeItemIndent; } } @@ -141,6 +195,51 @@ } } +.c-nav { + $dimension: $treeNavArrowD; + + &__up, &__down { + flex: 0 0 auto; + height: $dimension; + width: $dimension; + visibility: hidden; + position: relative; + text-align: center; + + &.is-enabled { + visibility: visible; + } + + &:before { + // Nav arrow + $dimension: 9px; + $width: 3px; + border: solid $colorItemTreeVC; + border-width: 0 $width $width 0; + content: ''; + display: block; + position: absolute; + left: 50%; top: 50%; + height: $dimension; + width: $dimension; + } + + @include desktop { + &:hover:before { + border-color: $colorItemTreeHoverFg; + } + } + } + + &__up:before { + transform: translate(-30%, -50%) rotate(135deg); + } + + &__down:before { + transform: translate(-70%, -50%) rotate(-45deg); + } +} + .c-selector { .c-tree-and-search__tree.c-tree { border: 1px solid $colorInteriorBorder; @@ -148,3 +247,32 @@ padding: $interiorMargin; } } + +// TRANSITIONS +.slide-left, +.slide-right { + animation-duration: 500ms; + animation-iteration-count: 1; + transition: all; + transition-timing-function: ease-in-out; +} + +.slide-left { + animation-name: animSlideLeft; +} + +.slide-right { + animation-name: animSlideRight; +} + +@keyframes animSlideLeft { + 0% {opactiy: 0; transform: translateX(100%);} + 10% {opacity: 1;} + 100% {transform: translateX(0);} +} + +@keyframes animSlideRight { + 0% {opactiy: 0; transform: translateX(-100%);} + 10% {opacity: 1;} + 100% {transform: translateX(0);} +} diff --git a/src/ui/layout/mct-tree.vue b/src/ui/layout/mct-tree.vue index c75352e93e..f1d969ecdf 100644 --- a/src/ui/layout/mct-tree.vue +++ b/src/ui/layout/mct-tree.vue @@ -1,5 +1,8 @@ <template> -<div class="c-tree-and-search"> +<div + class="c-tree-and-search" +> + <div class="c-tree-and-search__search"> <search ref="shell-search" @@ -10,15 +13,8 @@ /> </div> - <!-- loading --> <div - v-if="isLoading" - class="c-tree-and-search__loading loading" - ></div> - <!-- end loading --> - - <div - v-if="(allTreeItems.length === 0) || (searchValue && filteredTreeItems.length === 0)" + v-if="(searchValue && allTreeItems.length === 0 && !isLoading) || (searchValue && searchResultItems.length === 0)" class="c-tree-and-search__no-results" > No results found @@ -26,30 +22,72 @@ <!-- main tree --> <ul - v-if="!isLoading" - v-show="!searchValue" + ref="mainTree" class="c-tree-and-search__tree c-tree" > - <tree-item - v-for="treeItem in allTreeItems" - :key="treeItem.id" - :node="treeItem" - /> + <!-- ancestors --> + <div v-if="!activeSearch"> + <tree-item + v-for="(ancestor, index) in ancestors" + :key="ancestor.id" + :node="ancestor" + :show-up="index < ancestors.length - 1" + :show-down="false" + :left-offset="index * 10 + 'px'" + :emit-height="getChildHeight" + @emittedHeight="setChildHeight" + @resetTree="handleReset" + /> + <!-- loading --> + <li + v-if="isLoading" + class="c-tree__item c-tree-and-search__loading loading" + > + <span class="c-tree__item__label">Loading...</span> + </li> + <!-- end loading --> + </div> + <!-- currently viewed children --> + <transition + @enter="childrenIn" + > + <li + v-if="!isLoading" + :class="childrenSlideClass" + :style="childrenListStyles()" + > + <ul + ref="scrollable" + class="scrollable-children" + :style="scrollableStyles()" + @scroll="scrollItems" + > + <div :style="{ height: childrenHeight + 'px'}"> + <tree-item + v-for="(treeItem, index) in visibleItems" + :key="treeItem.id" + :node="treeItem" + :left-offset="itemLeftOffset" + :item-offset="itemOffset" + :item-index="index" + :item-height="itemHeight" + :virtual-scroll="!noScroll" + :show-down="activeSearch ? false : true" + @expanded="handleExpanded" + /> + <li + v-if="visibleItems.length === 0" + :style="emptyStyles()" + class="c-tree__item c-tree__item--empty" + > + No items + </li> + </div> + </ul> + </li> + </transition> </ul> <!-- end main tree --> - - <!-- search tree --> - <ul - v-if="searchValue" - class="c-tree-and-search__tree c-tree" - > - <tree-item - v-for="treeItem in filteredTreeItems" - :key="treeItem.id" - :node="treeItem" - /> - </ul> - <!-- end search tree --> </div> </template> @@ -57,6 +95,14 @@ import treeItem from './tree-item.vue'; import search from '../components/search.vue'; +const LOCAL_STORAGE_KEY__TREE_EXPANDED = 'mct-tree-expanded'; +const ROOT_PATH = '/browse/'; +const ITEM_BUFFER = 5; +const RECHECK_DELAY = 100; +const RESIZE_FIRE_DELAY_MS = 500; +let windowResizeId = undefined; +let windowResizing = false; + export default { inject: ['openmct'], name: 'MctTree', @@ -64,75 +110,518 @@ export default { search, treeItem }, + props: { + syncTreeNavigation: { + type: Boolean, + required: true + } + }, data() { + let isMobile = this.openmct.$injector.get('agentService'); + return { + isLoading: false, searchValue: '', allTreeItems: [], - filteredTreeItems: [], - isLoading: false + searchResultItems: [], + visibleItems: [], + ancestors: [], + childrenSlideClass: 'slide-left', + availableContainerHeight: 0, + noScroll: true, + updatingView: false, + itemHeight: 28, + itemOffset: 0, + childrenHeight: 0, + scrollable: undefined, + pageThreshold: 50, + activeSearch: false, + getChildHeight: false, + settingChildrenHeight: false, + isMobile: isMobile.mobileName, + multipleRootChildren: false }; }, - mounted() { + computed: { + currentNavigatedPath() { + let ancestorsCopy = [...this.ancestors]; + if (this.multipleRootChildren) { + ancestorsCopy.shift(); // remove root + } + + return ancestorsCopy + .map((ancestor) => ancestor.id) + .join('/'); + }, + currentObjectPath() { + let ancestorsCopy = [...this.ancestors]; + + return ancestorsCopy + .reverse() + .map((ancestor) => ancestor.object); + }, + focusedItems() { + return this.activeSearch ? this.searchResultItems : this.allTreeItems; + }, + itemLeftOffset() { + return this.activeSearch ? '0px' : this.ancestors.length * 10 + 'px'; + } + }, + watch: { + syncTreeNavigation() { + const AND_SAVE_PATH = true; + let currentLocationPath = this.openmct.router.currentLocation.path; + let hasParent = this.currentlyViewedObjectParentPath() || (this.multipleRootChildren && !this.currentlyViewedObjectParentPath()); + let jumpAndScroll = currentLocationPath + && hasParent + && !this.currentPathIsActivePath(); + let justScroll = this.currentPathIsActivePath() && !this.noScroll; + + if (this.searchValue) { + this.searchValue = ''; + } + + if (jumpAndScroll) { + this.scrollTo = this.currentlyViewedObjectId(); + this.allTreeItems = []; + this.jumpPath = this.currentlyViewedObjectParentPath(); + if (this.multipleRootChildren) { + if (!this.jumpPath) { + this.jumpPath = 'ROOT'; + this.ancestors = []; + } else { + this.ancestors = [this.ancestors[0]]; + } + } else { + this.ancestors = []; + } + + this.jumpToPath(AND_SAVE_PATH); + } else if (justScroll) { + this.scrollTo = this.currentlyViewedObjectId(); + this.autoScroll(); + } + }, + searchValue() { + if (this.searchValue !== '' && !this.activeSearch) { + this.searchActivated(); + } else if (this.searchValue === '') { + this.searchDeactivated(); + } + }, + searchResultItems() { + this.setContainerHeight(); + } + }, + async mounted() { + let savedPath = this.getSavedNavigatedPath(); this.searchService = this.openmct.$injector.get('searchService'); - this.getAllChildren(); + window.addEventListener('resize', this.handleWindowResize); + + let root = await this.openmct.objects.get('ROOT'); + + if (root.identifier !== undefined) { + let rootNode = this.buildTreeItem(root); + // if more than one root item, set multipleRootChildren to true and add root to ancestors + if (root.composition && root.composition.length > 1) { + this.ancestors.push(rootNode); + this.multipleRootChildren = true; + } else if (!savedPath && root.composition[0] !== undefined) { + // needed if saved path is not set, need to set it to the only root child + savedPath = root.composition[0]; + } + + if (savedPath) { + let scrollIfApplicable = () => { + if (this.currentPathIsActivePath()) { + this.scrollTo = this.currentlyViewedObjectId(); + } + }; + + this.jumpPath = savedPath; + this.afterJump = scrollIfApplicable; + } + + this.getAllChildren(rootNode); + } + }, + destroyed() { + window.removeEventListener('resize', this.handleWindowResize); }, methods: { - getAllChildren() { - this.isLoading = true; - this.openmct.objects.get('ROOT') - .then(root => { - let composition = this.openmct.composition.get(root); - if (composition !== undefined) { - return composition.load(); - } else { - return []; + updatevisibleItems() { + if (this.updatingView) { + return; + } + + this.updatingView = true; + requestAnimationFrame(() => { + let start = 0; + let end = this.pageThreshold; + let allItemsCount = this.focusedItems.length; + + if (allItemsCount < this.pageThreshold) { + end = allItemsCount; + } else { + let firstVisible = this.calculateFirstVisibleItem(); + let lastVisible = this.calculateLastVisibleItem(); + let totalVisible = lastVisible - firstVisible; + let numberOffscreen = this.pageThreshold - totalVisible; + + start = firstVisible - Math.floor(numberOffscreen / 2); + end = lastVisible + Math.ceil(numberOffscreen / 2); + + if (start < 0) { + start = 0; + end = Math.min(this.pageThreshold, allItemsCount); + } else if (end >= allItemsCount) { + end = allItemsCount; + start = end - this.pageThreshold + 1; } - }) - .then(children => { - this.isLoading = false; - this.allTreeItems = children.map(c => { - return { - id: this.openmct.objects.makeKeyString(c.identifier), - object: c, - objectPath: [c], - navigateToParent: '/browse' - }; - }); - }); + } + + this.itemOffset = start; + this.visibleItems = this.focusedItems.slice(start, end); + + this.updatingView = false; + }); }, - getFilteredChildren() { - this.searchService.query(this.searchValue).then(children => { - this.filteredTreeItems = children.hits.map(child => { + async setContainerHeight() { + await this.$nextTick(); + let mainTree = this.$refs.mainTree; + let mainTreeHeight = mainTree.clientHeight; - let context = child.object.getCapability('context'); - let object = child.object.useCapability('adapter'); - let objectPath = []; - let navigateToParent; + if (mainTreeHeight !== 0) { + this.calculateChildHeight(() => { + let ancestorsHeight = this.calculateAncestorHeight(); + let allChildrenHeight = this.calculateChildrenHeight(); - if (context) { - objectPath = context.getPath().slice(1) - .map(oldObject => oldObject.useCapability('adapter')) - .reverse(); - navigateToParent = '/browse/' + objectPath.slice(1) - .map((parent) => this.openmct.objects.makeKeyString(parent.identifier)) - .join('/'); + if (this.activeSearch) { + ancestorsHeight = 0; } - return { - id: this.openmct.objects.makeKeyString(object.identifier), - object, - objectPath, - navigateToParent - }; + this.availableContainerHeight = mainTreeHeight - ancestorsHeight; + + if (allChildrenHeight > this.availableContainerHeight) { + this.setPageThreshold(); + this.noScroll = false; + } else { + this.noScroll = true; + } + + this.updatevisibleItems(); }); + } else { + window.setTimeout(this.setContainerHeight, RECHECK_DELAY); + } + }, + calculateFirstVisibleItem() { + let scrollTop = this.$refs.scrollable.scrollTop; + + return Math.floor(scrollTop / this.itemHeight); + }, + calculateLastVisibleItem() { + let scrollBottom = this.$refs.scrollable.scrollTop + this.$refs.scrollable.offsetHeight; + + return Math.ceil(scrollBottom / this.itemHeight); + }, + calculateChildrenHeight() { + let mainTreeTopMargin = this.getElementStyleValue(this.$refs.mainTree, 'marginTop'); + let childrenCount = this.focusedItems.length; + + return (this.itemHeight * childrenCount) - mainTreeTopMargin; // 5px margin + }, + setChildrenHeight() { + this.childrenHeight = this.calculateChildrenHeight(); + }, + calculateAncestorHeight() { + let ancestorCount = this.ancestors.length; + + return this.itemHeight * ancestorCount; + }, + calculateChildHeight(callback) { + if (callback) { + this.afterChildHeight = callback; + } + + if (!this.activeSearch) { + this.getChildHeight = true; + } else if (this.afterChildHeight) { + // keep the height from before + this.afterChildHeight(); + delete this.afterChildHeight; + } + }, + async setChildHeight(item) { + if (!this.getChildHeight || this.settingChildrenHeight) { + return; + } + + this.settingChildrenHeight = true; + if (this.isMobile) { + item = item.children[0]; + } + + await this.$nextTick(); + let topMargin = this.getElementStyleValue(item, 'marginTop'); + let bottomMargin = this.getElementStyleValue(item, 'marginBottom'); + let totalVerticalMargin = topMargin + bottomMargin; + + this.itemHeight = item.clientHeight + totalVerticalMargin; + this.setChildrenHeight(); + if (this.afterChildHeight) { + this.afterChildHeight(); + delete this.afterChildHeight; + } + + this.getChildHeight = false; + this.settingChildrenHeight = false; + }, + setPageThreshold() { + let threshold = Math.ceil(this.availableContainerHeight / this.itemHeight) + ITEM_BUFFER; + // all items haven't loaded yet (nextTick not working for this) + if (threshold === ITEM_BUFFER) { + window.setTimeout(this.setPageThreshold, RECHECK_DELAY); + } else { + this.pageThreshold = threshold; + } + }, + handleWindowResize() { + if (!windowResizing) { + windowResizing = true; + window.clearTimeout(windowResizeId); + windowResizeId = window.setTimeout(() => { + this.setContainerHeight(); + windowResizing = false; + }, RESIZE_FIRE_DELAY_MS); + } + }, + async getAllChildren(node) { + this.isLoading = true; + if (this.composition) { + this.composition.off('add', this.addChild); + this.composition.off('remove', this.removeChild); + delete this.composition; + } + + this.allTreeItems = []; + this.composition = this.openmct.composition.get(node.object); + this.composition.on('add', this.addChild); + this.composition.on('remove', this.removeChild); + await this.composition.load(); + this.finishLoading(); + }, + buildTreeItem(domainObject) { + let navToParent = ROOT_PATH + this.currentNavigatedPath; + if (navToParent === ROOT_PATH) { + navToParent = navToParent.slice(0, -1); + } + + return { + id: this.openmct.objects.makeKeyString(domainObject.identifier), + object: domainObject, + objectPath: [domainObject].concat(this.currentObjectPath), + navigateToParent: navToParent + }; + }, + addChild(child) { + let item = this.buildTreeItem(child); + this.allTreeItems.push(item); + if (!this.isLoading) { + this.setContainerHeight(); + } + }, + removeChild(identifier) { + let removeId = this.openmct.objects.makeKeyString(identifier); + this.allTreeItems = this.allTreeItems + .filter(c => c.id !== removeId); + this.setContainerHeight(); + }, + finishLoading() { + if (this.jumpPath) { + this.jumpToPath(); + } + + this.autoScroll(); + this.isLoading = false; + }, + async jumpToPath(saveExpandedPath = false) { + // check for older implementations of tree storage and reformat if necessary + if (Array.isArray(this.jumpPath)) { + this.jumpPath = this.jumpPath[0]; + } + + let nodes = this.jumpPath.split('/'); + + for (let i = 0; i < nodes.length; i++) { + let currentNode = await this.openmct.objects.get(nodes[i]); + let newParent = this.buildTreeItem(currentNode); + this.ancestors.push(newParent); + + if (i === nodes.length - 1) { + this.jumpPath = ''; + this.getAllChildren(newParent); + if (this.afterJump) { + await this.$nextTick(); + this.afterJump(); + delete this.afterJump; + } + + if (saveExpandedPath) { + this.setCurrentNavigatedPath(); + } + } + } + }, + async autoScroll() { + if (!this.scrollTo) { + return; + } + + if (this.$refs.scrollable) { + let indexOfScroll = this.indexOfItemById(this.scrollTo); + let scrollTopAmount = indexOfScroll * this.itemHeight; + + await this.$nextTick(); + this.$refs.scrollable.scrollTop = scrollTopAmount; + // race condition check + if (scrollTopAmount > 0 && this.$refs.scrollable.scrollTop === 0) { + window.setTimeout(this.autoScroll, RECHECK_DELAY); + + return; + } + + this.scrollTo = undefined; + } else { + window.setTimeout(this.autoScroll, RECHECK_DELAY); + } + }, + indexOfItemById(id) { + for (let i = 0; i < this.allTreeItems.length; i++) { + if (this.allTreeItems[i].id === id) { + return i; + } + } + }, + async getSearchResults() { + let results = await this.searchService.query(this.searchValue); + this.searchResultItems = results.hits.map(result => { + + let context = result.object.getCapability('context'); + let object = result.object.useCapability('adapter'); + let objectPath = []; + let navigateToParent; + + if (context) { + objectPath = context.getPath().slice(1) + .map(oldObject => oldObject.useCapability('adapter')) + .reverse(); + navigateToParent = objectPath.slice(1) + .map((parent) => this.openmct.objects.makeKeyString(parent.identifier)); + navigateToParent = ROOT_PATH + navigateToParent.reverse().join('/'); + } + + return { + id: this.openmct.objects.makeKeyString(object.identifier), + object, + objectPath, + navigateToParent + }; }); }, searchTree(value) { this.searchValue = value; if (this.searchValue !== '') { - this.getFilteredChildren(); + this.getSearchResults(); } + }, + searchActivated() { + this.activeSearch = true; + this.$refs.scrollable.scrollTop = 0; + }, + searchDeactivated() { + this.activeSearch = false; + this.$refs.scrollable.scrollTop = 0; + this.setContainerHeight(); + }, + handleReset(node) { + this.childrenSlideClass = 'slide-right'; + this.ancestors.splice(this.ancestors.indexOf(node) + 1); + this.getAllChildren(node); + this.setCurrentNavigatedPath(); + }, + handleExpanded(node) { + if (this.activeSearch) { + return; + } + + this.childrenSlideClass = 'slide-left'; + let newParent = this.buildTreeItem(node); + this.ancestors.push(newParent); + this.getAllChildren(newParent); + this.setCurrentNavigatedPath(); + }, + getSavedNavigatedPath() { + return JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY__TREE_EXPANDED)); + }, + setCurrentNavigatedPath() { + if (!this.searchValue) { + localStorage.setItem(LOCAL_STORAGE_KEY__TREE_EXPANDED, JSON.stringify(this.currentNavigatedPath)); + } + }, + currentPathIsActivePath() { + return this.getSavedNavigatedPath() === this.currentlyViewedObjectParentPath(); + }, + currentlyViewedObjectId() { + let currentPath = this.openmct.router.currentLocation.path; + if (currentPath) { + currentPath = currentPath.split(ROOT_PATH)[1]; + + return currentPath.split('/').pop(); + } + }, + currentlyViewedObjectParentPath() { + let currentPath = this.openmct.router.currentLocation.path; + if (currentPath) { + currentPath = currentPath.split(ROOT_PATH)[1]; + currentPath = currentPath.split('/'); + currentPath.pop(); + + return currentPath.join('/'); + } + }, + scrollItems(event) { + if (!windowResizing) { + this.updatevisibleItems(); + } + }, + childrenListStyles() { + return { position: 'relative' }; + }, + scrollableStyles() { + return { + height: this.availableContainerHeight + 'px', + overflow: this.noScroll ? 'hidden' : 'scroll' + }; + }, + emptyStyles() { + let offset = ((this.ancestors.length + 1) * 10); + + return { + paddingLeft: offset + 'px' + }; + }, + childrenIn(el, done) { + // still needing this timeout for some reason + window.setTimeout(this.setContainerHeight, RECHECK_DELAY); + done(); + }, + getElementStyleValue(el, style) { + let styleString = window.getComputedStyle(el)[style]; + let index = styleString.indexOf('px'); + + return Number(styleString.slice(0, index)); } } }; diff --git a/src/ui/layout/pane.scss b/src/ui/layout/pane.scss index 0d988e1124..fdcb0913e3 100644 --- a/src/ui/layout/pane.scss +++ b/src/ui/layout/pane.scss @@ -40,6 +40,10 @@ display: flex; align-items: center; @include desktop() { margin-bottom: $interiorMargin; } + + [class*="button"] { + color: $colorBtnMajorBg; + } } &--reacts { @@ -128,12 +132,23 @@ @include userSelectNone(); color: $splitterBtnLabelColorFg; display: block; - pointer-events: none; text-transform: uppercase; - transform-origin: top left; flex: 1 1 auto; } + [class*="expand-button"] { + display: none; // Hidden by default + background: $splitterCollapsedBtnColorBg; + color: $splitterCollapsedBtnColorFg; + font-size: 0.9em; + + &:hover { + background: $splitterCollapsedBtnColorBgHov; + color: inherit; + transition: $transIn; + } + } + &--resizing { // User is dragging the handle and resizing a pane @include userSelectNone(); @@ -160,23 +175,12 @@ display: none; } - .l-pane__header { - &:hover { - color: $splitterCollapsedBtnColorFgHov; - .l-pane__label { - color: inherit; - } - .l-pane__collapse-button { - background: $splitterCollapsedBtnColorBgHov; - color: inherit; - transition: $transIn; - } - } + [class*="collapse-button"] { + display: none; } - .l-pane__collapse-button { - background: $splitterCollapsedBtnColorBg; - color: $splitterCollapsedBtnColorFg; + [class*="expand-button"] { + display: block; } } @@ -198,36 +202,26 @@ .l-pane__collapse-button { &:before { - content: $glyph-icon-arrow-right-equilateral; + content: $glyph-icon-line-horz; } } &[class*="--collapsed"] { /************************ COLLAPSED HORIZONTAL SPLITTER, EITHER DIRECTION */ [class*="__header"] { - @include abs(); - margin: 0; + display: none; } - [class*="label"] { - position: absolute; - transform: translate($interiorMarginLg + 1, 18px) rotate(90deg); - left: 3px; - top: 0; - z-index: 1; - } - - .l-pane__collapse-button { - border-top-left-radius: 0; - border-bottom-left-radius: 0; // Only have to do this once, because of scaleX(-1) below. + [class*="expand-button"] { position: absolute; top: 0; right: 0; bottom: 0; left: 0; height: auto; width: 100%; - padding: 0; + padding: $interiorMargin 2px; - &:before { - position: absolute; - top: 5px; + [class*="label"] { + text-orientation: mixed; + text-transform: uppercase; + writing-mode: vertical-lr; } } } @@ -243,10 +237,9 @@ transform: translateX(floor($splitterHandleD / -2)); // Center over the pane edge } - &[class*="--collapsed"] { - .l-pane__collapse-button { - transform: scaleX(-1); - } + [class*="expand-button"] { + border-top-left-radius: $controlCr; + border-bottom-left-radius: $controlCr; } } @@ -261,10 +254,9 @@ transform: translateX(floor($splitterHandleD / 2)); } - &:not([class*="--collapsed"]) { - .l-pane__collapse-button { - transform: scaleX(-1); - } + [class*="expand-button"] { + border-top-right-radius: $controlCr; + border-bottom-right-radius: $controlCr; } } } diff --git a/src/ui/layout/pane.vue b/src/ui/layout/pane.vue index 1f8a2267f7..8bd178dc50 100644 --- a/src/ui/layout/pane.vue +++ b/src/ui/layout/pane.vue @@ -20,12 +20,19 @@ <span v-if="label" class="l-pane__label" >{{ label }}</span> + <slot name="controls"></slot> <button v-if="collapsable" - class="l-pane__collapse-button c-button" + class="l-pane__collapse-button c-icon-button" @click="toggleCollapse" ></button> </div> + <button + class="l-pane__expand-button" + @click="toggleCollapse" + > + <span class="l-pane__expand-button__label">{{ label }}</span> + </button> <div class="l-pane__contents"> <slot></slot> </div> diff --git a/src/ui/layout/tree-item.vue b/src/ui/layout/tree-item.vue index 3cb3b7490d..800955b758 100644 --- a/src/ui/layout/tree-item.vue +++ b/src/ui/layout/tree-item.vue @@ -1,38 +1,39 @@ <template> -<li class="c-tree__item-h"> +<li + ref="me" + :style="{ + 'top': virtualScroll ? itemTop : 'auto', + 'position': virtualScroll ? 'absolute' : 'relative' + }" + class="c-tree__item-h" +> <div class="c-tree__item" - :class="{ 'is-alias': isAlias, 'is-navigated-object': navigated }" + :class="{ + 'is-alias': isAlias, + 'is-navigated-object': navigated + }" > <view-control v-model="expanded" class="c-tree__item__view-control" - :enabled="hasChildren" + :control-class="'c-nav__up'" + :enabled="showUp" + @input="resetTreeHere" /> <object-label :domain-object="node.object" :object-path="node.objectPath" :navigate-to-path="navigateToPath" + :style="{ paddingLeft: leftOffset }" + /> + <view-control + v-model="expanded" + class="c-tree__item__view-control" + :control-class="'c-nav__down'" + :enabled="hasComposition && showDown" /> </div> - <ul - v-if="expanded" - class="c-tree" - > - <li - v-if="isLoading && !loaded" - class="c-tree__item-h" - > - <div class="c-tree__item loading"> - <span class="c-tree__item__label">Loading...</span> - </div> - </li> - <tree-item - v-for="child in children" - :key="child.id" - :node="child" - /> - </ul> </li> </template> @@ -40,8 +41,6 @@ import viewControl from '../components/viewControl.vue'; import ObjectLabel from '../components/ObjectLabel.vue'; -const LOCAL_STORAGE_KEY__TREE_EXPANDED = 'mct-tree-expanded'; - export default { name: 'TreeItem', inject: ['openmct'], @@ -53,17 +52,49 @@ export default { node: { type: Object, required: true + }, + leftOffset: { + type: String, + default: '0px' + }, + showUp: { + type: Boolean, + default: false + }, + showDown: { + type: Boolean, + default: true + }, + itemIndex: { + type: Number, + required: false, + default: undefined + }, + itemOffset: { + type: Number, + required: false, + default: undefined + }, + itemHeight: { + type: Number, + required: false, + default: 0 + }, + virtualScroll: { + type: Boolean, + default: false + }, + emitHeight: { + type: Boolean, + default: false } }, data() { this.navigateToPath = this.buildPathString(this.node.navigateToParent); return { - hasChildren: false, - isLoading: false, - loaded: false, + hasComposition: false, navigated: this.navigateToPath === this.openmct.router.currentLocation.path, - children: [], expanded: false }; }, @@ -77,32 +108,23 @@ export default { let parentKeyString = this.openmct.objects.makeKeyString(parent.identifier); return parentKeyString !== this.node.object.location; + }, + itemTop() { + return (this.itemOffset + this.itemIndex) * this.itemHeight + 'px'; } }, watch: { expanded() { - if (!this.hasChildren) { - return; - } - - if (!this.loaded && !this.isLoading) { - this.composition = this.openmct.composition.get(this.domainObject); - this.composition.on('add', this.addChild); - this.composition.on('remove', this.removeChild); - this.composition.load().then(this.finishLoading); - this.isLoading = true; - } - - this.setLocalStorageExpanded(this.navigateToPath); + this.$emit('expanded', this.domainObject); + }, + emitHeight() { + this.$nextTick(() => { + this.$emit('emittedHeight', this.$refs.me); + }); } }, mounted() { - // TODO: should update on mutation. - // TODO: click navigation should not fubar hash quite so much. - // TODO: should highlight if navigated to. - // TODO: should have context menu. - // TODO: should support drag/drop composition - // TODO: set isAlias per tree-item + let objectComposition = this.openmct.composition.get(this.node.object); this.domainObject = this.node.object; let removeListener = this.openmct.objects.observe(this.domainObject, '*', (newObject) => { @@ -110,49 +132,19 @@ export default { }); this.$once('hook:destroyed', removeListener); - if (this.openmct.composition.get(this.node.object)) { - this.hasChildren = true; + if (objectComposition) { + this.hasComposition = true; } this.openmct.router.on('change:path', this.highlightIfNavigated); - - this.getLocalStorageExpanded(); - }, - beforeDestroy() { - /**** - * calling this.setLocalStorageExpanded explicitly here because for whatever reason, - * the watcher on this.expanded is not triggering this.setLocalStorageExpanded(), - * even though Vue documentation states, "At this stage the instance is still fully functional." - *****/ - this.expanded = false; - this.setLocalStorageExpanded(); + if (this.emitHeight) { + this.$emit('emittedHeight', this.$refs.me); + } }, destroyed() { this.openmct.router.off('change:path', this.highlightIfNavigated); - if (this.composition) { - this.composition.off('add', this.addChild); - this.composition.off('remove', this.removeChild); - delete this.composition; - } }, methods: { - addChild(child) { - this.children.push({ - id: this.openmct.objects.makeKeyString(child.identifier), - object: child, - objectPath: [child].concat(this.node.objectPath), - navigateToParent: this.navigateToPath - }); - }, - removeChild(identifier) { - let removeId = this.openmct.objects.makeKeyString(identifier); - this.children = this.children - .filter(c => c.id !== removeId); - }, - finishLoading() { - this.isLoading = false; - this.loaded = true; - }, buildPathString(parentPath) { return [parentPath, this.openmct.objects.makeKeyString(this.node.object.identifier)].join('/'); }, @@ -163,35 +155,8 @@ export default { this.navigated = false; } }, - getLocalStorageExpanded() { - let expandedPaths = localStorage.getItem(LOCAL_STORAGE_KEY__TREE_EXPANDED); - - if (expandedPaths) { - expandedPaths = JSON.parse(expandedPaths); - this.expanded = expandedPaths.includes(this.navigateToPath); - } - }, - // expanded nodes/paths are stored in local storage as an array - setLocalStorageExpanded() { - let expandedPaths = localStorage.getItem(LOCAL_STORAGE_KEY__TREE_EXPANDED); - expandedPaths = expandedPaths ? JSON.parse(expandedPaths) : []; - - if (this.expanded) { - if (!expandedPaths.includes(this.navigateToPath)) { - expandedPaths.push(this.navigateToPath); - } - } else { - // remove this node path and all children paths from stored expanded paths - expandedPaths = expandedPaths.filter(path => !path.startsWith(this.navigateToPath)); - } - - localStorage.setItem(LOCAL_STORAGE_KEY__TREE_EXPANDED, JSON.stringify(expandedPaths)); - }, - removeLocalStorageExpanded() { - let expandedPaths = localStorage.getItem(LOCAL_STORAGE_KEY__TREE_EXPANDED); - expandedPaths = expandedPaths ? JSON.parse(expandedPaths) : []; - expandedPaths = expandedPaths.filter(path => !path.startsWith(this.navigateToPath)); - localStorage.setItem(LOCAL_STORAGE_KEY__TREE_EXPANDED, JSON.stringify(expandedPaths)); + resetTreeHere() { + this.$emit('resetTree', this.node); } } };