From 334e578626107ddd5e7ceb7273f7a1a7461309f5 Mon Sep 17 00:00:00 2001 From: Markus <1720843+maxmarkus@users.noreply.github.com> Date: Wed, 19 Feb 2020 13:42:08 +0100 Subject: [PATCH] Virtual tree navigation (#1075) --- .../src/luigi-config/extended/navigation.js | 2 - core/src/navigation/services/navigation.js | 80 ++++++++++- core/src/utilities/helpers/generic-helpers.js | 33 +++++ core/test/services/navigation.spec.js | 131 +++++++++++++++++- .../utilities/helpers/generic-helpers.spec.js | 15 ++ docs/navigation-parameters-reference.md | 15 ++ 6 files changed, 269 insertions(+), 7 deletions(-) diff --git a/core/examples/luigi-sample-angular/src/luigi-config/extended/navigation.js b/core/examples/luigi-sample-angular/src/luigi-config/extended/navigation.js index cf5715c150..aef45cb0b2 100644 --- a/core/examples/luigi-sample-angular/src/luigi-config/extended/navigation.js +++ b/core/examples/luigi-sample-angular/src/luigi-config/extended/navigation.js @@ -234,8 +234,6 @@ class Navigation { defaultLabel: 'Select Environment ...', parentNodePath: '/environments', // absolute path lazyloadOptions: true, // load options on click instead on page load - preserveSubPathOnSwitch: true, - // alwaysShowDropdown: false, // disable dropdown if there is only one option and no actions options: () => [...Array(10).keys()] .filter(n => n !== 0) diff --git a/core/src/navigation/services/navigation.js b/core/src/navigation/services/navigation.js index c8669e6a08..460d42e4dc 100644 --- a/core/src/navigation/services/navigation.js +++ b/core/src/navigation/services/navigation.js @@ -61,7 +61,7 @@ class NavigationClass { this.rootNode = { children: topNavNodes }; } - await this.getChildren(this.rootNode); // keep it, mutates and filters children + await this.getChildren(this.rootNode, null, activePath); // keep it, mutates and filters children } const nodeNamesInCurrentPath = activePath.split('/'); @@ -205,7 +205,18 @@ class NavigationClass { pathParams ); try { - let children = await this.getChildren(node, newContext); + /** + * If its a virtual tree, + * build static children + */ + this.buildVirtualTree(node, nodeNamesInCurrentPath, pathParams); + + // STANDARD PROCEDURE + let children = await this.getChildren( + node, + newContext, + nodeNamesInCurrentPath + ); const newNodeNamesInCurrentPath = nodeNamesInCurrentPath.slice(1); result = this.buildNode( newNodeNamesInCurrentPath, @@ -222,6 +233,71 @@ class NavigationClass { return result; } + /** + * Requires str to include :virtualPath + * and pathParams consist of :virtualSegment_N + * for deep nested virtual tree building + * + * @param {string} str + * @param {Object} pathParams + * @param {number} _virtualPathIndex + */ + buildVirtualViewUrl(str, pathParams, _virtualPathIndex) { + let newStr = ''; + for (const key in pathParams) { + if (key.startsWith('virtualSegment')) { + newStr += ':' + key + '/'; + } + } + newStr += ':virtualSegment_' + _virtualPathIndex + '/'; + return str + '/' + newStr; + } + + buildVirtualTree(node, nodeNamesInCurrentPath, pathParams) { + const virtualTreeRoot = node.virtualTree; + // Temporary store values that will be cleaned up when creating a copy + const virtualTreeChild = node._virtualTree; + const _virtualViewUrl = node._virtualViewUrl || node.viewUrl; + if ((virtualTreeRoot || virtualTreeChild) && nodeNamesInCurrentPath[0]) { + let _virtualPathIndex = node._virtualPathIndex; + if (virtualTreeRoot) { + _virtualPathIndex = 0; + node.keepSelectedForChildren = true; + } + + // Allowing maximum of 50 path segments to avoid memory issues + const maxPathDepth = 50; + if (_virtualPathIndex > maxPathDepth) { + return; + } + + _virtualPathIndex++; + const keysToClean = [ + '_*', + 'virtualTree', + 'parent', + 'children', + 'keepSelectedForChildren', + 'navigationContext' + ]; + const newChild = GenericHelpers.removeProperties(node, keysToClean); + Object.assign(newChild, { + pathSegment: ':virtualSegment_' + _virtualPathIndex, + label: ':virtualSegment_' + _virtualPathIndex, + viewUrl: this.buildVirtualViewUrl( + _virtualViewUrl, + pathParams, + _virtualPathIndex + ), + _virtualTree: true, + _virtualPathIndex, + _virtualViewUrl + }); + + node.children = [newChild]; + } + } + findMatchingNode(urlPathElement, nodes) { let result = null; const segmentsLength = nodes.filter(n => !!n.pathSegment).length; diff --git a/core/src/utilities/helpers/generic-helpers.js b/core/src/utilities/helpers/generic-helpers.js index 4da695492c..95c72b08ea 100644 --- a/core/src/utilities/helpers/generic-helpers.js +++ b/core/src/utilities/helpers/generic-helpers.js @@ -262,6 +262,39 @@ class GenericHelpersClass { input ); } + + /** + * Returns a new Object with the same object, + * without the keys that were given. + * References still stay. + * Allows wildcard ending keys + * + * @param {Object} input any given object + * @param {Array} of keys, allows also wildcards at the end, like: _* + */ + removeProperties(input, keys) { + const res = {}; + if (!keys instanceof Array || !keys.length) { + console.error( + '[ERROR] removeProperties requires second parameter: array of keys to remove from object.' + ); + return input; + } + for (const key in input) { + if (input.hasOwnProperty(key)) { + const noFullMatch = keys.filter(k => key.includes(k)).length === 0; + const noPartialMatch = + keys + .filter(k => k.endsWith('*')) + .map(k => k.slice(0, -1)) + .filter(k => key.startsWith(k)).length === 0; + if (noFullMatch && noPartialMatch) { + res[key] = input[key]; + } + } + } + return res; + } } export const GenericHelpers = new GenericHelpersClass(); diff --git a/core/test/services/navigation.spec.js b/core/test/services/navigation.spec.js index 9367d04be7..c6b17ec778 100644 --- a/core/test/services/navigation.spec.js +++ b/core/test/services/navigation.spec.js @@ -71,11 +71,16 @@ describe('Navigation', function() { beforeEach(() => { Navigation._rootNodeProviderUsed = undefined; Navigation.rootNode = undefined; + console.warn = sinon.spy(); + console.error = sinon.spy(); + console.warn.resetHistory(); + console.error.resetHistory(); }); afterEach(() => { // reset LuigiConfig.config = {}; sinon.restore(); + sinon.reset(); }); describe('getNavigationPath', function() { it('should not fail for undefined arguments', () => { @@ -434,9 +439,6 @@ describe('Navigation', function() { ] }); - console.warn = sinon.spy(); - console.error = sinon.spy(); - // truthy tests // when const resStaticOk = Navigation.findMatchingNode('other', [staticNode()]); @@ -914,4 +916,127 @@ describe('Navigation', function() { assert.deepEqual(result, expected); }); }); + describe('buildVirtualViewUrl', () => { + it('returns valid substituted string without proper pathParams', () => { + const mock = { + url: 'https://mf.luigi-project.io#!', + pathParams: { + otherParam: 'foo' + }, + index: 1 + }; + const expected = 'https://mf.luigi-project.io#!/:virtualSegment_1/'; + + assert.equal( + Navigation.buildVirtualViewUrl(mock.url, mock.pathParams, mock.index), + expected + ); + }); + it('returns valid substituted string with pathParams', () => { + const mock = { + url: 'https://mf.luigi-project.io#!', + pathParams: { + otherParam: 'foo', + virtualSegment_1: 'one', + virtualSegment_2: 'two' + }, + index: 3 + }; + const expected = + 'https://mf.luigi-project.io#!/:virtualSegment_1/:virtualSegment_2/:virtualSegment_3/'; + + assert.equal( + Navigation.buildVirtualViewUrl(mock.url, mock.pathParams, mock.index), + expected + ); + }); + }); + describe('buildVirtualTree', () => { + it('unchanged node if not a virtual tree root', () => { + const given = { + label: 'Luigi' + }; + const expected = Object.assign({}, given); + + Navigation.buildVirtualTree(given); + + assert.deepEqual(given, expected); + }); + it('unchanged if directly accessing a node which is defined as virtual tree root', () => { + const mockNode = { + label: 'Luigi', + virtualTree: true, + viewUrl: 'foo' + }; + const mockNodeNames = []; // no further child segments + + const expected = Object.assign({}, mockNode); + + Navigation.buildVirtualTree(mockNode, mockNodeNames); + + assert.deepEqual(mockNode, expected); + }); + it('with first virtual tree segment', () => { + const mockNode = { + label: 'Luigi', + virtualTree: true, + viewUrl: 'http://mf.luigi-project.io' + }; + const mockNodeNames = ['foo']; + + const expected = Object.assign({}, mockNode, { + keepSelectedForChildren: true, + children: [ + { + _virtualTree: true, + _virtualPathIndex: 1, + label: ':virtualSegment_1', + pathSegment: ':virtualSegment_1', + viewUrl: 'http://mf.luigi-project.io/:virtualSegment_1/', + _virtualViewUrl: 'http://mf.luigi-project.io' + } + ] + }); + + Navigation.buildVirtualTree(mockNode, mockNodeNames); + + assert.deepEqual(expected, mockNode); + }); + it('with a deep nested virtual tree segment', () => { + const mockNode = { + _virtualTree: true, + _virtualPathIndex: 3, + label: ':virtualSegment_3', + pathSegment: ':virtualSegment_3', + viewUrl: + 'http://mf.luigi-project.io/:virtualSegment_2/:virtualSegment_3/', + _virtualViewUrl: 'http://mf.luigi-project.io' + }; + const mockNodeNames = ['foo']; + const pathParams = { + otherParam: 'foo', + virtualSegment_1: 'one', + virtualSegment_2: 'two', + virtualSegment_3: 'three' + }; + + const expected = Object.assign({}, mockNode, { + children: [ + { + _virtualTree: true, + _virtualPathIndex: 4, + label: ':virtualSegment_4', + pathSegment: ':virtualSegment_4', + viewUrl: + 'http://mf.luigi-project.io/:virtualSegment_1/:virtualSegment_2/:virtualSegment_3/:virtualSegment_4/', + _virtualViewUrl: 'http://mf.luigi-project.io' + } + ] + }); + + Navigation.buildVirtualTree(mockNode, mockNodeNames, pathParams); + + assert.deepEqual(expected, mockNode); + }); + }); }); diff --git a/core/test/utilities/helpers/generic-helpers.spec.js b/core/test/utilities/helpers/generic-helpers.spec.js index f3a70cd762..ff984aab2e 100644 --- a/core/test/utilities/helpers/generic-helpers.spec.js +++ b/core/test/utilities/helpers/generic-helpers.spec.js @@ -77,4 +77,19 @@ describe('Generic-helpers', () => { }; assert.deepEqual(GenericHelpers.removeInternalProperties(input), expected); }); + it('removeProperties', () => { + const input = { + some: true, + value: true, + _internal: true, + _somefn: () => true, + internalOne: true, + internalTwo: true + }; + const keys = ['_*', 'value', 'internal*']; + const expected = { + some: true + }; + assert.deepEqual(GenericHelpers.removeProperties(input, keys), expected); + }); }); diff --git a/docs/navigation-parameters-reference.md b/docs/navigation-parameters-reference.md index 25fe7dd5fc..bf94b0acc9 100644 --- a/docs/navigation-parameters-reference.md +++ b/docs/navigation-parameters-reference.md @@ -255,6 +255,21 @@ settings: { - **type**: boolean or "exclusive" - **description**: when set to `true`, the node is always accessible. When set to `exclusive`, the node is only visible in logged-out state. Requires **auth.disableAutoLogin** to be set to `true`. **anonymousAccess** needs to be defined both on parent and child nodes. +### virtualTree +- **type**: boolean +- **description**: marks the node as the beginning of a virtual tree. Allows navigation to any of its children's paths without the need of specifying nested children. The path that comes after the node marked as **virtualTree** is appended to its **viewUrl**. [**keepSelectedForChildren**](#keepSelectedForChildren) is automatically applied. +- **example**: + In this example, navigating to `core.tld/settings/some/nested/view` will result in opening `/sampleapp.html#/settings/some/nested/view`. + ```javascript + { + pathSegment: 'settings', + label: 'Settings', + viewUrl: '/sampleapp.html#/settings', + navigationContext: 'settings', + virtualTree: true + } + ``` + ## Context switcher The context switcher is a drop-down list available in the top navigation bar. It allows you to switch between a curated list of navigation elements such as Environments. To do so, add the **contextSwitcher** parameter to the **navigation** object using the following optional parameters: