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/package.json b/package.json index 44608b8bd2..3bc82ca216 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openmct", - "version": "1.0.0-snapshot", + "version": "1.3.0-SNAPSHOT", "description": "The Open MCT core platform", "dependencies": {}, "devDependencies": { 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..3e0d999573 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 (rootRegistry) { + // 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/condition/Condition.js b/src/plugins/condition/Condition.js index 1394978f72..7a16281931 100644 --- a/src/plugins/condition/Condition.js +++ b/src/plugins/condition/Condition.js @@ -68,7 +68,7 @@ export default class Condition extends EventEmitter { this.description = ''; } - getResult(datum) { + updateResult(datum) { if (!datum || !datum.id) { console.log('no data received'); @@ -79,9 +79,9 @@ export default class Condition extends EventEmitter { this.criteria.forEach(criterion => { if (this.isAnyOrAllTelemetry(criterion)) { - criterion.getResult(datum, this.conditionManager.telemetryObjects); + criterion.updateResult(datum, this.conditionManager.telemetryObjects); } else { - criterion.getResult(datum); + criterion.updateResult(datum); } }); diff --git a/src/plugins/condition/ConditionManager.js b/src/plugins/condition/ConditionManager.js index 165aa92b2a..fffd2b8881 100644 --- a/src/plugins/condition/ConditionManager.js +++ b/src/plugins/condition/ConditionManager.js @@ -57,7 +57,9 @@ export default class ConditionManager extends EventEmitter { return; } - this.telemetryObjects[id] = Object.assign({}, endpoint, {telemetryMetaData: this.openmct.telemetry.getMetadata(endpoint).valueMetadatas}); + const metadata = this.openmct.telemetry.getMetadata(endpoint); + + this.telemetryObjects[id] = Object.assign({}, endpoint, {telemetryMetaData: metadata ? metadata.valueMetadatas : []}); this.subscriptions[id] = this.openmct.telemetry.subscribe( endpoint, this.telemetryReceived.bind(this, endpoint) @@ -322,8 +324,11 @@ export default class ConditionManager extends EventEmitter { let timestamp = {}; timestamp[timeSystemKey] = normalizedDatum[timeSystemKey]; - this.conditions.forEach(condition => { - condition.getResult(normalizedDatum); + //We want to stop when the first condition evaluates to true. + this.conditions.some((condition) => { + condition.updateResult(normalizedDatum); + + return condition.result === true; }); this.updateCurrentCondition(timestamp); diff --git a/src/plugins/condition/ConditionSpec.js b/src/plugins/condition/ConditionSpec.js index 916ba9fd9a..61bfc7c6c9 100644 --- a/src/plugins/condition/ConditionSpec.js +++ b/src/plugins/condition/ConditionSpec.js @@ -149,7 +149,7 @@ describe("The condition", function () { }); it("gets the result of a condition when new telemetry data is received", function () { - conditionObj.getResult({ + conditionObj.updateResult({ value: '0', utc: 'Hi', id: testTelemetryObject.identifier.key @@ -158,7 +158,7 @@ describe("The condition", function () { }); it("gets the result of a condition when new telemetry data is received", function () { - conditionObj.getResult({ + conditionObj.updateResult({ value: '1', utc: 'Hi', id: testTelemetryObject.identifier.key @@ -167,14 +167,14 @@ describe("The condition", function () { }); it("keeps the old result new telemetry data is not used by it", function () { - conditionObj.getResult({ + conditionObj.updateResult({ value: '0', utc: 'Hi', id: testTelemetryObject.identifier.key }); expect(conditionObj.result).toBeTrue(); - conditionObj.getResult({ + conditionObj.updateResult({ value: '1', utc: 'Hi', id: '1234' diff --git a/src/plugins/condition/criterion/AllTelemetryCriterion.js b/src/plugins/condition/criterion/AllTelemetryCriterion.js index 70bc9191ba..bedb361f6c 100644 --- a/src/plugins/condition/criterion/AllTelemetryCriterion.js +++ b/src/plugins/condition/criterion/AllTelemetryCriterion.js @@ -122,7 +122,7 @@ export default class AllTelemetryCriterion extends TelemetryCriterion { return datum; } - getResult(data, telemetryObjects) { + updateResult(data, telemetryObjects) { const validatedData = this.isValid() ? data : {}; if (validatedData) { diff --git a/src/plugins/condition/criterion/TelemetryCriterion.js b/src/plugins/condition/criterion/TelemetryCriterion.js index 03066a752c..a241d56f83 100644 --- a/src/plugins/condition/criterion/TelemetryCriterion.js +++ b/src/plugins/condition/criterion/TelemetryCriterion.js @@ -120,7 +120,7 @@ export default class TelemetryCriterion extends EventEmitter { return datum; } - getResult(data) { + updateResult(data) { const validatedData = this.isValid() ? data : {}; if (this.isStalenessCheck()) { if (this.stalenessSubscription) { @@ -201,7 +201,9 @@ export default class TelemetryCriterion extends EventEmitter { let metadataObject; if (metadata) { const telemetryMetadata = this.openmct.telemetry.getMetadata(telemetryObject); - metadataObject = telemetryMetadata.valueMetadatas.find((valueMetadata) => valueMetadata.key === metadata); + if (telemetryMetadata) { + metadataObject = telemetryMetadata.valueMetadatas.find((valueMetadata) => valueMetadata.key === metadata); + } } return metadataObject; diff --git a/src/plugins/condition/criterion/TelemetryCriterionSpec.js b/src/plugins/condition/criterion/TelemetryCriterionSpec.js index fe3b9fc701..385d55bdd1 100644 --- a/src/plugins/condition/criterion/TelemetryCriterionSpec.js +++ b/src/plugins/condition/criterion/TelemetryCriterionSpec.js @@ -110,7 +110,7 @@ describe("The telemetry criterion", function () { }); it("returns a result on new data from relevant telemetry providers", function () { - telemetryCriterion.getResult({ + telemetryCriterion.updateResult({ value: 'Hello', utc: 'Hi', id: testTelemetryObject.identifier.key diff --git a/src/plugins/condition/pluginSpec.js b/src/plugins/condition/pluginSpec.js index 888a48a9bd..265655e7ac 100644 --- a/src/plugins/condition/pluginSpec.js +++ b/src/plugins/condition/pluginSpec.js @@ -45,8 +45,9 @@ describe('the plugin', function () { type: "test-object", name: "Test Object", telemetry: { - valueMetadatas: [{ - key: "some-key", + values: [{ + key: "some-key2", + source: "some-key2", name: "Some attribute", hints: { range: 2 @@ -64,6 +65,13 @@ describe('the plugin', function () { source: "value", name: "Test", format: "string" + }, + { + key: "some-key", + source: "some-key", + hints: { + domain: 1 + } }] } }; @@ -458,7 +466,7 @@ describe('the plugin', function () { }; }); - xit('should evaluate as stale when telemetry is not received in the allotted time', (done) => { + it('should evaluate as stale when telemetry is not received in the allotted time', (done) => { let conditionMgr = new ConditionManager(conditionSetDomainObject, openmct); conditionMgr.on('conditionSetResultUpdated', mockListener); @@ -480,7 +488,7 @@ describe('the plugin', function () { }, 400); }); - xit('should not evaluate as stale when telemetry is received in the allotted time', (done) => { + it('should not evaluate as stale when telemetry is received in the allotted time', (done) => { const date = Date.now(); conditionSetDomainObject.configuration.conditionCollection[0].configuration.criteria[0].input = ["0.4"]; let conditionMgr = new ConditionManager(conditionSetDomainObject, openmct); @@ -500,10 +508,133 @@ describe('the plugin', function () { key: 'cf4456a9-296a-4e6b-b182-62ed29cd15b9' }, conditionId: '2532d90a-e0d6-4935-b546-3123522da2de', - utc: undefined + utc: date }); done(); }, 300); }); }); + + describe('the condition evaluation', () => { + let conditionSetDomainObject; + + beforeEach(() => { + conditionSetDomainObject = { + "configuration": { + "conditionTestData": [ + { + "telemetry": "", + "metadata": "", + "input": "" + } + ], + "conditionCollection": [ + { + "id": "39584410-cbf9-499e-96dc-76f27e69885f", + "configuration": { + "name": "Unnamed Condition0", + "output": "Any telemetry less than 0", + "trigger": "all", + "criteria": [ + { + "id": "35400132-63b0-425c-ac30-8197df7d5864", + "telemetry": "any", + "operation": "lessThan", + "input": [ + "0" + ], + "metadata": "some-key" + } + ] + }, + "summary": "Match if all criteria are met: Any telemetry value is less than 0" + }, + { + "id": "39584410-cbf9-499e-96dc-76f27e69885d", + "configuration": { + "name": "Unnamed Condition", + "output": "Any telemetry greater than 0", + "trigger": "all", + "criteria": [ + { + "id": "35400132-63b0-425c-ac30-8197df7d5862", + "telemetry": "any", + "operation": "greaterThan", + "input": [ + "0" + ], + "metadata": "some-key" + } + ] + }, + "summary": "Match if all criteria are met: Any telemetry value is greater than 0" + }, + { + "id": "39584410-cbf9-499e-96dc-76f27e69885e", + "configuration": { + "name": "Unnamed Condition1", + "output": "Any telemetry greater than 1", + "trigger": "all", + "criteria": [ + { + "id": "35400132-63b0-425c-ac30-8197df7d5863", + "telemetry": "any", + "operation": "greaterThan", + "input": [ + "1" + ], + "metadata": "some-key" + } + ] + }, + "summary": "Match if all criteria are met: Any telemetry value is greater than 1" + }, + { + "isDefault": true, + "id": "2532d90a-e0d6-4935-b546-3123522da2de", + "configuration": { + "name": "Default", + "output": "Default", + "trigger": "all", + "criteria": [ + ] + }, + "summary": "" + } + ] + }, + "composition": [ + { + "namespace": "", + "key": "test-object" + } + ], + "telemetry": { + }, + "name": "Condition Set", + "type": "conditionSet", + "identifier": { + "namespace": "", + "key": "cf4456a9-296a-4e6b-b182-62ed29cd15b9" + } + + }; + }); + + it('should stop evaluating conditions when a condition evaluates to true', () => { + const date = Date.now(); + let conditionMgr = new ConditionManager(conditionSetDomainObject, openmct); + conditionMgr.on('conditionSetResultUpdated', mockListener); + conditionMgr.telemetryObjects = { + "test-object": testTelemetryObject + }; + conditionMgr.updateConditionTelemetryObjects(); + conditionMgr.telemetryReceived(testTelemetryObject, { + "some-key": 2, + utc: date + }); + let result = conditionMgr.conditions.map(condition => condition.result); + expect(result[2]).toBeUndefined(); + }); + }); }); 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 20c22775b5..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 @@ -351,7 +351,7 @@ $colorSummaryFgEm: $colorBodyFg; // Plot $colorPlotBg: rgba(black, 0.1); $colorPlotFg: $colorBodyFg; -$colorPlotHash: black; +$colorPlotHash: $colorPlotFg; $opacityPlotHash: 0.2; $stylePlotHash: dashed; $colorPlotAreaBorder: $colorInteriorBorder; @@ -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 c6aec4ceb6..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 @@ -351,8 +351,8 @@ $colorSummaryFgEm: white; // Plot $colorPlotBg: rgba(black, 0.05); $colorPlotFg: $colorBodyFg; -$colorPlotHash: black; -$opacityPlotHash: 0.2; +$colorPlotHash: $colorPlotFg; +$opacityPlotHash: 0.3; $stylePlotHash: dashed; $colorPlotAreaBorder: $colorInteriorBorder; $colorPlotLabelFg: pushBack($colorPlotFg, 20%); @@ -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 e52290b5d5..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; } @@ -915,14 +923,6 @@ input[type="range"] { display: inline-flex; align-items: center; } - - &--overlay-content { - > .c-button { - background: $colorLocalControlOvrBg; - border-radius: $controlCr; - box-shadow: $colorLocalControlOvrBg 0 0 0 2px; - } - } } .c-local-controls { 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/styles/plotly.scss b/src/styles/plotly.scss index 1de915ca3d..7be69ce560 100644 --- a/src/styles/plotly.scss +++ b/src/styles/plotly.scss @@ -29,20 +29,27 @@ stroke-width: 1 !important; } - .cartesianlayer .gridlayer { - .x, - .y { - path { - opacity: $opacityPlotHash; - stroke: $colorPlotHash !important; + .cartesianlayer { + .gridlayer { + .x, + .y { + path { + opacity: $opacityPlotHash; + stroke: $colorPlotHash !important; + } } } + + path.xy2-y { + stroke: $colorPlotHash !important; // Using this instead of $colorPlotAreaBorder because that is an rgba + opacity: $opacityPlotHash !important; + } } .xtick, .ytick, - .g-xtitle, - .g-ytitle { + [class^="g-"] text[class*="title"] { + // Matches text { fill: $colorPlotFg !important; font-size: 12px !important; 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 @@ @@ -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 @@
@@ -47,12 +49,23 @@ label="Browse" collapsable > - + + 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..51fb54112e 100644 --- a/src/ui/layout/mct-tree.vue +++ b/src/ui/layout/mct-tree.vue @@ -1,5 +1,8 @@ @@ -57,6 +95,15 @@ import treeItem from './tree-item.vue'; import search from '../components/search.vue'; +const LOCAL_STORAGE_KEY__TREE_EXPANDED__OLD = 'mct-tree-expanded'; +const LOCAL_STORAGE_KEY__EXPANDED_TREE_NODE = 'mct-expanded-tree-node'; +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,74 +111,534 @@ 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(); + }, + allTreeItems() { + // catches an edge case race condition and when new items are added (ex. folder) + if (!this.isLoading) { + this.setContainerHeight(); + } + } + }, + async mounted() { + this.backwardsCompatibilityCheck(); + + 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); + }, + 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) { + // switching back and forth between multiple root children can cause issues, + // this checks for one of those issues + if (this.jumpPath.key) { + this.jumpPath = this.jumpPath.key; + } + + 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(); + }, + async handleReset(node) { + this.visibleItems = []; + await this.$nextTick(); // prevents "ghost" image of visibleItems + this.childrenSlideClass = 'slide-right'; + this.ancestors.splice(this.ancestors.indexOf(node) + 1); + this.getAllChildren(node); + this.setCurrentNavigatedPath(); + }, + async handleExpanded(node) { + if (this.activeSearch) { + return; + } + + this.visibleItems = []; + await this.$nextTick(); // prevents "ghost" image of visibleItems + 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__EXPANDED_TREE_NODE)); + }, + setCurrentNavigatedPath() { + if (!this.searchValue) { + localStorage.setItem(LOCAL_STORAGE_KEY__EXPANDED_TREE_NODE, 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)); + }, + backwardsCompatibilityCheck() { + let oldTreeExpanded = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY__TREE_EXPANDED__OLD)); + + if (oldTreeExpanded) { + localStorage.removeItem(LOCAL_STORAGE_KEY__TREE_EXPANDED__OLD); } } } 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 @@ {{ label }} +
+
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 @@ @@ -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); } } };