Repository: SAP/luigi (js)

Issue:
Add navigateToIntent to wc client api
The LuigiClient [navigateToIntent](https://github.com/SAP/luigi/blob/main/client/src/linkManager.js#L139) function for the webcomponent client api is missing.
Should be added to the linkManager client api for webcomponents.

Add it to container client api as well.

Pull request description:
Adds navigateToIntent to wc client api
**Description**

Changes proposed in this pull request:

- adds 'navigateToIntent' to wc client api

**Related issue(s)**

Resolves #3768 


Patch:
diff --git a/client/src/linkManager.js b/client/src/linkManager.js
index 2412c00f..1611ee8d 100644
--- a/client/src/linkManager.js
+++ b/client/src/linkManager.js
@@ -139,7 +139,7 @@ export class linkManager extends LuigiClientBase {
   navigateToIntent(semanticSlug, params = {}) {
     let newPath = '#?intent=';
     newPath += semanticSlug;
-    if (params) {
+    if (params && Object.keys(params)?.length) {
       const paramList = Object.entries(params);
       // append parameters to the path if any
       if (paramList.length > 0) {
diff --git a/container/src/services/webcomponents.service.ts b/container/src/services/webcomponents.service.ts
index 71ed4de3..f7c11f74 100644
--- a/container/src/services/webcomponents.service.ts
+++ b/container/src/services/webcomponents.service.ts
@@ -110,6 +110,29 @@ export class WebComponentService {
               ...options
             });
           },
+          navigateToIntent: (semanticSlug: string, params = {}): void => {
+            let newPath = '#?intent=';
+
+            newPath += semanticSlug;
+
+            if (params && Object.keys(params)?.length) {
+              const paramList = Object.entries(params);
+
+              // append parameters to the path if any
+              if (paramList.length > 0) {
+                newPath += '?';
+
+                for (const [key, value] of paramList) {
+                  newPath += key + '=' + value + '&';
+                }
+
+                // trim potential excessive ampersand & at the end
+                newPath = newPath.slice(0, -1);
+              }
+            }
+
+            linkManagerInstance.navigate(newPath);
+          },
           fromClosestContext: () => {
             fromClosestContext = true;
             return linkManagerInstance;
diff --git a/core/src/core-api/_internalLinkManager.js b/core/src/core-api/_internalLinkManager.js
index 5150df09..61db1f44 100644
--- a/core/src/core-api/_internalLinkManager.js
+++ b/core/src/core-api/_internalLinkManager.js
@@ -54,6 +54,44 @@ export class linkManager extends LuigiCoreAPIBase {
     return remotePromise;
   }
 
+  /**
+   * Offers an alternative way of navigating with intents. This involves specifying a semanticSlug and an object containing
+   * parameters.
+   * This method internally generates a URL of the form `#?intent=<semantic object>-<action>?<param_name>=<param_value>` through the given
+   * input arguments. This then follows a call to the original `linkManager.navigate(...)` function.
+   * Consequently, the following calls shall have the exact same effect:
+   * - linkManager().navigateToIntent('Sales-settings', {project: 'pr2', user: 'john'})
+   * - linkManager().navigate('/#?intent=Sales-settings?project=pr2&user=john')
+   * @param {string} semanticSlug concatenation of semantic object and action connected with a dash (-), i.e.: `<semanticObject>-<action>`
+   * @param {Object} params an object representing all the parameters passed, i.e.: `{param1: '1', param2: 2, param3: 'value3'}`.
+   * @example
+   * LuigiClient.linkManager().navigateToIntent('Sales-settings', {project: 'pr2', user: 'john'})
+   * LuigiClient.linkManager().navigateToIntent('Sales-settings')
+   */
+  navigateToIntent(semanticSlug, params = {}) {
+    let newPath = '#?intent=';
+
+    newPath += semanticSlug;
+
+    if (params && Object.keys(params)?.length) {
+      const paramList = Object.entries(params);
+
+      // append parameters to the path if any
+      if (paramList.length > 0) {
+        newPath += '?';
+
+        for (const [key, value] of paramList) {
+          newPath += key + '=' + value + '&';
+        }
+
+        // trim potential excessive ampersand & at the end
+        newPath = newPath.slice(0, -1);
+      }
+    }
+
+    this.navigate(newPath);
+  }
+
   /**
    * This function navigates to a modal after adding the onClosePromise that handles the callback for when the modal is closed.
    * @param {string} path the navigation path to open in the modal
diff --git a/core/src/core-api/navigation.js b/core/src/core-api/navigation.js
index b1834b16..e6122df5 100644
--- a/core/src/core-api/navigation.js
+++ b/core/src/core-api/navigation.js
@@ -48,6 +48,20 @@ class LuigiNavigationManager {
     return new linkManager().navigate(path, preserveView, modalSettings, splitViewSettings, drawerSettings);
   }
 
+  /**
+   * Offers an alternative way of navigating with intents. This involves specifying a semanticSlug and an object containing parameters.
+   * @memberof LuigiNavigation
+   * @param {string} semanticSlug concatenation of semantic object and action connected with a dash (-)
+   * @param {Object} params an object representing all the parameters passed (optional, default '{}')
+   * @since NEXTRELEASE
+   * @example
+   * Luigi.navigation().navigateToIntent('Sales-settings')
+   * Luigi.navigation().navigateToIntent('Sales-settings', {project: 'pr1'})
+   */
+  navigateToIntent(semanticSlug, params) {
+    return new linkManager().navigateToIntent(semanticSlug, params);
+  }
+
   /**
    * Opens a view in a modal. You can specify the modal's title and size. If you do not specify the title, it is the node label. If there is no node label, the title remains empty.  The default size of the modal is `l`, which means 80%. You can also use `m` (60%) and `s` (40%) to set the modal size. Optionally, use it in combination with any of the navigation functions.
    * @memberof LuigiNavigation
diff --git a/docs/luigi-core-api.md b/docs/luigi-core-api.md
index 28018a9d..bf9ec3c8 100644
--- a/docs/luigi-core-api.md
+++ b/docs/luigi-core-api.md
@@ -587,6 +587,22 @@ Luigi.navigation().navigate('users/groups/stakeholders')
 Luigi.navigation().navigate('/settings', null, true) // preserve view
 ```
 
+#### navigateToIntent
+
+Offers an alternative way of navigating with intents. This involves specifying a semanticSlug and an object containing parameters.
+
+##### Parameters
+
+-   `semanticSlug` **[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)** concatenation of semantic object and action connected with a dash (-)
+-   `params` **[Object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)** an object representing all the parameters passed (optional, default '{}')
+
+##### Examples
+
+```javascript
+Luigi.navigation().navigateToIntent('Sales-settings')
+Luigi.navigation().navigateToIntent('Sales-settings', {project: 'pr1'})
+```
+
 #### openAsModal
 
 Opens a view in a modal. You can specify the modal's title and size. If you do not specify the title, it is the node label. If there is no node label, the title remains empty.  The default size of the modal is `l`, which means 80%. You can also use `m` (60%) and `s` (40%) to set the modal size. Optionally, use it in combination with any of the navigation functions.


Test patch:
diff --git a/container/cypress/e2e/test-app/wc/wc-container.cy.js b/container/cypress/e2e/test-app/wc/wc-container.cy.js
index 0273473d..25b00c02 100644
--- a/container/cypress/e2e/test-app/wc/wc-container.cy.js
+++ b/container/cypress/e2e/test-app/wc/wc-container.cy.js
@@ -81,19 +81,19 @@ describe('Web Container Test', () => {
           expect(stub.getCall(0)).to.be.calledWith('LuigiClient.getAnchor()="testanchor"');
         });
     });
-    
+
     it('defer-init flag for webcomponent container', () => {
       // the initialized webcomponent has id="defer-init-flag"
       cy.get('#defer-init-flag').should('not.exist');
       // click button that calls container.init()
       cy.get('#init-button').click();
-  
+
       cy.get('#defer-init-flag').should('exist');
     });
-    
+
     it('LuigiClient API getCurrentRoute for LuigiContainer', () => {
-      const stub = cy.stub();
       cy.on('window:alert', stub);
+
       cy.get(containerSelector)
         .shadow()
         .contains('getCurrentRoute')
@@ -103,6 +103,18 @@ describe('Web Container Test', () => {
         });
     });
 
+    it('LuigiClient API navigateToIntent for LuigiContainer', () => {
+      cy.on('window:alert', stub);
+
+      cy.get('[data-test-id="luigi-client-api-test-01"]')
+        .shadow()
+        .contains('navigateToIntent')
+        .click()
+        .then(() => {
+          expect(stub.getCall(0)).to.be.calledWith('navigated to: #?intent=Sales-settings');
+        });
+    });
+
     it('updateContext', () => {
       cy.on('window:alert', stub);
 
diff --git a/container/test-app/wc/helloWorldWC.js b/container/test-app/wc/helloWorldWC.js
index b07515ce..977268b9 100644
--- a/container/test-app/wc/helloWorldWC.js
+++ b/container/test-app/wc/helloWorldWC.js
@@ -85,6 +85,9 @@ export default class extends HTMLElement {
     hasBack(), updateTopNavigation(), goBack(), pathExists()
     </button>`;
 
+    const navigateToIntentBtn = document.createElement('template');
+    navigateToIntentBtn.innerHTML = '<button id="navigateToIntent">navigateToIntent</button>';
+
     this._shadowRoot = this.attachShadow({
       mode: 'open',
       delegatesFocus: false
@@ -108,6 +111,7 @@ export default class extends HTMLElement {
     this._shadowRoot.appendChild(linkManagerUpdateTopPathExistsBackBtn.content.cloneNode(true));
     this._shadowRoot.appendChild(setViewGroupDataBtn.content.cloneNode(true));
     this._shadowRoot.appendChild(getCurrentRouteBtn.content.cloneNode(true));
+    this._shadowRoot.appendChild(navigateToIntentBtn.content.cloneNode(true));
 
     this._shadowRoot.appendChild(empty.content.cloneNode(true));
 
@@ -295,6 +299,14 @@ export default class extends HTMLElement {
           alert('current route: ' + result);
         });
     });
+
+    this.$navigateToIntent = this._shadowRoot.querySelector('#navigateToIntent');
+    this.$navigateToIntent.addEventListener('click', () => {
+      if (this.LuigiClient) {
+        this.LuigiClient.linkManager().navigateToIntent('Sales-settings');
+        alert('navigated to: #?intent=Sales-settings');
+      }
+    });
   }
 
   get context() {
diff --git a/container/test/services/webcomponents.service.spec.ts b/container/test/services/webcomponents.service.spec.ts
index 93377972..1e4ec08a 100644
--- a/container/test/services/webcomponents.service.spec.ts
+++ b/container/test/services/webcomponents.service.spec.ts
@@ -162,6 +162,49 @@ describe('createClientAPI', () => {
       expect(dispatchEventSpy).toHaveBeenCalledWith(Events.NAVIGATION_REQUEST, expectedPayload);
     });
 
+    it.each([
+      { slug: null, params: null },
+      { slug: 'Sales-settings', params: null },
+      { slug: null, params: { project: 'pr2', user: 'john' } },
+      { slug: 'Sales-settings', params: { project: 'pr2', user: 'john' } }
+    ])('test linkManager navigateToIntent', (data) => {
+      let payloadLink = `#?intent=${data.slug}`;
+
+      if (data.params && Object.keys(data.params)?.length) {
+        const paramList = Object.entries(data.params);
+
+        if (paramList.length > 0) {
+          payloadLink += '?';
+
+          for (const [key, value] of paramList) {
+            payloadLink += key + '=' + value + '&';
+          }
+
+          payloadLink = payloadLink.slice(0, -1);
+        }
+      }
+
+      // mock and spy on functions
+      service.containerService.dispatch = jest.fn();
+      const dispatchEventSpy = jest.spyOn(service, 'dispatchLuigiEvent');
+
+      // act
+      const clientAPI = service.createClientAPI(undefined, 'nodeId', 'wc_id', 'component');
+      clientAPI.linkManager().navigateToIntent(data.slug, data.params);
+
+      // assert
+      const expectedPayload = {
+        fromClosestContext: false,
+        fromParent: false,
+        fromContext: null,
+        fromVirtualTreeRoot: false,
+        link: payloadLink,
+        nodeParams: {}
+      };
+
+      expect(dispatchEventSpy).toHaveBeenCalledWith(Events.NAVIGATION_REQUEST, expectedPayload);
+    });
+
     it('test linkManager: openAsDrawer', () => {
       const route = '/test/route';
 
diff --git a/core/test/core-api/internal-link-manager.spec.js b/core/test/core-api/internal-link-manager.spec.js
index 0e555c7a..26cddbcf 100644
--- a/core/test/core-api/internal-link-manager.spec.js
+++ b/core/test/core-api/internal-link-manager.spec.js
@@ -1,9 +1,7 @@
+import { linkManager } from '../../src/core-api/_internalLinkManager';
 import { GenericHelpers } from '../../src/utilities/helpers';
 
 const sinon = require('sinon');
-
-import { linkManager } from '../../src/core-api/_internalLinkManager';
-
 let lm;
 
 describe('linkManager', function() {
@@ -112,6 +110,63 @@ describe('linkManager', function() {
     });
   });
 
+  describe('navigateToIntent', () => {
+    beforeEach(() => {
+      sinon.stub(lm, 'sendPostMessageToLuigiCore');
+      console.warn = sinon.spy();
+    });
+
+    it.each([
+      { slug: null, params: null },
+      { slug: 'Sales-settings', params: null },
+      { slug: null, params: { project: 'pr2', user: 'john' } },
+      { slug: 'Sales-settings', params: { project: 'pr2', user: 'john' } }
+    ])('should call sendPostMessageToLuigiCore', (data) => {
+      const options = {
+        preserveView: false,
+        nodeParams: {},
+        errorSkipNavigation: false,
+        fromContext: null,
+        fromClosestContext: false,
+        relative: false,
+        link: ''
+      };
+      const modalSettings = { modalSetting: 'modalValue' };
+      const splitViewSettings = { splitViewSetting: 'splitViewValue' };
+      const drawerSettings = { drawerSetting: 'drawerValue' };
+      const relativePath = !!(data.slug && data.slug[0] !== '/');
+      let payloadLink = `#?intent=${data.slug}`;
+
+      if (data.params && Object.keys(data.params)?.length) {
+        const paramList = Object.entries(data.params);
+
+        if (paramList.length > 0) {
+          payloadLink += '?';
+
+          for (const [key, value] of paramList) {
+            payloadLink += key + '=' + value + '&';
+          }
+
+          payloadLink = payloadLink.slice(0, -1);
+        }
+      }
+
+      const navigationOpenMsg = {
+        msg: 'luigi.navigation.open',
+        params: Object.assign(options, {
+          link: payloadLink,
+          relative: relativePath,
+          modal: modalSettings,
+          splitView: splitViewSettings,
+          drawer: drawerSettings
+        })
+      };
+
+      lm.navigateToIntent(data.slug, data.params);
+      lm.sendPostMessageToLuigiCore.calledOnceWithExactly(navigationOpenMsg);
+    });
+  });
+
   describe('openAsModal', () => {
     beforeEach(() => {
       sinon.stub(lm, 'navigate');
diff --git a/test/e2e-test-application/cypress/e2e/tests/0-js-test-app/js-test-app-navigation.cy.js b/test/e2e-test-application/cypress/e2e/tests/0-js-test-app/js-test-app-navigation.cy.js
index 947bb408..98364512 100644
--- a/test/e2e-test-application/cypress/e2e/tests/0-js-test-app/js-test-app-navigation.cy.js
+++ b/test/e2e-test-application/cypress/e2e/tests/0-js-test-app/js-test-app-navigation.cy.js
@@ -135,6 +135,17 @@ describe('JS-TEST-APP', () => {
         });
       });
 
+      it('navigateToIntent', () => {
+        cy.visitTestApp('/', newConfig);
+        cy.get('#app[configversion="normal-navigation"]');
+        cy.window().then(win => {
+          win.Luigi.navigation().navigate('/home').then(() => {
+            win.Luigi.navigation().navigateToIntent('Sales-setting');
+            cy.expectPathToBe('/home/two/#?intent=Sales-setting');
+          });
+        });
+      });
+
       it('hideShellbar', () => {
         cy.visitTestApp('/', newConfig);
         cy.get('#app[configversion="normal-navigation"]');