diff --git a/client/src/linkManager.js b/client/src/linkManager.js index 2412c00f8a..1611ee8dbf 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/cypress/e2e/test-app/wc/wc-container.cy.js b/container/cypress/e2e/test-app/wc/wc-container.cy.js index 0273473d3c..25b00c020a 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/src/services/webcomponents.service.ts b/container/src/services/webcomponents.service.ts index 71ed4de32a..f7c11f743e 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/container/test-app/wc/helloWorldWC.js b/container/test-app/wc/helloWorldWC.js index b07515ce69..977268b92d 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() `; + const navigateToIntentBtn = document.createElement('template'); + navigateToIntentBtn.innerHTML = ''; + 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 933779726d..1e4ec08ae9 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/src/core-api/_internalLinkManager.js b/core/src/core-api/_internalLinkManager.js index 5150df09e1..61db1f44df 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=-?=` 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.: `-` + * @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 b1834b1688..e6122df5e0 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/core/test/core-api/internal-link-manager.spec.js b/core/test/core-api/internal-link-manager.spec.js index 0e555c7a31..26cddbcf34 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/docs/luigi-core-api.md b/docs/luigi-core-api.md index 28018a9d14..bf9ec3c8c8 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. 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 947bb408f2..98364512b1 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"]');