From 659e1e7e5442e75c9293b89961b7d438c560c519 Mon Sep 17 00:00:00 2001 From: Ndricim Rrapi Date: Wed, 28 Oct 2020 13:15:22 +0100 Subject: [PATCH] Implement intent based navigation (#1634) --- client/luigi-client.d.ts | 1 + client/src/linkManager.js | 3 + core/src/App.html | 3 + core/src/services/routing.js | 24 ++- core/src/utilities/helpers/routing-helpers.js | 132 ++++++++++++- core/test/services/routing.spec.js | 173 ++++++++++++++++++ docs/advanced-scenarios.md | 44 +++++ docs/luigi-client-api.md | 1 + docs/navigation-parameters-reference.md | 9 + ...luigi-client-link-manager-features.spec.js | 11 ++ .../src/app/project/project.component.html | 17 ++ .../src/luigi-config/extended/navigation.js | 7 + 12 files changed, 422 insertions(+), 3 deletions(-) diff --git a/client/luigi-client.d.ts b/client/luigi-client.d.ts index e3dfd749b1..e6a8777956 100644 --- a/client/luigi-client.d.ts +++ b/client/luigi-client.d.ts @@ -287,6 +287,7 @@ export declare interface LinkManager { * LuigiClient.linkManager().navigate('/overview') * LuigiClient.linkManager().navigate('users/groups/stakeholders') * LuigiClient.linkManager().navigate('/settings', null, true) // preserve view + * LuigiClient.linkManager().navigate('#?intent=Sales-order?id=13') // intent navigation */ navigate: ( path: string, diff --git a/client/src/linkManager.js b/client/src/linkManager.js index bd0d332fc5..829122650f 100644 --- a/client/src/linkManager.js +++ b/client/src/linkManager.js @@ -47,6 +47,7 @@ export class linkManager extends LuigiClientBase { * LuigiClient.linkManager().navigate('/overview') * LuigiClient.linkManager().navigate('users/groups/stakeholders') * LuigiClient.linkManager().navigate('/settings', null, true) // preserve view + * LuigiClient.linkManager().navigate('#?Intent=Sales-order?id=13') // intent navigation */ navigate(path, sessionId, preserveView, modalSettings, splitViewSettings) { if (this.options.errorSkipNavigation) { @@ -61,12 +62,14 @@ export class linkManager extends LuigiClientBase { this.options.preserveView = preserveView; const relativePath = path[0] !== '/'; + const hasIntent = path.toLowerCase().includes('?intent='); const navigationOpenMsg = { msg: 'luigi.navigation.open', sessionId: sessionId, params: Object.assign(this.options, { link: path, relative: relativePath, + intent: hasIntent, modal: modalSettings, splitView: splitViewSettings }) diff --git a/core/src/App.html b/core/src/App.html index 1649ff73f4..3683e56cea 100644 --- a/core/src/App.html +++ b/core/src/App.html @@ -462,6 +462,9 @@ n => navigationContext === n.navigationContext ); path = Routing.concatenatePath(getSubPath(node), params.link); + } else if (params.intent) { + const intentPath = RoutingHelpers.getIntentPath(params.link); + path = intentPath ? intentPath : path; } else if (params.relative) { // relative path = Routing.concatenatePath(getSubPath(currentNode), params.link); diff --git a/core/src/services/routing.js b/core/src/services/routing.js index 48bf3f4847..1c51ccc5e9 100644 --- a/core/src/services/routing.js +++ b/core/src/services/routing.js @@ -114,14 +114,28 @@ class RoutingClass { } getHashPath(url = window.location.hash) { + // check for intent, if any + if (url && /\?intent=/i.test(url)) { + const hash = url.replace('#/#', '#'); // handle default hash and intent specific hash + const intentHash = RoutingHelpers.getIntentPath(hash.split('#')[1]); + if (intentHash) { + return intentHash; + } + } + return url.split('#/')[1]; } getModifiedPathname() { + // check for intent, if any + if (window.location.hash && /\?intent=/i.test(window.location.hash)) { + const hash = window.location.hash.replace('#/#', '').replace('#', ''); + const intentPath = RoutingHelpers.getIntentPath(hash); + return intentPath ? intentPath : '/'; + } const path = (window.history.state && window.history.state.path) || window.location.pathname; - return path .split('/') .slice(1) @@ -129,6 +143,14 @@ class RoutingClass { } getCurrentPath() { + if (/\?intent=/i.test(window.location.hash)) { + const hash = window.location.hash.replace('#/#', '').replace('#', ''); + const intentPath = RoutingHelpers.getIntentPath(hash); + if (intentPath) { + // if intent faulty or illegal then skip + return intentPath; + } + } return LuigiConfig.getConfigValue('routing.useHashRouting') ? window.location.hash.replace('#', '') // TODO: GenericHelpers.getPathWithoutHash(window.location.hash) fails in ContextSwitcher : window.location.search diff --git a/core/src/utilities/helpers/routing-helpers.js b/core/src/utilities/helpers/routing-helpers.js index 49f1a28877..68a5470aa3 100644 --- a/core/src/utilities/helpers/routing-helpers.js +++ b/core/src/utilities/helpers/routing-helpers.js @@ -172,8 +172,11 @@ class RoutingHelpersClass { getNodeHref(node, pathParams) { if (LuigiConfig.getConfigBooleanValue('navigation.addNavHrefs')) { - const link = RoutingHelpers.getRouteLink(node, pathParams, - LuigiConfig.getConfigValue('routing.useHashRouting')?"#":''); + const link = RoutingHelpers.getRouteLink( + node, + pathParams, + LuigiConfig.getConfigValue('routing.useHashRouting') ? '#' : '' + ); return link.url || link; } return 'javascript:void(0)'; @@ -264,6 +267,131 @@ class RoutingHelpersClass { featureToggleList.forEach(ft => LuigiFeatureToggles.setFeatureToggle(ft)); } } + + /** + * This function takes an intentLink and parses it conforming certain limitations in characters usage. + * Limitations include: + * - `semanticObject` allows only alphanumeric characters + * - `action` allows alphanumeric characters and the '_' sign + * + * Example of resulting output: + * ``` + * { + * semanticObject: "Sales", + * action: "order", + * params: [{param1: "value1"},{param2: "value2"}] + * }; + * ``` + * @param {string} link the intent link represents the semantic intent defined by the user + * i.e.: #?intent=semanticObject-action?param=value + */ + getIntentObject(intentLink) { + const intentParams = intentLink.split('?intent=')[1]; + if (intentParams) { + const elements = intentParams.split('-'); + if (elements.length === 2) { + // avoids usage of '-' in semantic object and action + const semanticObject = elements[0]; + const actionAndParams = elements[1].split('?'); + // length 2 involves parameters, length 1 involves no parameters + if (actionAndParams.length === 2 || actionAndParams.length === 1) { + let action = actionAndParams[0]; + let params = actionAndParams[1]; + // parse parameters, if any + if (params) { + params = params.split('&'); + let paramObjects = []; + params.forEach(item => { + const param = item.split('='); + param.length === 2 && paramObjects.push({ [param[0]]: param[1] }); + }); + params = paramObjects; + } + const alphanumeric = /^[0-9a-zA-Z]+$/; + const alphanumericOrUnderscores = /^[0-9a-zA-Z_]+$/; + // TODO: check for character size limit + if ( + semanticObject.match(alphanumeric) && + action.match(alphanumericOrUnderscores) + ) { + return { + semanticObject, + action, + params + }; + } else { + console.warn( + 'Intent found contains illegal characters. Semantic object must be alphanumeric, action must be (alphanumeric+underscore)' + ); + } + } + } + } + return false; + } + + /** + * This function compares the intentLink parameter with the configuration intentMapping + * and returns the path segment that is matched together with the parameters, if any + * + * Example: + * + * For intentLink = `#?intent=Sales-order?foo=bar` + * and Luigi configuration: + * ``` + * intentMapping: [{ + * semanticObject: 'Sales', + * action: 'order', + * pathSegment: '/projects/pr2/order' + * }] + * ``` + * the given intentLink is matched with the configuration's same semanticObject and action, + * resulting in pathSegment `/projects/pr2/order` being returned. The parameter is also added in + * this case resulting in: `/projects/pr2/order?~foo=bar` + * @param {string} intentLink the intentLink represents the semantic intent defined by the user + * i.e.: #?intent=semanticObject-action?param=value + */ + getIntentPath(intentLink) { + const mappings = LuigiConfig.getConfigValue('navigation.intentMapping'); + if (mappings && mappings.length > 0) { + const caseInsensitiveLink = intentLink.replace(/\?intent=/i, '?intent='); + const intentObject = this.getIntentObject(caseInsensitiveLink); + if (intentObject) { + let realPath = mappings.find( + item => + item.semanticObject === intentObject.semanticObject && + item.action === intentObject.action + ); + if (!realPath) { + return false; + } + realPath = realPath.pathSegment; + if (intentObject.params) { + // get custom node param prefixes if any or default to ~ + let nodeParamPrefix = LuigiConfig.getConfigValue( + 'routing.nodeParamPrefix' + ); + nodeParamPrefix = nodeParamPrefix ? nodeParamPrefix : '~'; + realPath = realPath.concat(`?${nodeParamPrefix}`); + intentObject.params.forEach(param => { + realPath = realPath.concat(Object.keys(param)[0]); // append param name + realPath = realPath.concat('='); + // append param value and prefix in case of multiple params + realPath = realPath + .concat(param[Object.keys(param)[0]]) + .concat(`&${nodeParamPrefix}`); + }); + realPath = realPath.slice(0, -(nodeParamPrefix.length + 1)); // slice extra prefix + } + return realPath; + } else { + console.warn('Could not parse given intent link.'); + } + } else { + console.warn('No intent mappings are defined in Luigi configuration.'); + } + return false; + } } export const RoutingHelpers = new RoutingHelpersClass(); diff --git a/core/test/services/routing.spec.js b/core/test/services/routing.spec.js index 0af4642247..6156d0feba 100644 --- a/core/test/services/routing.spec.js +++ b/core/test/services/routing.spec.js @@ -116,7 +116,117 @@ describe('Routing', function() { }); }); + describe('getIntentObject()', () => { + beforeEach(() => { + LuigiConfig.getConfigValue.restore(); + sinon + .stub(LuigiConfig, 'getConfigValue') + .withArgs('navigation.intentMapping') + .returns([ + { + semanticObject: 'Sales', + action: 'settings', + pathSegment: '/projects/pr2/settings' + } + ]); + }); + + it('returns intentObject from provided intent link with params', () => { + const actual = RoutingHelpers.getIntentObject( + '#?intent=Sales-settings?param1=luigi¶m2=mario' + ); + const expected = { + semanticObject: 'Sales', + action: 'settings', + params: [{ param1: 'luigi' }, { param2: 'mario' }] + }; + assert.deepEqual(actual, expected); + }); + + it('returns intentObject from provided intent link without params', () => { + const actual = RoutingHelpers.getIntentObject('#?intent=Sales-settings'); + const expected = { + semanticObject: 'Sales', + action: 'settings', + params: undefined + }; + assert.deepEqual(actual, expected); + }); + + it('falsy intentObject from provided intent link with illegal characters', () => { + const actual = RoutingHelpers.getIntentObject( + '#?intent=Sales-$et$$tings' + ); + assert.isNotOk(actual); + }); + }); + + describe('getIntentPath()', () => { + beforeEach(() => { + LuigiConfig.getConfigValue.restore(); + sinon + .stub(LuigiConfig, 'getConfigValue') + .withArgs('navigation.intentMapping') + .returns([ + { + semanticObject: 'Sales', + action: 'settings', + pathSegment: '/projects/pr2/settings' + } + ]); + }); + + it('checks intent path parsing with illegal characters', () => { + const actual = RoutingHelpers.getIntentPath( + '#?intent=Sa#les-sett!@ings?param1=luigi¶m2=mario' + ); + assert.isNotOk(actual); + }); + + it('checks intent path parsing with illegal hyphen character', () => { + const actual = RoutingHelpers.getIntentPath( + '#?intent=Sa-les-sett-ings?param1=luigi¶m2=mario' + ); + assert.isNotOk(actual); + }); + + it('returns path from provided intent link without params', () => { + const actual = RoutingHelpers.getIntentPath('#?intent=Sales-settings'); + const expected = '/projects/pr2/settings'; + assert.equal(actual, expected); + }); + + it('returns path from provided intent link with params', () => { + const actual = RoutingHelpers.getIntentPath( + '#?intent=Sales-settings?param1=hello¶m2=world' + ); + const expected = '/projects/pr2/settings?~param1=hello&~param2=world'; + assert.equal(actual, expected); + }); + + it('returns path from intent link with params and case insensitive start pattern ', () => { + const actual = RoutingHelpers.getIntentPath( + '#?iNteNT=Sales-settings?param1=hello¶m2=world' + ); + const expected = '/projects/pr2/settings?~param1=hello&~param2=world'; + assert.equal(actual, expected); + }); + }); + describe('getHashPath()', () => { + beforeEach(() => { + LuigiConfig.getConfigValue.restore(); + sinon + .stub(LuigiConfig, 'getConfigValue') + .withArgs('navigation.intentMapping') + .returns([ + { + semanticObject: 'Sales', + action: 'settings', + pathSegment: '/projects/pr2/settings' + } + ]); + }); it('returns hash path from default param', () => { window.location.hash = '#/projects/pr3'; const actual = Routing.getHashPath(); @@ -129,6 +239,26 @@ describe('Routing', function() { const expected = 'projects/pr3'; assert.equal(actual, expected); }); + + it('returns path from provided intent link with params', () => { + const actual = Routing.getHashPath( + '#?intent=Sales-settings?param1=luigi¶m2=mario' + ); + const expected = '/projects/pr2/settings?~param1=luigi&~param2=mario'; + assert.equal(actual, expected); + }); + + it('returns path from provided intent link without params', () => { + const actual = Routing.getHashPath('#?intent=Sales-settings'); + const expected = '/projects/pr2/settings'; + assert.equal(actual, expected); + }); + + it('returns path from provided intent link with case insensitive starting pattern', () => { + const actual = Routing.getHashPath('#?iNteNT=Sales-settings'); + const expected = '/projects/pr2/settings'; + assert.equal(actual, expected); + }); }); describe('buildFromRelativePath', () => { @@ -566,6 +696,20 @@ describe('Routing', function() { }); describe('getModifiedPathname()', () => { + beforeEach(() => { + LuigiConfig.getConfigValue.restore(); + sinon + .stub(LuigiConfig, 'getConfigValue') + .withArgs('navigation.intentMapping') + .returns([ + { + semanticObject: 'Sales', + action: 'settings', + pathSegment: '/projects/pr2/settings' + } + ]); + }); + it('without state, falls back to location', () => { const mockPathName = 'projects'; sinon.stub(window.history, 'state').returns(null); @@ -581,6 +725,35 @@ describe('Routing', function() { }); assert.equal(Routing.getModifiedPathname(), 'this/is/some/'); }); + + it('from intent based link with params', () => { + window.location.hash = + '#?intent=Sales-settings?param1=luigi¶m2=mario'; + assert.equal( + Routing.getModifiedPathname(), + '/projects/pr2/settings?~param1=luigi&~param2=mario' + ); + }); + + it('from intent based link without params', () => { + window.location.hash = '#?intent=Sales-settings'; + assert.equal(Routing.getModifiedPathname(), '/projects/pr2/settings'); + }); + + it('from intent based link with case insensitive pattern', () => { + window.location.hash = '#?inTeNT=Sales-settings'; + assert.equal(Routing.getModifiedPathname(), '/projects/pr2/settings'); + }); + + it('from faulty intent based link', () => { + window.location.hash = '#?intent=Sales-sett-ings'; + assert.equal(Routing.getModifiedPathname(), '/'); + }); + + it('from intent based link with illegal characters', () => { + window.location.hash = '#?intent=Sales-sett$ings'; + assert.equal(Routing.getModifiedPathname(), '/'); + }); }); describe('navigateToLink()', () => { diff --git a/docs/advanced-scenarios.md b/docs/advanced-scenarios.md index 608a087bb4..bb7f35cd4c 100644 --- a/docs/advanced-scenarios.md +++ b/docs/advanced-scenarios.md @@ -205,4 +205,48 @@ Luigi allows you to implement and configure feature toggles. They can be used to } ``` +### Use Intent-Based Navigation in Luigi Client + +#### Overview +Luigi Client allows you to navigate through micro frontends by using an intent-based navigation. This type of navigation decouples navigation triggers from the actual navigation targets. Rather than directly encoding the name of the target app into the URL fragment, app developers provide a navigation intent such as `display` or `edit` as shown in the examples below. + +#### Usage +* To **enable** intent-based navigation, you need to first identify the necessary target mappings. This can be done by defining `intentMapping` in the Luigi configuration under `navigation` as in the example below: + ```javascript + intentMapping = [ + { + semanticObject: 'Sales', + action: 'display', + pathSegment: '/projects/sap/munich/database/sales/display' + }, + { + semanticObject: 'Sales', + action: 'edit', + pathSegment: '/projects/sap/munich/database/sales/edit' + } + ]; + ``` + 1. The intent link is built using the `semanticObject`, `action` and optional parameters in the following format: + `#?intent=semanticObject-action?params`. + An example of an intent link would be as follows: + ```javascript + #?intent=Sales-edit?id=100 + ``` + 2. Navigation to a micro frontend through this intent is then made possible by using the [linkManager navigate method](luigi-client-api.md#navigate) from Luigi Client API: + ```javascript + LuigiClient.linkManager().navigate('#?intent=Sales-edit?id=100'); + ``` + + 3. This method would then be navigating to the translated real path segment: + ```javascript + https://example.com/projects/sap/munich/database/sales/edit?~id=100; + ``` + + 4. Alternatively, the intent link can also be accessed through the browser URL and accessed from outside: + ```javascript + https://example.com/#?intent=Sales-edit?id=100; + ``` + + + diff --git a/docs/luigi-client-api.md b/docs/luigi-client-api.md index b60fd7484b..f021c66f11 100644 --- a/docs/luigi-client-api.md +++ b/docs/luigi-client-api.md @@ -364,6 +364,7 @@ Navigates to the given path in the application hosted by Luigi. It contains eith LuigiClient.linkManager().navigate('/overview') LuigiClient.linkManager().navigate('users/groups/stakeholders') LuigiClient.linkManager().navigate('/settings', null, true) // preserve view +LuigiClient.linkManager().navigate('#?Intent=Sales-order?id=13') // intent navigation ``` #### openAsModal diff --git a/docs/navigation-parameters-reference.md b/docs/navigation-parameters-reference.md index 2aba18f863..5171654068 100644 --- a/docs/navigation-parameters-reference.md +++ b/docs/navigation-parameters-reference.md @@ -105,6 +105,15 @@ The navigation parameters allow you to configure **global** navigation settings - **preloadUrl**(string): needs to be an absolute URL of a micro frontend belonging to a view group. It cannot be an URL of a node. It is recommended that you use a dedicated small, visually empty view, which imports Luigi Client and is fine with getting an empty context, for example, without an access token. The **preloadUrl** parameter is also required for view group caching in case you need a view group iframe to refresh whenever you navigate back to it. + ### intentMapping +- **type**: array +- **description**: contains an array of abstract intent objects that can be used to navigate through micro frontends through the [LuigiClient linkManager.navigate()](luigi-client-api.md#navigate) method. The attributes contained in each intent object of the array are abstract notations which can be used to define the target mapping of your desired intent navigation in a semantic way. +Check our [Advanced Scenarios](advanced-scenarios.md) page for an example. +- **attributes**: + - **semanticObject**(string): may represent a business entity such as a sales order or a product. It enables navigating to such entities in an abstract implementation-independent way. It can only only contain alphanumerical characters. + - **action**(string): defines an operation, i.e.: `display`, `approve` or `edit`. The operation is intended to be performed on a **semanticObject** such as a sales order or a certain product. It can only contain alphanumerical characters but also the underscore character. + - **pathSegment**(string): represents the target of the navigation. In order to use it as a target link, it has to be defined under navigation nodes in the Luigi configuration. + ## Node parameters Node parameters are all the parameters that can be added to an individual navigation node in the `nodes:` section of the Luigi configuration file. diff --git a/test/e2e-test-application/e2e/tests/1-angular/luigi-client-link-manager-features.spec.js b/test/e2e-test-application/e2e/tests/1-angular/luigi-client-link-manager-features.spec.js index 959b07d4b8..e56601501d 100644 --- a/test/e2e-test-application/e2e/tests/1-angular/luigi-client-link-manager-features.spec.js +++ b/test/e2e-test-application/e2e/tests/1-angular/luigi-client-link-manager-features.spec.js @@ -79,6 +79,17 @@ describe('Luigi client linkManager', () => { .click(); cy.expectPathToBe('/projects/pr2'); + //navigate with intent + cy.wrap($iframeBody) + .contains('navigate to settings with intent with parameters') + .click(); + cy.expectPathToBe('/projects/pr2/settings'); + cy.expectSearchToBe('?~param1=abc&~param2=bcd'); + + cy.goToOverviewPage(); + cy.goToLinkManagerMethods($iframeBody); + + cy.goToLinkManagerMethods($iframeBody); //navigate with preserve view functionality cy.wrap($iframeBody) .contains('with preserved view: project to global settings and back') diff --git a/test/e2e-test-application/src/app/project/project.component.html b/test/e2e-test-application/src/app/project/project.component.html index d02906c6c4..3bfbb112c4 100644 --- a/test/e2e-test-application/src/app/project/project.component.html +++ b/test/e2e-test-application/src/app/project/project.component.html @@ -517,6 +517,23 @@

Navigate

> +
  • + + navigate to settings with intent with parameters + + +
  • diff --git a/test/e2e-test-application/src/luigi-config/extended/navigation.js b/test/e2e-test-application/src/luigi-config/extended/navigation.js index 6a550faf6c..a92be64c46 100644 --- a/test/e2e-test-application/src/luigi-config/extended/navigation.js +++ b/test/e2e-test-application/src/luigi-config/extended/navigation.js @@ -22,6 +22,13 @@ class Navigation { preloadUrl: '/sampleapp.html#/preload' } }; + intentMapping = [ + { + semanticObject: 'Sales', + action: 'settings', + pathSegment: '/projects/pr2/settings' + } + ]; nodeAccessibilityResolver = navigationPermissionChecker; nodes = [ {