diff --git a/client/luigi-element.d.ts b/client/luigi-element.d.ts index 31fefc3e89..46b6c0812b 100644 --- a/client/luigi-element.d.ts +++ b/client/luigi-element.d.ts @@ -336,6 +336,15 @@ export declare interface LinkManager { * @returns {boolean} indicating if there is a preserved view you can return to */ hasBack: () => boolean; + + /** + * Gets the luigi route associated with the current micro frontend. + * @returns {promise} a promise which resolves to a String value specifying the current luigi route + * @since NEXTRELEASE + * @example + * LuigiClient.linkManager().getCurrentRoute(); + */ + getCurrentRoute: () => Promise; } export declare class LuigiElement extends HTMLElement { diff --git a/core/src/services/web-components.js b/core/src/services/web-components.js index cf8add39dc..485f04d0e5 100644 --- a/core/src/services/web-components.js +++ b/core/src/services/web-components.js @@ -1,590 +1,604 @@ -import { - DefaultCompoundRenderer, - deSanitizeParamsMap, - registerEventListeners, - resolveRenderer -} from '../utilities/helpers/web-component-helpers'; -import { LuigiConfig } from '../core-api'; -import { RoutingHelpers, GenericHelpers, NavigationHelpers } from '../utilities/helpers'; - -const DEFAULT_TEMPORARY_HEIGHT = '500px'; -const DEFAULT_INTERSECTION_OBSERVER_ROOTMARGIN = '0px'; - -/** Methods for dealing with web components based micro frontend handling */ -class WebComponentSvcClass { - /** - * @typedef {object} WcContainerData - * @property {string} viewUrl - * @property {HTMLElement} wc_container - * @property {object} extendedContext - * @property {object} node - * @property {string} nodeId - * @property {boolean} isSpecialMf indicates whether the web component is rendered in a modal, splitView or drawer (`false` by default) - * @property {boolean} noTemporaryContainerHeight - */ - - /** @type {WeakMap} */ - wcContainerData = new WeakMap(); - - dynamicImport(viewUrl) { - /** __luigi_dyn_import_____________() is replaced by import(\/* webpackIgnore: true *\/) after webpack is done, - * because webpack can't let his hands off imports ;) - * trailing underscores are there to match the replacement char nr to avoid sourcemap mess*/ - return __luigi_dyn_import_____________(viewUrl); - } - - /** Creates a web component with tagname wc_id and adds it to wcItemContainer, - * if attached to wc_container - */ - attachWC(wc_id, wcItemPlaceholder, wc_container, extendedContext, viewUrl, nodeId, isSpecialMf, isLazyLoading) { - if (wc_container && wc_container.contains(wcItemPlaceholder)) { - const wc = document.createElement(wc_id); - - if (nodeId) { - wc.setAttribute('nodeId', nodeId); - } - wc.setAttribute('lui_web_component', true); - this.initWC(wc, wc_id, wc_container, viewUrl, extendedContext, nodeId, isSpecialMf); - - wc_container.replaceChild(wc, wcItemPlaceholder); - - if (isLazyLoading) { - this.removeTemporaryHeightFromCompoundItemContainer(wc_container); - this.wcContainerData.delete(wc_container); - } - } - } - - initWC(wc, wc_id, eventBusElement, viewUrl, extendedContext, nodeId, isSpecialMf) { - const ctx = extendedContext.context; - wc.extendedContext = extendedContext; - - // handle difference modal vs main mf - if (wc.extendedContext.currentNode) { - wc.extendedContext.clientPermissions = wc.extendedContext.currentNode.clientPermissions; - } - const clientAPI = { - linkManager: window.Luigi.navigation, - uxManager: window.Luigi.ux, - getCurrentLocale: () => window.Luigi.i18n().getCurrentLocale(), - publishEvent: ev => { - if (eventBusElement.eventBus) { - eventBusElement.eventBus.onPublishEvent(ev, nodeId, wc_id); - } - }, - getActiveFeatureToggleList: () => window.Luigi.featureToggles().getActiveFeatureToggleList(), - getActiveFeatureToggles: () => window.Luigi.featureToggles().getActiveFeatureToggleList(), - getPathParams: () => (wc.extendedContext?.pathParams ? wc.extendedContext.pathParams : {}), - getCoreSearchParams: () => { - const node = { - clientPermissions: wc.extendedContext.clientPermissions - }; - return RoutingHelpers.prepareSearchParamsForClient(node); - }, - getClientPermissions: () => (wc.extendedContext?.clientPermissions ? wc.extendedContext.clientPermissions : {}), - addNodeParams: (params, keepBrowserHistory) => { - if (!isSpecialMf) { - window.Luigi.routing().addNodeParams(params, keepBrowserHistory); - } - }, - getNodeParams: shouldDesanitise => { - if (isSpecialMf) { - return {}; - } - const result = wc.extendedContext?.nodeParams ? wc.extendedContext.nodeParams : {}; - if (shouldDesanitise) { - return deSanitizeParamsMap(result); - } - return wc.extendedContext.nodeParams; - }, - setAnchor: anchor => { - if (!isSpecialMf) { - window.Luigi.routing().setAnchor(anchor); - } - }, - getAnchor: () => { - return window.Luigi.routing().getAnchor(); - }, - getUserSettings: async () => { - return await this.getUserSettingsForWc(eventBusElement._luigi_node); - }, - setViewGroupData: data => { - const vg = NavigationHelpers.findViewGroup(eventBusElement._luigi_node); - if (vg) { - const vgSettings = NavigationHelpers.getViewGroupSettings(vg); - vgSettings._liveCustomData = data; - LuigiConfig.configChanged('navigation.viewgroupdata'); - } - } - }; - - if (wc.__postProcess) { - const url = - new URL(document.baseURI).origin === new URL(viewUrl, document.baseURI).origin - ? new URL(viewUrl, document.baseURI) - : new URL('./', viewUrl); - wc.__postProcess(ctx, clientAPI, url.origin + url.pathname); - } else { - wc.context = ctx; - wc.nodeParams = extendedContext.nodeParams; - wc.LuigiClient = clientAPI; - } - - const wcCreationInterceptor = LuigiConfig.getConfigValue('settings.webcomponentCreationInterceptor'); - if (GenericHelpers.isFunction(wcCreationInterceptor)) { - wcCreationInterceptor(wc, extendedContext.currentNode, extendedContext, nodeId, isSpecialMf); - } - } - - /** Generates a unique web component id (tagname) based on the viewUrl - * returns a string that can be used as part of a tagname, only alphanumeric - * characters and no whitespaces. - */ - generateWCId(viewUrl) { - let charRep = ''; - let normalizedViewUrl = new URL(viewUrl, encodeURI(location.href)).href; - for (let i = 0; i < normalizedViewUrl.length; i++) { - charRep += normalizedViewUrl.charCodeAt(i).toString(16); - } - return 'luigi-wc-' + charRep; - } - - /** Does a module import from viewUrl and defines a new web component - * with the default export of the module or the first export extending HTMLElement if no default is - * specified. - * @returns a promise that gets resolved after successfull import */ - registerWCFromUrl(viewUrl, wc_id) { - const i18nViewUrl = RoutingHelpers.getI18nViewUrl(viewUrl); - return new Promise((resolve, reject) => { - if (this.checkWCUrl(i18nViewUrl)) { - this.dynamicImport(i18nViewUrl) - .then(module => { - try { - if (!window.customElements.get(wc_id)) { - let cmpClazz = module.default; - if (!HTMLElement.isPrototypeOf(cmpClazz)) { - let props = Object.keys(module); - for (let i = 0; i < props.length; i++) { - cmpClazz = module[props[i]]; - if (HTMLElement.isPrototypeOf(cmpClazz)) { - break; - } - } - } - window.customElements.define(wc_id, cmpClazz); - } - resolve(); - } catch (e) { - reject(e); - } - }) - .catch(err => reject(err)); - } else { - console.warn(`View URL '${i18nViewUrl}' not allowed to be included`); - reject(`View URL '${i18nViewUrl}' not allowed`); - } - }); - } - - /** - * Handles the import of self registered web component bundles, i.e. the web component - * is added to the customElements registry by the bundle code rather than by luigi. - * - * @param {*} node the corresponding navigation node - * @param {*} viewUrl the source of the wc bundle - * @param {*} onload callback function executed after script attached and loaded - */ - includeSelfRegisteredWCFromUrl(node, viewUrl, onload) { - if (this.checkWCUrl(viewUrl)) { - /** Append reg function to luigi object if not present */ - if (!window.Luigi._registerWebcomponent) { - window.Luigi._registerWebcomponent = (srcString, el) => { - const wcId = this.generateWCId(srcString); - if (!window.customElements.get(wcId)) { - window.customElements.define(wcId, el); - } - }; - } - - let scriptTag = document.createElement('script'); - scriptTag.setAttribute('src', viewUrl); - if (node.webcomponent.type === 'module') { - scriptTag.setAttribute('type', 'module'); - } - scriptTag.setAttribute('defer', true); - scriptTag.addEventListener('load', () => { - onload(); - }); - document.body.appendChild(scriptTag); - } else { - console.warn(`View URL '${viewUrl}' not allowed to be included`); - } - } - - /** - * Checks if a url is allowed to be included, based on 'navigation.validWebcomponentUrls' in luigi config. - * Returns true, if allowed. - * - * @param {*} url the url string to check - */ - checkWCUrl(url) { - if (url.indexOf('://') > 0 || url.trim().indexOf('//') === 0) { - const ur = new URL(url); - if (ur.host === window.location.host) { - return true; // same host is okay - } - - const valids = LuigiConfig.getConfigValue('navigation.validWebcomponentUrls'); - if (valids && valids.length > 0) { - for (let el of valids) { - try { - if (new RegExp(el).test(url)) { - return true; - } - } catch (e) { - console.error(e); - } - } - } - return false; - } - // relative URL is okay - return true; - } - - /** Adds a web component defined by viewUrl to the wc_container and sets the node context. - * If the web component is not defined yet, it gets imported. - */ - renderWebComponent(viewUrl, wc_container, extendedContext, node, nodeId, isSpecialMf, isLazyLoading) { - const context = extendedContext.context; - const i18nViewUrl = RoutingHelpers.substituteViewUrl(viewUrl, { context }); - const wc_id = node?.webcomponent?.tagName || this.generateWCId(i18nViewUrl); - const wcItemPlaceholder = document.createElement('div'); - - wc_container.appendChild(wcItemPlaceholder); - wc_container._luigi_node = node; - - if (window.customElements.get(wc_id)) { - this.attachWC( - wc_id, - wcItemPlaceholder, - wc_container, - extendedContext, - i18nViewUrl, - nodeId, - isSpecialMf, - isLazyLoading - ); - } else { - /** Custom import function, if defined */ - if (window.luigiWCFn) { - window.luigiWCFn(i18nViewUrl, wc_id, wcItemPlaceholder, () => { - this.attachWC( - wc_id, - wcItemPlaceholder, - wc_container, - extendedContext, - i18nViewUrl, - nodeId, - isSpecialMf, - isLazyLoading - ); - }); - } else if (node.webcomponent && node.webcomponent.selfRegistered) { - this.includeSelfRegisteredWCFromUrl(node, i18nViewUrl, () => { - this.attachWC( - wc_id, - wcItemPlaceholder, - wc_container, - extendedContext, - i18nViewUrl, - nodeId, - isSpecialMf, - isLazyLoading - ); - }); - } else { - this.registerWCFromUrl(i18nViewUrl, wc_id).then(() => { - this.attachWC( - wc_id, - wcItemPlaceholder, - wc_container, - extendedContext, - i18nViewUrl, - nodeId, - isSpecialMf, - isLazyLoading - ); - }); - } - } - } - - /** - * Creates a compound container according to the given renderer. - * Returns a promise that gets resolved with the created container DOM element. - * - * @param {DefaultCompoundRenderer} renderer - */ - createCompoundContainerAsync(renderer, ctx, navNode) { - return new Promise((resolve, reject) => { - if (renderer.viewUrl) { - try { - const wc_id = navNode?.webcomponent?.tagName || this.generateWCId(renderer.viewUrl); - if (navNode?.webcomponent?.selfRegistered) { - this.includeSelfRegisteredWCFromUrl(navNode, renderer.viewUrl, () => { - const wc = document.createElement(wc_id); - wc.setAttribute('lui_web_component', true); - this.initWC(wc, wc_id, wc, renderer.viewUrl, ctx, '_root'); - resolve(wc); - }); - } else { - this.registerWCFromUrl(renderer.viewUrl, wc_id).then(() => { - const wc = document.createElement(wc_id); - wc.setAttribute('lui_web_component', true); - this.initWC(wc, wc_id, wc, renderer.viewUrl, ctx, '_root'); - resolve(wc); - }); - } - } catch (e) { - reject(e); - } - } else { - resolve(renderer.createCompoundContainer()); - } - }); - } - - /** - * @param {IntersectionObserverEntry[]} entries - * @param {IntersectionObserver} observer - */ - intersectionObserverCallback(entries, observer) { - const intersectingEntries = entries.filter(entry => entry.isIntersecting); - - intersectingEntries.forEach(intersectingEntry => { - const compoundItemContainer = intersectingEntry.target; - const wcContainerData = this.wcContainerData.get(compoundItemContainer); - - if (!!wcContainerData) { - this.renderWebComponent( - wcContainerData.viewUrl, - wcContainerData.wc_container, - wcContainerData.extendedContext, - wcContainerData.node, - wcContainerData.nodeId, - wcContainerData.isSpecialMf, - true - ); - } else { - console.error('Could not find WC container data', { - for: compoundItemContainer - }); - } - observer.unobserve(compoundItemContainer); - }); - } - - /** - * When lazy loading is active, this function sets a temporary height to the given compound item container. - * The temporary height is added because otherwise, when adding the empty containers for all compound items, - * all containers would have a height of 0 because the web components they contain will be added - * asynchronously later. All containers would be visible so that all web components would be added right away. - * In other words, this would break lazy loading. - * @param {HTMLElement} compoundItemContainer - * @param {object} compoundSettings - * @param {object} [compoundSettings.lazyLoadingOptions] - * @param {string} [compoundSettings.lazyLoadingOptions.temporaryContainerHeight] - * @param {boolean} [compoundSettings.lazyLoadingOptions.noTemporaryContainerHeight] - * @param {object} compoundItemSettings - * @param {object} compoundItemSettings.layoutConfig - * @param {string} [compoundItemSettings.layoutConfig.temporaryContainerHeight] - */ - setTemporaryHeightForCompoundItemContainer(compoundItemContainer, compoundSettings, compoundItemSettings) { - if (compoundSettings.lazyLoadingOptions?.noTemporaryContainerHeight === true) { - return; - } - - const temporaryContainerHeight = - compoundItemSettings.layoutConfig?.temporaryContainerHeight || - compoundSettings.lazyLoadingOptions?.temporaryContainerHeight || - DEFAULT_TEMPORARY_HEIGHT; - - compoundItemContainer.style.height = temporaryContainerHeight; - } - - /** - * When lazy loading is active, this function removes the temporary height from the given compound item - * container after the web component is instantiated and attached to the container. - * @param {HTMLElement} compoundItemContainer - */ - removeTemporaryHeightFromCompoundItemContainer(compoundItemContainer) { - const wcContainerData = this.wcContainerData.get(compoundItemContainer); - - if (wcContainerData?.noTemporaryContainerHeight !== true) { - compoundItemContainer.style.removeProperty('height'); - } - } - - /** - * @param {object} navNode - */ - getCompoundRenderer(navNode, context) { - const isNestedWebComponent = navNode.webcomponent && !!navNode.viewUrl; - let renderer; - - if (isNestedWebComponent) { - // Nested web component - renderer = new DefaultCompoundRenderer(); - renderer.viewUrl = RoutingHelpers.substituteViewUrl(navNode.viewUrl, { - context - }); - renderer.createCompoundItemContainer = layoutConfig => { - var cnt = document.createElement('div'); - if (layoutConfig && layoutConfig.slot) { - cnt.setAttribute('slot', layoutConfig.slot); - } - return cnt; - }; - } else if (navNode.compound.renderer) { - renderer = resolveRenderer(navNode.compound.renderer); - } else { - renderer = new DefaultCompoundRenderer(); - } - - return renderer; - } - - createIntersectionObserver(navNode) { - return new IntersectionObserver( - (entries, observer) => { - this.intersectionObserverCallback(entries, observer); - }, - { - rootMargin: - navNode.compound.lazyLoadingOptions?.intersectionRootMargin || DEFAULT_INTERSECTION_OBSERVER_ROOTMARGIN - } - ); - } - - /** - * Responsible for rendering web component compounds based on a renderer or a nesting - * micro frontend. - * - * @param {*} navNode the navigation node defining the compound - * @param {*} wc_container the web component container dom element - * @param {*} context the luigi node context - */ - renderWebComponentCompound(navNode, wc_container, extendedContext) { - const useLazyLoading = navNode.compound?.lazyLoadingOptions?.enabled === true; - const context = extendedContext.context; - const renderer = this.getCompoundRenderer(navNode, context); - /** @type {IntersectionObserver} */ - let intersectionObserver; - - if (useLazyLoading) { - intersectionObserver = this.createIntersectionObserver(navNode); - } - - wc_container._luigi_node = navNode; - - return new Promise(resolve => { - this.createCompoundContainerAsync(renderer, extendedContext, navNode).then(compoundContainer => { - const ebListeners = {}; - - compoundContainer.eventBus = { - listeners: ebListeners, - onPublishEvent: (event, srcNodeId, wcId) => { - const listeners = ebListeners[srcNodeId + '.' + event.type] || []; - - listeners.push(...(ebListeners['*.' + event.type] || [])); - listeners.forEach(listenerInfo => { - const target = - listenerInfo.wcElement || compoundContainer.querySelector('[nodeId=' + listenerInfo.wcElementId + ']'); - if (target) { - target.dispatchEvent( - new CustomEvent(listenerInfo.action, { - detail: listenerInfo.converter ? listenerInfo.converter(event.detail) : event.detail - }) - ); - } else { - console.debug('Could not find event target', listenerInfo); - } - }); - } - }; - - navNode.compound.children.forEach((compoundItemSettings, index) => { - const ctx = { ...context, ...compoundItemSettings.context }; - const compoundItemContainer = renderer.createCompoundItemContainer(compoundItemSettings.layoutConfig); - const nodeId = compoundItemSettings.id || 'gen_' + index; - - compoundItemContainer.eventBus = compoundContainer.eventBus; - - if (useLazyLoading) { - this.setTemporaryHeightForCompoundItemContainer( - compoundItemContainer, - navNode.compound, - compoundItemSettings - ); - renderer.attachCompoundItem(compoundContainer, compoundItemContainer); - this.wcContainerData.set(compoundItemContainer, { - viewUrl: compoundItemSettings.viewUrl, - wc_container: compoundItemContainer, - extendedContext: { context: ctx }, - node: compoundItemSettings, - nodeId: nodeId, - isSpecialMf: true, - noTemporaryContainerHeight: navNode.compound.lazyLoadingOptions?.noTemporaryContainerHeight - }); - intersectionObserver.observe(compoundItemContainer); - } else { - renderer.attachCompoundItem(compoundContainer, compoundItemContainer); - this.renderWebComponent( - compoundItemSettings.viewUrl, - compoundItemContainer, - { context: ctx }, - compoundItemSettings, - nodeId, - true, - false - ); - } - - registerEventListeners(ebListeners, compoundItemSettings, nodeId); - }); - - wc_container.appendChild(compoundContainer); - - // listener for nesting wc - registerEventListeners(ebListeners, navNode.compound, '_root', compoundContainer); - resolve(compoundContainer); - }); - }); - } - - /** - * Gets the stored user settings for a specific user settings group - - * @param {Object} wc node object definition - * @returns a promise that gets resolved with the stored user settings for a specific user settings group. - */ - getUserSettingsForWc(wc) { - return new Promise((resolve, reject) => { - if (wc.userSettingsGroup) { - const userSettingsGroupName = wc.userSettingsGroup; - LuigiConfig.readUserSettings().then(storedUserSettingsData => { - const hasUserSettings = - userSettingsGroupName && typeof storedUserSettingsData === 'object' && storedUserSettingsData !== null; - - const userSettings = hasUserSettings ? storedUserSettingsData[userSettingsGroupName] : null; - resolve(userSettings); - }); - } else { - reject(null); - } - }); - } -} - -export const WebComponentService = new WebComponentSvcClass(); +import { + DefaultCompoundRenderer, + deSanitizeParamsMap, + registerEventListeners, + resolveRenderer +} from '../utilities/helpers/web-component-helpers'; +import { LuigiConfig } from '../core-api'; +import { RoutingHelpers, GenericHelpers, NavigationHelpers } from '../utilities/helpers'; + +const DEFAULT_TEMPORARY_HEIGHT = '500px'; +const DEFAULT_INTERSECTION_OBSERVER_ROOTMARGIN = '0px'; + +/** Methods for dealing with web components based micro frontend handling */ +class WebComponentSvcClass { + /** + * @typedef {object} WcContainerData + * @property {string} viewUrl + * @property {HTMLElement} wc_container + * @property {object} extendedContext + * @property {object} node + * @property {string} nodeId + * @property {boolean} isSpecialMf indicates whether the web component is rendered in a modal, splitView or drawer (`false` by default) + * @property {boolean} noTemporaryContainerHeight + */ + + /** @type {WeakMap} */ + wcContainerData = new WeakMap(); + + dynamicImport(viewUrl) { + /** __luigi_dyn_import_____________() is replaced by import(\/* webpackIgnore: true *\/) after webpack is done, + * because webpack can't let his hands off imports ;) + * trailing underscores are there to match the replacement char nr to avoid sourcemap mess*/ + return __luigi_dyn_import_____________(viewUrl); + } + + /** Creates a web component with tagname wc_id and adds it to wcItemContainer, + * if attached to wc_container + */ + attachWC(wc_id, wcItemPlaceholder, wc_container, extendedContext, viewUrl, nodeId, isSpecialMf, isLazyLoading) { + if (wc_container && wc_container.contains(wcItemPlaceholder)) { + const wc = document.createElement(wc_id); + + if (nodeId) { + wc.setAttribute('nodeId', nodeId); + } + wc.setAttribute('lui_web_component', true); + this.initWC(wc, wc_id, wc_container, viewUrl, extendedContext, nodeId, isSpecialMf); + + wc_container.replaceChild(wc, wcItemPlaceholder); + + if (isLazyLoading) { + this.removeTemporaryHeightFromCompoundItemContainer(wc_container); + this.wcContainerData.delete(wc_container); + } + } + } + + initWC(wc, wc_id, eventBusElement, viewUrl, extendedContext, nodeId, isSpecialMf) { + const ctx = extendedContext.context; + wc.extendedContext = extendedContext; + + // handle difference modal vs main mf + if (wc.extendedContext.currentNode) { + wc.extendedContext.clientPermissions = wc.extendedContext.currentNode.clientPermissions; + } + const clientAPI = { + linkManager: () => { + const lm = window.Luigi.navigation(); + return new Proxy(lm, { + get(target, prop) { + if (prop === target.getCurrentRoute.name) { + return () => { + return new Promise((resolve) => { + resolve(target.getCurrentRoute()); + }); + }; + } + return target[prop]; + } + }); + }, + uxManager: window.Luigi.ux, + getCurrentLocale: () => window.Luigi.i18n().getCurrentLocale(), + publishEvent: (ev) => { + if (eventBusElement.eventBus) { + eventBusElement.eventBus.onPublishEvent(ev, nodeId, wc_id); + } + }, + getActiveFeatureToggleList: () => window.Luigi.featureToggles().getActiveFeatureToggleList(), + getActiveFeatureToggles: () => window.Luigi.featureToggles().getActiveFeatureToggleList(), + getPathParams: () => (wc.extendedContext?.pathParams ? wc.extendedContext.pathParams : {}), + getCoreSearchParams: () => { + const node = { + clientPermissions: wc.extendedContext.clientPermissions + }; + return RoutingHelpers.prepareSearchParamsForClient(node); + }, + getClientPermissions: () => (wc.extendedContext?.clientPermissions ? wc.extendedContext.clientPermissions : {}), + addNodeParams: (params, keepBrowserHistory) => { + if (!isSpecialMf) { + window.Luigi.routing().addNodeParams(params, keepBrowserHistory); + } + }, + getNodeParams: (shouldDesanitise) => { + if (isSpecialMf) { + return {}; + } + const result = wc.extendedContext?.nodeParams ? wc.extendedContext.nodeParams : {}; + if (shouldDesanitise) { + return deSanitizeParamsMap(result); + } + return wc.extendedContext.nodeParams; + }, + setAnchor: (anchor) => { + if (!isSpecialMf) { + window.Luigi.routing().setAnchor(anchor); + } + }, + getAnchor: () => { + return window.Luigi.routing().getAnchor(); + }, + getUserSettings: async () => { + return await this.getUserSettingsForWc(eventBusElement._luigi_node); + }, + setViewGroupData: (data) => { + const vg = NavigationHelpers.findViewGroup(eventBusElement._luigi_node); + if (vg) { + const vgSettings = NavigationHelpers.getViewGroupSettings(vg); + vgSettings._liveCustomData = data; + LuigiConfig.configChanged('navigation.viewgroupdata'); + } + } + }; + + if (wc.__postProcess) { + const url = + new URL(document.baseURI).origin === new URL(viewUrl, document.baseURI).origin + ? new URL(viewUrl, document.baseURI) + : new URL('./', viewUrl); + wc.__postProcess(ctx, clientAPI, url.origin + url.pathname); + } else { + wc.context = ctx; + wc.nodeParams = extendedContext.nodeParams; + wc.LuigiClient = clientAPI; + } + + const wcCreationInterceptor = LuigiConfig.getConfigValue('settings.webcomponentCreationInterceptor'); + if (GenericHelpers.isFunction(wcCreationInterceptor)) { + wcCreationInterceptor(wc, extendedContext.currentNode, extendedContext, nodeId, isSpecialMf); + } + } + + /** Generates a unique web component id (tagname) based on the viewUrl + * returns a string that can be used as part of a tagname, only alphanumeric + * characters and no whitespaces. + */ + generateWCId(viewUrl) { + let charRep = ''; + let normalizedViewUrl = new URL(viewUrl, encodeURI(location.href)).href; + for (let i = 0; i < normalizedViewUrl.length; i++) { + charRep += normalizedViewUrl.charCodeAt(i).toString(16); + } + return 'luigi-wc-' + charRep; + } + + /** Does a module import from viewUrl and defines a new web component + * with the default export of the module or the first export extending HTMLElement if no default is + * specified. + * @returns a promise that gets resolved after successfull import */ + registerWCFromUrl(viewUrl, wc_id) { + const i18nViewUrl = RoutingHelpers.getI18nViewUrl(viewUrl); + return new Promise((resolve, reject) => { + if (this.checkWCUrl(i18nViewUrl)) { + this.dynamicImport(i18nViewUrl) + .then((module) => { + try { + if (!window.customElements.get(wc_id)) { + let cmpClazz = module.default; + if (!HTMLElement.isPrototypeOf(cmpClazz)) { + let props = Object.keys(module); + for (let i = 0; i < props.length; i++) { + cmpClazz = module[props[i]]; + if (HTMLElement.isPrototypeOf(cmpClazz)) { + break; + } + } + } + window.customElements.define(wc_id, cmpClazz); + } + resolve(); + } catch (e) { + reject(e); + } + }) + .catch((err) => reject(err)); + } else { + console.warn(`View URL '${i18nViewUrl}' not allowed to be included`); + reject(`View URL '${i18nViewUrl}' not allowed`); + } + }); + } + + /** + * Handles the import of self registered web component bundles, i.e. the web component + * is added to the customElements registry by the bundle code rather than by luigi. + * + * @param {*} node the corresponding navigation node + * @param {*} viewUrl the source of the wc bundle + * @param {*} onload callback function executed after script attached and loaded + */ + includeSelfRegisteredWCFromUrl(node, viewUrl, onload) { + if (this.checkWCUrl(viewUrl)) { + /** Append reg function to luigi object if not present */ + if (!window.Luigi._registerWebcomponent) { + window.Luigi._registerWebcomponent = (srcString, el) => { + const wcId = this.generateWCId(srcString); + if (!window.customElements.get(wcId)) { + window.customElements.define(wcId, el); + } + }; + } + + let scriptTag = document.createElement('script'); + scriptTag.setAttribute('src', viewUrl); + if (node.webcomponent.type === 'module') { + scriptTag.setAttribute('type', 'module'); + } + scriptTag.setAttribute('defer', true); + scriptTag.addEventListener('load', () => { + onload(); + }); + document.body.appendChild(scriptTag); + } else { + console.warn(`View URL '${viewUrl}' not allowed to be included`); + } + } + + /** + * Checks if a url is allowed to be included, based on 'navigation.validWebcomponentUrls' in luigi config. + * Returns true, if allowed. + * + * @param {*} url the url string to check + */ + checkWCUrl(url) { + if (url.indexOf('://') > 0 || url.trim().indexOf('//') === 0) { + const ur = new URL(url); + if (ur.host === window.location.host) { + return true; // same host is okay + } + + const valids = LuigiConfig.getConfigValue('navigation.validWebcomponentUrls'); + if (valids && valids.length > 0) { + for (let el of valids) { + try { + if (new RegExp(el).test(url)) { + return true; + } + } catch (e) { + console.error(e); + } + } + } + return false; + } + // relative URL is okay + return true; + } + + /** Adds a web component defined by viewUrl to the wc_container and sets the node context. + * If the web component is not defined yet, it gets imported. + */ + renderWebComponent(viewUrl, wc_container, extendedContext, node, nodeId, isSpecialMf, isLazyLoading) { + const context = extendedContext.context; + const i18nViewUrl = RoutingHelpers.substituteViewUrl(viewUrl, { context }); + const wc_id = node?.webcomponent?.tagName || this.generateWCId(i18nViewUrl); + const wcItemPlaceholder = document.createElement('div'); + + wc_container.appendChild(wcItemPlaceholder); + wc_container._luigi_node = node; + + if (window.customElements.get(wc_id)) { + this.attachWC( + wc_id, + wcItemPlaceholder, + wc_container, + extendedContext, + i18nViewUrl, + nodeId, + isSpecialMf, + isLazyLoading + ); + } else { + /** Custom import function, if defined */ + if (window.luigiWCFn) { + window.luigiWCFn(i18nViewUrl, wc_id, wcItemPlaceholder, () => { + this.attachWC( + wc_id, + wcItemPlaceholder, + wc_container, + extendedContext, + i18nViewUrl, + nodeId, + isSpecialMf, + isLazyLoading + ); + }); + } else if (node.webcomponent && node.webcomponent.selfRegistered) { + this.includeSelfRegisteredWCFromUrl(node, i18nViewUrl, () => { + this.attachWC( + wc_id, + wcItemPlaceholder, + wc_container, + extendedContext, + i18nViewUrl, + nodeId, + isSpecialMf, + isLazyLoading + ); + }); + } else { + this.registerWCFromUrl(i18nViewUrl, wc_id).then(() => { + this.attachWC( + wc_id, + wcItemPlaceholder, + wc_container, + extendedContext, + i18nViewUrl, + nodeId, + isSpecialMf, + isLazyLoading + ); + }); + } + } + } + + /** + * Creates a compound container according to the given renderer. + * Returns a promise that gets resolved with the created container DOM element. + * + * @param {DefaultCompoundRenderer} renderer + */ + createCompoundContainerAsync(renderer, ctx, navNode) { + return new Promise((resolve, reject) => { + if (renderer.viewUrl) { + try { + const wc_id = navNode?.webcomponent?.tagName || this.generateWCId(renderer.viewUrl); + if (navNode?.webcomponent?.selfRegistered) { + this.includeSelfRegisteredWCFromUrl(navNode, renderer.viewUrl, () => { + const wc = document.createElement(wc_id); + wc.setAttribute('lui_web_component', true); + this.initWC(wc, wc_id, wc, renderer.viewUrl, ctx, '_root'); + resolve(wc); + }); + } else { + this.registerWCFromUrl(renderer.viewUrl, wc_id).then(() => { + const wc = document.createElement(wc_id); + wc.setAttribute('lui_web_component', true); + this.initWC(wc, wc_id, wc, renderer.viewUrl, ctx, '_root'); + resolve(wc); + }); + } + } catch (e) { + reject(e); + } + } else { + resolve(renderer.createCompoundContainer()); + } + }); + } + + /** + * @param {IntersectionObserverEntry[]} entries + * @param {IntersectionObserver} observer + */ + intersectionObserverCallback(entries, observer) { + const intersectingEntries = entries.filter((entry) => entry.isIntersecting); + + intersectingEntries.forEach((intersectingEntry) => { + const compoundItemContainer = intersectingEntry.target; + const wcContainerData = this.wcContainerData.get(compoundItemContainer); + + if (!!wcContainerData) { + this.renderWebComponent( + wcContainerData.viewUrl, + wcContainerData.wc_container, + wcContainerData.extendedContext, + wcContainerData.node, + wcContainerData.nodeId, + wcContainerData.isSpecialMf, + true + ); + } else { + console.error('Could not find WC container data', { + for: compoundItemContainer + }); + } + observer.unobserve(compoundItemContainer); + }); + } + + /** + * When lazy loading is active, this function sets a temporary height to the given compound item container. + * The temporary height is added because otherwise, when adding the empty containers for all compound items, + * all containers would have a height of 0 because the web components they contain will be added + * asynchronously later. All containers would be visible so that all web components would be added right away. + * In other words, this would break lazy loading. + * @param {HTMLElement} compoundItemContainer + * @param {object} compoundSettings + * @param {object} [compoundSettings.lazyLoadingOptions] + * @param {string} [compoundSettings.lazyLoadingOptions.temporaryContainerHeight] + * @param {boolean} [compoundSettings.lazyLoadingOptions.noTemporaryContainerHeight] + * @param {object} compoundItemSettings + * @param {object} compoundItemSettings.layoutConfig + * @param {string} [compoundItemSettings.layoutConfig.temporaryContainerHeight] + */ + setTemporaryHeightForCompoundItemContainer(compoundItemContainer, compoundSettings, compoundItemSettings) { + if (compoundSettings.lazyLoadingOptions?.noTemporaryContainerHeight === true) { + return; + } + + const temporaryContainerHeight = + compoundItemSettings.layoutConfig?.temporaryContainerHeight || + compoundSettings.lazyLoadingOptions?.temporaryContainerHeight || + DEFAULT_TEMPORARY_HEIGHT; + + compoundItemContainer.style.height = temporaryContainerHeight; + } + + /** + * When lazy loading is active, this function removes the temporary height from the given compound item + * container after the web component is instantiated and attached to the container. + * @param {HTMLElement} compoundItemContainer + */ + removeTemporaryHeightFromCompoundItemContainer(compoundItemContainer) { + const wcContainerData = this.wcContainerData.get(compoundItemContainer); + + if (wcContainerData?.noTemporaryContainerHeight !== true) { + compoundItemContainer.style.removeProperty('height'); + } + } + + /** + * @param {object} navNode + */ + getCompoundRenderer(navNode, context) { + const isNestedWebComponent = navNode.webcomponent && !!navNode.viewUrl; + let renderer; + + if (isNestedWebComponent) { + // Nested web component + renderer = new DefaultCompoundRenderer(); + renderer.viewUrl = RoutingHelpers.substituteViewUrl(navNode.viewUrl, { + context + }); + renderer.createCompoundItemContainer = (layoutConfig) => { + var cnt = document.createElement('div'); + if (layoutConfig && layoutConfig.slot) { + cnt.setAttribute('slot', layoutConfig.slot); + } + return cnt; + }; + } else if (navNode.compound.renderer) { + renderer = resolveRenderer(navNode.compound.renderer); + } else { + renderer = new DefaultCompoundRenderer(); + } + + return renderer; + } + + createIntersectionObserver(navNode) { + return new IntersectionObserver( + (entries, observer) => { + this.intersectionObserverCallback(entries, observer); + }, + { + rootMargin: + navNode.compound.lazyLoadingOptions?.intersectionRootMargin || DEFAULT_INTERSECTION_OBSERVER_ROOTMARGIN + } + ); + } + + /** + * Responsible for rendering web component compounds based on a renderer or a nesting + * micro frontend. + * + * @param {*} navNode the navigation node defining the compound + * @param {*} wc_container the web component container dom element + * @param {*} context the luigi node context + */ + renderWebComponentCompound(navNode, wc_container, extendedContext) { + const useLazyLoading = navNode.compound?.lazyLoadingOptions?.enabled === true; + const context = extendedContext.context; + const renderer = this.getCompoundRenderer(navNode, context); + /** @type {IntersectionObserver} */ + let intersectionObserver; + + if (useLazyLoading) { + intersectionObserver = this.createIntersectionObserver(navNode); + } + + wc_container._luigi_node = navNode; + + return new Promise((resolve) => { + this.createCompoundContainerAsync(renderer, extendedContext, navNode).then((compoundContainer) => { + const ebListeners = {}; + + compoundContainer.eventBus = { + listeners: ebListeners, + onPublishEvent: (event, srcNodeId, wcId) => { + const listeners = ebListeners[srcNodeId + '.' + event.type] || []; + + listeners.push(...(ebListeners['*.' + event.type] || [])); + listeners.forEach((listenerInfo) => { + const target = + listenerInfo.wcElement || compoundContainer.querySelector('[nodeId=' + listenerInfo.wcElementId + ']'); + if (target) { + target.dispatchEvent( + new CustomEvent(listenerInfo.action, { + detail: listenerInfo.converter ? listenerInfo.converter(event.detail) : event.detail + }) + ); + } else { + console.debug('Could not find event target', listenerInfo); + } + }); + } + }; + + navNode.compound.children.forEach((compoundItemSettings, index) => { + const ctx = { ...context, ...compoundItemSettings.context }; + const compoundItemContainer = renderer.createCompoundItemContainer(compoundItemSettings.layoutConfig); + const nodeId = compoundItemSettings.id || 'gen_' + index; + + compoundItemContainer.eventBus = compoundContainer.eventBus; + + if (useLazyLoading) { + this.setTemporaryHeightForCompoundItemContainer( + compoundItemContainer, + navNode.compound, + compoundItemSettings + ); + renderer.attachCompoundItem(compoundContainer, compoundItemContainer); + this.wcContainerData.set(compoundItemContainer, { + viewUrl: compoundItemSettings.viewUrl, + wc_container: compoundItemContainer, + extendedContext: { context: ctx }, + node: compoundItemSettings, + nodeId: nodeId, + isSpecialMf: true, + noTemporaryContainerHeight: navNode.compound.lazyLoadingOptions?.noTemporaryContainerHeight + }); + intersectionObserver.observe(compoundItemContainer); + } else { + renderer.attachCompoundItem(compoundContainer, compoundItemContainer); + this.renderWebComponent( + compoundItemSettings.viewUrl, + compoundItemContainer, + { context: ctx }, + compoundItemSettings, + nodeId, + true, + false + ); + } + + registerEventListeners(ebListeners, compoundItemSettings, nodeId); + }); + + wc_container.appendChild(compoundContainer); + + // listener for nesting wc + registerEventListeners(ebListeners, navNode.compound, '_root', compoundContainer); + resolve(compoundContainer); + }); + }); + } + + /** + * Gets the stored user settings for a specific user settings group + + * @param {Object} wc node object definition + * @returns a promise that gets resolved with the stored user settings for a specific user settings group. + */ + getUserSettingsForWc(wc) { + return new Promise((resolve, reject) => { + if (wc.userSettingsGroup) { + const userSettingsGroupName = wc.userSettingsGroup; + LuigiConfig.readUserSettings().then((storedUserSettingsData) => { + const hasUserSettings = + userSettingsGroupName && typeof storedUserSettingsData === 'object' && storedUserSettingsData !== null; + + const userSettings = hasUserSettings ? storedUserSettingsData[userSettingsGroupName] : null; + resolve(userSettings); + }); + } else { + reject(null); + } + }); + } +} + +export const WebComponentService = new WebComponentSvcClass(); diff --git a/core/test/services/web-components.spec.js b/core/test/services/web-components.spec.js index b9c1a2b2f3..1caaea8004 100644 --- a/core/test/services/web-components.spec.js +++ b/core/test/services/web-components.spec.js @@ -1,913 +1,924 @@ -import { WebComponentService } from '../../src/services/web-components'; -import { LuigiConfig, LuigiI18N } from '../../src/core-api'; - -import { DefaultCompoundRenderer } from '../../src/utilities/helpers/web-component-helpers'; -import { LuigiElement } from '../../../client/src/luigi-element'; - -const chai = require('chai'); -const sinon = require('sinon'); -const expect = chai.expect; -const assert = chai.assert; - -describe('WebComponentService', function() { - let customElementsGetSpy; - let customElementsDefineSpy; - - beforeEach(() => { - customElementsGetSpy = jest.spyOn(globalThis.customElements, 'get'); - customElementsDefineSpy = jest.spyOn(globalThis.customElements, 'define'); - }); - - afterEach(() => { - customElementsGetSpy.mockRestore(); - customElementsDefineSpy.mockRestore(); - }); - - describe('generate web component id', function() { - const someRandomString = 'dsfgljhbakjdfngb,mdcn vkjrzwero78to4 wfoasb f,asndbf'; - - it('check determinism', () => { - const wcId = WebComponentService.generateWCId(someRandomString); - const wcId2 = WebComponentService.generateWCId(someRandomString); - expect(wcId).to.equal(wcId2); - }); - - it('check uniqueness', () => { - const wcId = WebComponentService.generateWCId(someRandomString); - const wcId2 = WebComponentService.generateWCId('someOtherRandomString_9843utieuhfgiasdf'); - expect(wcId).to.not.equal(wcId2); - }); - }); - - describe('attach web component', function() { - const sb = sinon.createSandbox(); - let container; - let itemPlaceholder; - const extendedContext = { context: { someValue: true } }; - - beforeEach(() => { - window.Luigi = { - navigation: 'mock1', - ux: 'mock2', - i18n: () => LuigiI18N - }; - - container = document.createElement('div'); - itemPlaceholder = document.createElement('div'); - }); - - afterEach(() => { - sb.restore(); - delete window.Luigi; - }); - - it('check dom injection abort if container not attached', () => { - WebComponentService.attachWC('div', itemPlaceholder, container, extendedContext); - - expect(container.children.length).to.equal(0); - }); - - it('check dom injection', () => { - container.appendChild(itemPlaceholder); - WebComponentService.attachWC('div', itemPlaceholder, container, extendedContext); - - const expectedCmp = container.children[0]; - expect(expectedCmp.context).to.equal(extendedContext.context); - expect(expectedCmp.LuigiClient.linkManager).to.equal(window.Luigi.navigation); - expect(expectedCmp.LuigiClient.uxManager).to.equal(window.Luigi.ux); - expect(expectedCmp.LuigiClient.getCurrentLocale()).to.equal(window.Luigi.i18n().getCurrentLocale()); - expect(expectedCmp.LuigiClient.getCurrentLocale).to.be.a('function'); - expect(expectedCmp.LuigiClient.publishEvent).to.be.a('function'); - }); - - it('check post-processing', () => { - const wc_id = 'my-wc'; - const MyLuigiElement = class extends LuigiElement { - render(ctx) { - return '
'; - } - }; - - const myEl = Object.create(MyLuigiElement.prototype, {}); - sb.stub(myEl, '__postProcess').callsFake(() => {}); - sb.stub(myEl, 'setAttribute').callsFake(() => {}); - sb.stub(document, 'createElement') - .callThrough() - .withArgs('my-wc') - .callsFake(() => { - return myEl; - }); - sb.stub(container, 'replaceChild').callsFake(() => {}); - sb.stub(window, 'location').value({ origin: 'http://localhost' }); - - container.appendChild(itemPlaceholder); - WebComponentService.attachWC(wc_id, itemPlaceholder, container, extendedContext, 'http://localhost:8080/'); - - assert(myEl.__postProcess.calledOnce, '__postProcess should be called'); - expect(myEl.setAttribute.calledWith('lui_web_component', true)).to.equal(true); - }); - }); - - describe('register web component from url', function() { - const sb = sinon.createSandbox(); - - afterEach(() => { - sb.restore(); - }); - - it('check resolve', done => { - let definedId; - sb.stub(WebComponentService, 'dynamicImport').returns( - new Promise((resolve, reject) => { - resolve({ default: {} }); - }) - ); - const customElementsMock = { - define: (id, clazz) => { - definedId = id; - }, - get: id => { - return undefined; - } - }; - customElementsGetSpy.mockImplementation(customElementsMock.get); - customElementsDefineSpy.mockImplementation(customElementsMock.define); - - WebComponentService.registerWCFromUrl('url', 'id').then(() => { - expect(definedId).to.equal('id'); - done(); - }); - }); - - it('check reject', done => { - let definedId; - sb.stub(WebComponentService, 'dynamicImport').returns( - new Promise((resolve, reject) => { - reject({ default: {} }); - }) - ); - const customElementsMock = { - define: (id, clazz) => { - definedId = id; - } - }; - customElementsGetSpy.mockImplementation(customElementsMock.define); - - WebComponentService.registerWCFromUrl('url', 'id') - .then(() => { - assert.fail('should not be here'); - done(); - }) - .catch(err => { - expect(definedId).to.be.undefined; - done(); - }); - }); - - it('check reject due to not-allowed url', done => { - WebComponentService.registerWCFromUrl('http://luigi-project.io/mfe.js', 'id') - .then(() => { - assert.fail('should not be here'); - done(); - }) - .catch(err => { - done(); - }); - }); - }); - - describe('render web component', function() { - const container = document.createElement('div'); - const ctx = { someValue: true }; - const viewUrl = 'someurl'; - const sb = sinon.createSandbox(); - const node = {}; - - beforeEach(() => { - sb.stub(WebComponentService, 'dynamicImport').returns( - new Promise(resolve => { - resolve({ default: {} }); - }) - ); - }); - - afterEach(() => { - sb.restore(); - }); - - it('check attachment of already existing wc', done => { - customElementsDefineSpy.mockReturnValue(); - customElementsGetSpy.mockReturnValue(true); - - sb.stub(WebComponentService, 'registerWCFromUrl').callsFake(() => { - assert.fail('should not be here'); - }); - - sb.stub(WebComponentService, 'attachWC').callsFake((id, iCnt, cnt, context) => { - expect(cnt).to.equal(container); - expect(JSON.stringify(context)).to.equal(JSON.stringify(ctx)); - done(); - }); - - WebComponentService.renderWebComponent(viewUrl, container, ctx, node); - }); - - it('check invocation of custom function', done => { - customElementsDefineSpy.mockReturnValue(); - customElementsGetSpy.mockReturnValue(false); - - sb.stub(WebComponentService, 'registerWCFromUrl').callsFake(() => { - assert.fail('should not be here'); - }); - - sb.stub(WebComponentService, 'attachWC').callsFake((id, iCnt, cnt, context) => { - expect(cnt).to.equal(container); - expect(JSON.stringify(context)).to.equal(JSON.stringify(ctx)); - done(); - }); - - window.luigiWCFn = (viewUrl, wc_id, wc_container, cb) => { - cb(); - }; - - WebComponentService.renderWebComponent(viewUrl, container, ctx, node); - - delete window.luigiWCFn; - }); - - it('check creation and attachment of new wc', done => { - customElementsDefineSpy.mockReturnValue(); - customElementsGetSpy.mockReturnValue(false); - - sb.stub(WebComponentService, 'registerWCFromUrl').callsFake(() => Promise.resolve()); - - sb.stub(WebComponentService, 'attachWC').callsFake((id, iCnt, cnt, context) => { - expect(cnt).to.equal(container); - expect(JSON.stringify(context)).to.equal(JSON.stringify(ctx)); - done(); - }); - - WebComponentService.renderWebComponent(viewUrl, container, ctx, node); - }); - }); - - describe('check valid wc url', function() { - const sb = sinon.createSandbox(); - - afterEach(() => { - sb.restore(); - }); - - it('check permission for relative and absolute urls from same domain', () => { - const relative1 = WebComponentService.checkWCUrl('/folder/sth.js'); - expect(relative1).to.be.true; - const relative2 = WebComponentService.checkWCUrl('folder/sth.js'); - expect(relative2).to.be.true; - const relative3 = WebComponentService.checkWCUrl('./folder/sth.js'); - expect(relative3).to.be.true; - - const absolute = WebComponentService.checkWCUrl(window.location.href + '/folder/sth.js'); - expect(absolute).to.be.true; - }); - - it('check permission and denial for urls based on config', () => { - sb.stub(LuigiConfig, 'getConfigValue').returns([ - 'https://fiddle.luigi-project.io/.?', - 'https://docs.luigi-project.io/.?' - ]); - - const valid1 = WebComponentService.checkWCUrl('https://fiddle.luigi-project.io/folder/sth.js'); - expect(valid1).to.be.true; - const valid2 = WebComponentService.checkWCUrl('https://docs.luigi-project.io/folder/sth.js'); - expect(valid2).to.be.true; - - const invalid1 = WebComponentService.checkWCUrl('http://fiddle.luigi-project.io/folder/sth.js'); - expect(invalid1).to.be.false; - const invalid2 = WebComponentService.checkWCUrl('https://slack.luigi-project.io/folder/sth.js'); - expect(invalid2).to.be.false; - }); - }); - - describe('check includeSelfRegisteredWCFromUrl', function() { - const sb = sinon.createSandbox(); - const node = { - webcomponent: { - selfRegistered: true - } - }; - - beforeAll(() => { - window.Luigi = { mario: 'luigi', luigi: window.luigi }; - }); - - afterAll(() => { - window.Luigi = window.Luigi.luigi; - }); - - afterEach(() => { - sb.restore(); - }); - - it('check if script tag is added', () => { - let element; - sb.stub(document.body, 'appendChild').callsFake(el => { - element = el; - }); - - WebComponentService.includeSelfRegisteredWCFromUrl(node, '/mfe.js', () => {}); - expect(element.getAttribute('src')).to.equal('/mfe.js'); - }); - - it('check if script tag is not added for untrusted url', () => { - sb.spy(document.body, 'appendChild'); - WebComponentService.includeSelfRegisteredWCFromUrl(node, 'https://luigi-project.io/mfe.js', () => {}); - assert(document.body.appendChild.notCalled); - }); - }); - - describe('check createCompoundContainerAsync', function() { - const sb = sinon.createSandbox(); - - afterEach(() => { - sb.restore(); - }); - - it('check compound container created', done => { - const renderer = new DefaultCompoundRenderer(); - sb.spy(renderer); - WebComponentService.createCompoundContainerAsync(renderer).then( - () => { - assert(renderer.createCompoundContainer.calledOnce, 'createCompoundContainer called once'); - done(); - }, - e => { - assert.fail('should not be here'); - done(); - } - ); - }); - - it('check nesting mfe created', done => { - const renderer = new DefaultCompoundRenderer(); - renderer.viewUrl = 'mfe.js'; - sb.stub(WebComponentService, 'registerWCFromUrl').resolves(); - sb.stub(WebComponentService, 'initWC').returns(); - sb.spy(renderer); - WebComponentService.createCompoundContainerAsync(renderer).then( - () => { - assert(renderer.createCompoundContainer.notCalled, 'createCompoundContainer should not be called'); - assert(WebComponentService.registerWCFromUrl.calledOnce, 'registerWCFromUrl called once'); - assert(WebComponentService.initWC.calledOnce, 'initWC called once'); - done(); - }, - e => { - assert.fail('should not be here'); - done(); - } - ); - }); - }); - - describe('check renderWebComponentCompound', () => { - const sb = sinon.createSandbox(); - const extendedContext = { context: { key: 'value', mario: 'luigi' } }; - const eventEmitter = 'emitterId'; - const eventName = 'emitterId'; - const navNode = { - compound: { - eventListeners: [ - { - source: '*', - name: eventName, - action: 'update', - dataConverter: data => { - return 'new text: ' + data; - } - } - ], - children: [ - { - viewUrl: 'mfe1.js', - context: { - title: 'My Awesome Grid' - }, - layoutConfig: { - row: '1', - column: '1 / -1' - }, - eventListeners: [ - { - source: eventEmitter, - name: eventName, - action: 'update', - dataConverter: data => { - return 'new text: ' + data; - } - } - ] - }, - { - id: eventEmitter, - viewUrl: 'mfe2.js', - context: { - title: 'Some input', - instant: true - } - } - ] - } - }; - - beforeAll(() => { - window.Luigi = { mario: 'luigi', luigi: window.luigi }; - }); - - afterAll(() => { - window.Luigi = window.Luigi.luigi; - }); - - beforeEach(() => { - globalThis.IntersectionObserver = jest.fn(function IntersectionObserver() { - this.observe = jest.fn(); - }); - }); - - afterEach(() => { - sb.restore(); - delete globalThis.IntersectionObserver; - }); - - it('render flat compound', done => { - const wc_container = document.createElement('div'); - - sb.spy(WebComponentService, 'renderWebComponent'); - sb.stub(WebComponentService, 'registerWCFromUrl').resolves(); - - WebComponentService.renderWebComponentCompound(navNode, wc_container, extendedContext) - .then(compoundCnt => { - expect(wc_container.children.length).to.equal(1); - - // eventbus test - const evBus = compoundCnt.eventBus; - const listeners = evBus.listeners[eventEmitter + '.' + eventName]; - expect(listeners.length).to.equal(1); - const target = compoundCnt.querySelector('[nodeId=' + listeners[0].wcElementId + ']'); - sb.spy(target, 'dispatchEvent'); - evBus.onPublishEvent(new CustomEvent(eventName), eventEmitter); - assert(target.dispatchEvent.calledOnce); - // IntersectionObserver for lazy loading should not be instantiated - expect(globalThis.IntersectionObserver.mock.instances).to.have.lengthOf(0); - // Check if renderWebComponent is called for each child - assert(WebComponentService.renderWebComponent.calledTwice); - - done(); - }) - .catch(reason => { - done(reason); - }); - }); - - it('render nested compound', done => { - const wc_container = document.createElement('div'); - const node = JSON.parse(JSON.stringify(navNode)); - node.viewUrl = 'mfe.js'; - node.webcomponent = true; - const customElementsMock = { - get: () => { - return false; - } - }; - - customElementsGetSpy.mockImplementation(customElementsMock.get); - - sb.stub(WebComponentService, 'registerWCFromUrl').resolves(); - - WebComponentService.renderWebComponentCompound(node, wc_container, extendedContext).then( - compoundCnt => { - expect(WebComponentService.registerWCFromUrl.callCount).to.equal(3); - expect(globalThis.IntersectionObserver.mock.instances).to.have.lengthOf(0); - // eventbus test - const evBus = compoundCnt.eventBus; - sb.spy(compoundCnt, 'dispatchEvent'); - evBus.onPublishEvent(new CustomEvent(eventName), eventEmitter); - assert(compoundCnt.dispatchEvent.calledOnce); - - done(); - }, - () => { - assert.fail('should not be here'); - done(); - } - ); - }); - }); - - describe('Get user settings for wc', () => { - const sb = sinon.createSandbox(); - afterEach(() => { - sb.restore(); - }); - it('get user settings for user settings group', () => { - const wc = { - viewUrl: '/test.js', - label: 'tets', - userSettingsGroup: 'language' - }; - const storedUserSettingsData = { - account: { name: 'luigi', email: 'luigi@tets.com' }, - language: { language: 'de', time: '12h', date: '' } - }; - sb.stub(LuigiConfig, 'readUserSettings').resolves(storedUserSettingsData); - WebComponentService.getUserSettingsForWc(wc).then(userSettings => { - expect(userSettings).to.deep.equal({ language: 'de', time: '12h', date: '' }); - }); - }); - it('get user settings, no user settings stored', () => { - const wc = { - viewUrl: '/test.js', - label: 'tets', - userSettingsGroup: 'language' - }; - sb.stub(LuigiConfig, 'readUserSettings').resolves(); - WebComponentService.getUserSettingsForWc(wc).then(userSettings => { - expect(userSettings).equal(null); - }); - }); - }); - - describe('Lazy loading', () => { - describe('setTemporaryHeightForCompoundItemContainer', () => { - const initialHeight = ''; - let mockContainerElement; - - beforeEach(() => { - mockContainerElement = { - style: { - height: initialHeight - } - }; - }); - - it('does not apply anything if noTemporaryContainerHeight is configured', () => { - WebComponentService.setTemporaryHeightForCompoundItemContainer( - mockContainerElement, - { lazyLoadingOptions: { temporaryContainerHeight: '666px', noTemporaryContainerHeight: true } }, - {} - ); - - expect(mockContainerElement.style.height).to.equal(initialHeight); - }); - - it('applies the fallback height if no height is configured', () => { - WebComponentService.setTemporaryHeightForCompoundItemContainer(mockContainerElement, {}, {}); - - expect(mockContainerElement.style.height).to.equal('500px'); - }); - - it('applies the compound setting if it is configured', () => { - WebComponentService.setTemporaryHeightForCompoundItemContainer( - mockContainerElement, - { lazyLoadingOptions: { temporaryContainerHeight: '666px' } }, - {} - ); - - expect(mockContainerElement.style.height).to.equal('666px'); - }); - - it('applies the compound item setting if it is configured', () => { - WebComponentService.setTemporaryHeightForCompoundItemContainer( - mockContainerElement, - {}, - { layoutConfig: { temporaryContainerHeight: '777px' } } - ); - - expect(mockContainerElement.style.height).to.equal('777px'); - }); - - it('applies the compound item setting if it is configured, overriding a compound setting', () => { - WebComponentService.setTemporaryHeightForCompoundItemContainer( - mockContainerElement, - { lazyLoadingOptions: { temporaryContainerHeight: '666px' } }, - { layoutConfig: { temporaryContainerHeight: '777px' } } - ); - - expect(mockContainerElement.style.height).to.equal('777px'); - }); - }); - - describe('removeTemporaryHeightFromCompoundItemContainer', () => { - let mockWcContainerDataGet; - let mockContainerElement; - - beforeEach(() => { - mockWcContainerDataGet = jest.spyOn(WebComponentService.wcContainerData, 'get'); - mockContainerElement = { - style: { - removeProperty: jest.fn() - } - }; - }); - - afterEach(() => { - mockWcContainerDataGet.mockRestore(); - }); - - it('removes the height style property', () => { - mockWcContainerDataGet.mockReturnValue({}); - - WebComponentService.removeTemporaryHeightFromCompoundItemContainer(mockContainerElement); - - expect(mockContainerElement.style.removeProperty.mock.calls).to.have.lengthOf(1); - }); - - it('does not remove the height style property if noTemporaryContainerHeight is set', () => { - mockWcContainerDataGet.mockReturnValue({ noTemporaryContainerHeight: true }); - - WebComponentService.removeTemporaryHeightFromCompoundItemContainer(mockContainerElement); - - expect(mockContainerElement.style.removeProperty.mock.calls).to.have.lengthOf(0); - }); - }); - - describe('createIntersectionObserver', () => { - beforeEach(() => { - globalThis.IntersectionObserver = jest.fn(function IntersectionObserver(callback, options) { - this.callback = callback; - this.options = options; - this.observe = jest.fn(); - }); - }); - - afterEach(() => { - delete globalThis.IntersectionObserver; - }); - - it('correctly applies intersectionRootMargin', () => { - const observer = WebComponentService.createIntersectionObserver({ - compound: { - lazyLoadingOptions: { - enabled: true, - intersectionRootMargin: '50px' - } - } - }); - - expect(observer.options.rootMargin).to.equal('50px'); - }); - - it('correctly applies the fallback if intersectionRootMargin is not set', () => { - const observer = WebComponentService.createIntersectionObserver({ - compound: { - lazyLoadingOptions: { - enabled: true - } - } - }); - - expect(observer.options.rootMargin).to.equal('0px'); - }); - }); - - describe('attachWC with lazy loading', () => { - let container; - let itemPlaceholder; - let mockedRemoveTemporaryHeightFromCompoundItemContainer; - let extendedContext; - - beforeEach(() => { - window.Luigi = { - navigation: 'mock1', - ux: 'mock2', - i18n: () => LuigiI18N - }; - container = document.createElement('div'); - itemPlaceholder = document.createElement('div'); - extendedContext = { context: { someValue: true } }; - mockedRemoveTemporaryHeightFromCompoundItemContainer = jest.spyOn( - WebComponentService, - 'removeTemporaryHeightFromCompoundItemContainer' - ); - }); - - afterEach(() => { - mockedRemoveTemporaryHeightFromCompoundItemContainer.mockRestore(); - delete window.Luigi; - }); - - it('does not call removeTemporaryHeightFromCompoundItemContainer if lazy loading is off', () => { - container.appendChild(itemPlaceholder); - WebComponentService.attachWC('div', itemPlaceholder, container, extendedContext); - - expect(mockedRemoveTemporaryHeightFromCompoundItemContainer.mock.calls).to.have.lengthOf(0); - }); - - it('calls removeTemporaryHeightFromCompoundItemContainer if lazy loading is on', () => { - container.appendChild(itemPlaceholder); - WebComponentService.attachWC( - 'div', - itemPlaceholder, - container, - extendedContext, - undefined, - undefined, - undefined, - true - ); - - expect(mockedRemoveTemporaryHeightFromCompoundItemContainer.mock.calls).to.have.lengthOf(1); - }); - }); - - describe('renderWebComponent with lazy loading', () => { - let mockAttachWc; - let mockRegisterWcFromUrl; - let viewUrl; - let ctx; - let node; - let container; - - beforeEach(() => { - viewUrl = 'someurl'; - ctx = { someValue: true }; - node = {}; - container = document.createElement('div'); - mockAttachWc = jest.spyOn(WebComponentService, 'attachWC'); - mockRegisterWcFromUrl = jest.spyOn(WebComponentService, 'registerWCFromUrl'); - }); - - afterEach(() => { - mockAttachWc.mockRestore(); - mockRegisterWcFromUrl.mockRestore(); - }); - - it('passes on isLazyLoading in case wc already exists', done => { - customElementsGetSpy.mockReturnValue(true); - - mockAttachWc.mockImplementation((_1, _2, _3, _4, _5, _6, _7, isLazyLoading) => { - expect(isLazyLoading).to.equal(true); - done(); - }); - - WebComponentService.renderWebComponent(viewUrl, container, ctx, node, undefined, undefined, true); - }); - - it('passes on isLazyLoading in case of custom function', done => { - customElementsGetSpy.mockReturnValue(false); - mockAttachWc.mockImplementation((_1, _2, _3, _4, _5, _6, _7, isLazyLoading) => { - expect(isLazyLoading).to.equal(true); - delete window.luigiWCFn; - done(); - }); - - window.luigiWCFn = (viewUrl, wc_id, wc_container, cb) => { - cb(); - }; - - WebComponentService.renderWebComponent(viewUrl, container, ctx, node, undefined, undefined, true); - }); - - it('passes on isLazyLoading in case of new wc', done => { - customElementsGetSpy.mockReturnValue(false); - mockAttachWc.mockImplementation((_1, _2, _3, _4, _5, _6, _7, isLazyLoading) => { - expect(isLazyLoading).to.equal(true); - done(); - }); - mockRegisterWcFromUrl.mockImplementation(() => Promise.resolve()); - - WebComponentService.renderWebComponent(viewUrl, container, ctx, node, undefined, undefined, true); - }); - }); - - describe('renderWebComponentCompound with lazy loading', () => { - let mockRenderWebComponent; - let mockRegisterWcFromUrl; - let mockSetTemporaryHeight; - let navNode; - let extendedContext; - let wc_container; - - beforeEach(() => { - globalThis.IntersectionObserver = jest.fn(function IntersectionObserver(callback, options) { - this.callback = callback; - this.options = options; - this.observe = jest.fn(); - }); - customElementsGetSpy.mockReturnValue(false); - - extendedContext = { context: { key: 'value', mario: 'luigi' } }; - wc_container = document.createElement('div'); - navNode = { - compound: { - lazyLoadingOptions: { - enabled: true, - intersectionRootMargin: '50px' - }, - eventListeners: [ - { - source: '*', - name: 'emitterId', - action: 'update', - dataConverter: data => { - return 'new text: ' + data; - } - } - ], - children: [ - { - viewUrl: 'mfe1.js', - context: { - title: 'My Awesome Grid' - }, - layoutConfig: { - row: '1', - column: '1 / -1' - }, - eventListeners: [ - { - source: 'emitterId', - name: 'emitterId', - action: 'update', - dataConverter: data => { - return 'new text: ' + data; - } - } - ] - }, - { - id: 'emitterId', - viewUrl: 'mfe2.js', - context: { - title: 'Some input', - instant: true - } - } - ] - } - }; - mockRenderWebComponent = jest.spyOn(WebComponentService, 'renderWebComponent'); - mockRegisterWcFromUrl = jest.spyOn(WebComponentService, 'registerWCFromUrl').mockResolvedValue(); - mockSetTemporaryHeight = jest.spyOn(WebComponentService, 'setTemporaryHeightForCompoundItemContainer'); - }); - - afterEach(() => { - mockRenderWebComponent.mockRestore(); - mockRegisterWcFromUrl.mockRestore(); - mockSetTemporaryHeight.mockRestore(); - delete globalThis.IntersectionObserver; - }); - - it('renders a flat compound with lazy loading', done => { - WebComponentService.renderWebComponentCompound(navNode, wc_container, extendedContext) - .then(() => { - expect(globalThis.IntersectionObserver.mock.instances).to.have.lengthOf(1); - - const intersectionObserverInstance = globalThis.IntersectionObserver.mock.instances[0]; - - expect(intersectionObserverInstance.observe.mock.calls).to.have.lengthOf(2); - expect(mockSetTemporaryHeight.mock.calls).to.have.lengthOf(2); - done(); - }) - .catch(reason => { - done(reason); - }); - }); - - it('renders a nested compound', done => { - const mockInitWc = jest.spyOn(WebComponentService, 'initWC').mockReturnValue(); - - navNode.viewUrl = 'mfe.js'; - navNode.webcomponent = true; - - WebComponentService.renderWebComponentCompound(navNode, wc_container, extendedContext) - .then(() => { - expect(globalThis.IntersectionObserver.mock.instances).to.have.lengthOf(1); - - const intersectionObserverInstance = globalThis.IntersectionObserver.mock.instances[0]; - - expect(intersectionObserverInstance.observe.mock.calls).to.have.lengthOf(2); - expect(mockSetTemporaryHeight.mock.calls).to.have.lengthOf(2); - - mockInitWc.mockRestore(); - done(); - }) - .catch(reason => { - done(reason); - }); - }); - - it('passes intersectionRootMargin to IntersectionObserver', done => { - WebComponentService.renderWebComponentCompound(navNode, wc_container, extendedContext) - .then(() => { - // expect(globalThis.IntersectionObserver.options).to.be.an('object'); - const intersectionObserverInstance = globalThis.IntersectionObserver.mock.instances[0]; - expect(intersectionObserverInstance.options).to.be.an('object'); - expect(intersectionObserverInstance.options.rootMargin).to.equal('50px'); - done(); - }) - .catch(reason => { - done(reason); - }); - }); - }); - }); -}); +import { WebComponentService } from '../../src/services/web-components'; +import { LuigiConfig, LuigiI18N } from '../../src/core-api'; + +import { DefaultCompoundRenderer } from '../../src/utilities/helpers/web-component-helpers'; +import { LuigiElement } from '../../../client/src/luigi-element'; + +const chai = require('chai'); +const sinon = require('sinon'); +const expect = chai.expect; +const assert = chai.assert; + +describe('WebComponentService', function () { + let customElementsGetSpy; + let customElementsDefineSpy; + + beforeEach(() => { + customElementsGetSpy = jest.spyOn(globalThis.customElements, 'get'); + customElementsDefineSpy = jest.spyOn(globalThis.customElements, 'define'); + }); + + afterEach(() => { + customElementsGetSpy.mockRestore(); + customElementsDefineSpy.mockRestore(); + }); + + describe('generate web component id', function () { + const someRandomString = 'dsfgljhbakjdfngb,mdcn vkjrzwero78to4 wfoasb f,asndbf'; + + it('check determinism', () => { + const wcId = WebComponentService.generateWCId(someRandomString); + const wcId2 = WebComponentService.generateWCId(someRandomString); + expect(wcId).to.equal(wcId2); + }); + + it('check uniqueness', () => { + const wcId = WebComponentService.generateWCId(someRandomString); + const wcId2 = WebComponentService.generateWCId('someOtherRandomString_9843utieuhfgiasdf'); + expect(wcId).to.not.equal(wcId2); + }); + }); + + describe('attach web component', function () { + const sb = sinon.createSandbox(); + let container; + let itemPlaceholder; + const extendedContext = { context: { someValue: true } }; + + beforeEach(() => { + window.Luigi = { + navigation: () => { + return new Object({ + mockValue: 'mock', + getCurrentRoute: () => { + return 'mockRoute'; + } + }); + }, + ux: 'mock2', + i18n: () => LuigiI18N + }; + + container = document.createElement('div'); + itemPlaceholder = document.createElement('div'); + }); + + afterEach(() => { + sb.restore(); + delete window.Luigi; + }); + + it('check dom injection abort if container not attached', () => { + WebComponentService.attachWC('div', itemPlaceholder, container, extendedContext); + + expect(container.children.length).to.equal(0); + }); + + it('check dom injection', async () => { + container.appendChild(itemPlaceholder); + WebComponentService.attachWC('div', itemPlaceholder, container, extendedContext); + + const expectedCmp = container.children[0]; + expect(expectedCmp.context).to.equal(extendedContext.context); + expect(expectedCmp.LuigiClient.linkManager).to.be.a('function'); + expect(expectedCmp.LuigiClient.linkManager().mockValue).to.equal('mock'); + expect(expectedCmp.LuigiClient.linkManager().getCurrentRoute()).to.be.a('promise'); + const route = await expectedCmp.LuigiClient.linkManager().getCurrentRoute(); + expect(route).to.equal('mockRoute'); + expect(expectedCmp.LuigiClient.uxManager).to.equal(window.Luigi.ux); + expect(expectedCmp.LuigiClient.getCurrentLocale()).to.equal(window.Luigi.i18n().getCurrentLocale()); + expect(expectedCmp.LuigiClient.getCurrentLocale).to.be.a('function'); + expect(expectedCmp.LuigiClient.publishEvent).to.be.a('function'); + }); + + it('check post-processing', () => { + const wc_id = 'my-wc'; + const MyLuigiElement = class extends LuigiElement { + render(ctx) { + return '
'; + } + }; + + const myEl = Object.create(MyLuigiElement.prototype, {}); + sb.stub(myEl, '__postProcess').callsFake(() => {}); + sb.stub(myEl, 'setAttribute').callsFake(() => {}); + sb.stub(document, 'createElement') + .callThrough() + .withArgs('my-wc') + .callsFake(() => { + return myEl; + }); + sb.stub(container, 'replaceChild').callsFake(() => {}); + sb.stub(window, 'location').value({ origin: 'http://localhost' }); + + container.appendChild(itemPlaceholder); + WebComponentService.attachWC(wc_id, itemPlaceholder, container, extendedContext, 'http://localhost:8080/'); + + assert(myEl.__postProcess.calledOnce, '__postProcess should be called'); + expect(myEl.setAttribute.calledWith('lui_web_component', true)).to.equal(true); + }); + }); + + describe('register web component from url', function () { + const sb = sinon.createSandbox(); + + afterEach(() => { + sb.restore(); + }); + + it('check resolve', (done) => { + let definedId; + sb.stub(WebComponentService, 'dynamicImport').returns( + new Promise((resolve, reject) => { + resolve({ default: {} }); + }) + ); + const customElementsMock = { + define: (id, clazz) => { + definedId = id; + }, + get: (id) => { + return undefined; + } + }; + customElementsGetSpy.mockImplementation(customElementsMock.get); + customElementsDefineSpy.mockImplementation(customElementsMock.define); + + WebComponentService.registerWCFromUrl('url', 'id').then(() => { + expect(definedId).to.equal('id'); + done(); + }); + }); + + it('check reject', (done) => { + let definedId; + sb.stub(WebComponentService, 'dynamicImport').returns( + new Promise((resolve, reject) => { + reject({ default: {} }); + }) + ); + const customElementsMock = { + define: (id, clazz) => { + definedId = id; + } + }; + customElementsGetSpy.mockImplementation(customElementsMock.define); + + WebComponentService.registerWCFromUrl('url', 'id') + .then(() => { + assert.fail('should not be here'); + done(); + }) + .catch((err) => { + expect(definedId).to.be.undefined; + done(); + }); + }); + + it('check reject due to not-allowed url', (done) => { + WebComponentService.registerWCFromUrl('http://luigi-project.io/mfe.js', 'id') + .then(() => { + assert.fail('should not be here'); + done(); + }) + .catch((err) => { + done(); + }); + }); + }); + + describe('render web component', function () { + const container = document.createElement('div'); + const ctx = { someValue: true }; + const viewUrl = 'someurl'; + const sb = sinon.createSandbox(); + const node = {}; + + beforeEach(() => { + sb.stub(WebComponentService, 'dynamicImport').returns( + new Promise((resolve) => { + resolve({ default: {} }); + }) + ); + }); + + afterEach(() => { + sb.restore(); + }); + + it('check attachment of already existing wc', (done) => { + customElementsDefineSpy.mockReturnValue(); + customElementsGetSpy.mockReturnValue(true); + + sb.stub(WebComponentService, 'registerWCFromUrl').callsFake(() => { + assert.fail('should not be here'); + }); + + sb.stub(WebComponentService, 'attachWC').callsFake((id, iCnt, cnt, context) => { + expect(cnt).to.equal(container); + expect(JSON.stringify(context)).to.equal(JSON.stringify(ctx)); + done(); + }); + + WebComponentService.renderWebComponent(viewUrl, container, ctx, node); + }); + + it('check invocation of custom function', (done) => { + customElementsDefineSpy.mockReturnValue(); + customElementsGetSpy.mockReturnValue(false); + + sb.stub(WebComponentService, 'registerWCFromUrl').callsFake(() => { + assert.fail('should not be here'); + }); + + sb.stub(WebComponentService, 'attachWC').callsFake((id, iCnt, cnt, context) => { + expect(cnt).to.equal(container); + expect(JSON.stringify(context)).to.equal(JSON.stringify(ctx)); + done(); + }); + + window.luigiWCFn = (viewUrl, wc_id, wc_container, cb) => { + cb(); + }; + + WebComponentService.renderWebComponent(viewUrl, container, ctx, node); + + delete window.luigiWCFn; + }); + + it('check creation and attachment of new wc', (done) => { + customElementsDefineSpy.mockReturnValue(); + customElementsGetSpy.mockReturnValue(false); + + sb.stub(WebComponentService, 'registerWCFromUrl').callsFake(() => Promise.resolve()); + + sb.stub(WebComponentService, 'attachWC').callsFake((id, iCnt, cnt, context) => { + expect(cnt).to.equal(container); + expect(JSON.stringify(context)).to.equal(JSON.stringify(ctx)); + done(); + }); + + WebComponentService.renderWebComponent(viewUrl, container, ctx, node); + }); + }); + + describe('check valid wc url', function () { + const sb = sinon.createSandbox(); + + afterEach(() => { + sb.restore(); + }); + + it('check permission for relative and absolute urls from same domain', () => { + const relative1 = WebComponentService.checkWCUrl('/folder/sth.js'); + expect(relative1).to.be.true; + const relative2 = WebComponentService.checkWCUrl('folder/sth.js'); + expect(relative2).to.be.true; + const relative3 = WebComponentService.checkWCUrl('./folder/sth.js'); + expect(relative3).to.be.true; + + const absolute = WebComponentService.checkWCUrl(window.location.href + '/folder/sth.js'); + expect(absolute).to.be.true; + }); + + it('check permission and denial for urls based on config', () => { + sb.stub(LuigiConfig, 'getConfigValue').returns([ + 'https://fiddle.luigi-project.io/.?', + 'https://docs.luigi-project.io/.?' + ]); + + const valid1 = WebComponentService.checkWCUrl('https://fiddle.luigi-project.io/folder/sth.js'); + expect(valid1).to.be.true; + const valid2 = WebComponentService.checkWCUrl('https://docs.luigi-project.io/folder/sth.js'); + expect(valid2).to.be.true; + + const invalid1 = WebComponentService.checkWCUrl('http://fiddle.luigi-project.io/folder/sth.js'); + expect(invalid1).to.be.false; + const invalid2 = WebComponentService.checkWCUrl('https://slack.luigi-project.io/folder/sth.js'); + expect(invalid2).to.be.false; + }); + }); + + describe('check includeSelfRegisteredWCFromUrl', function () { + const sb = sinon.createSandbox(); + const node = { + webcomponent: { + selfRegistered: true + } + }; + + beforeAll(() => { + window.Luigi = { mario: 'luigi', luigi: window.luigi }; + }); + + afterAll(() => { + window.Luigi = window.Luigi.luigi; + }); + + afterEach(() => { + sb.restore(); + }); + + it('check if script tag is added', () => { + let element; + sb.stub(document.body, 'appendChild').callsFake((el) => { + element = el; + }); + + WebComponentService.includeSelfRegisteredWCFromUrl(node, '/mfe.js', () => {}); + expect(element.getAttribute('src')).to.equal('/mfe.js'); + }); + + it('check if script tag is not added for untrusted url', () => { + sb.spy(document.body, 'appendChild'); + WebComponentService.includeSelfRegisteredWCFromUrl(node, 'https://luigi-project.io/mfe.js', () => {}); + assert(document.body.appendChild.notCalled); + }); + }); + + describe('check createCompoundContainerAsync', function () { + const sb = sinon.createSandbox(); + + afterEach(() => { + sb.restore(); + }); + + it('check compound container created', (done) => { + const renderer = new DefaultCompoundRenderer(); + sb.spy(renderer); + WebComponentService.createCompoundContainerAsync(renderer).then( + () => { + assert(renderer.createCompoundContainer.calledOnce, 'createCompoundContainer called once'); + done(); + }, + (e) => { + assert.fail('should not be here'); + done(); + } + ); + }); + + it('check nesting mfe created', (done) => { + const renderer = new DefaultCompoundRenderer(); + renderer.viewUrl = 'mfe.js'; + sb.stub(WebComponentService, 'registerWCFromUrl').resolves(); + sb.stub(WebComponentService, 'initWC').returns(); + sb.spy(renderer); + WebComponentService.createCompoundContainerAsync(renderer).then( + () => { + assert(renderer.createCompoundContainer.notCalled, 'createCompoundContainer should not be called'); + assert(WebComponentService.registerWCFromUrl.calledOnce, 'registerWCFromUrl called once'); + assert(WebComponentService.initWC.calledOnce, 'initWC called once'); + done(); + }, + (e) => { + assert.fail('should not be here'); + done(); + } + ); + }); + }); + + describe('check renderWebComponentCompound', () => { + const sb = sinon.createSandbox(); + const extendedContext = { context: { key: 'value', mario: 'luigi' } }; + const eventEmitter = 'emitterId'; + const eventName = 'emitterId'; + const navNode = { + compound: { + eventListeners: [ + { + source: '*', + name: eventName, + action: 'update', + dataConverter: (data) => { + return 'new text: ' + data; + } + } + ], + children: [ + { + viewUrl: 'mfe1.js', + context: { + title: 'My Awesome Grid' + }, + layoutConfig: { + row: '1', + column: '1 / -1' + }, + eventListeners: [ + { + source: eventEmitter, + name: eventName, + action: 'update', + dataConverter: (data) => { + return 'new text: ' + data; + } + } + ] + }, + { + id: eventEmitter, + viewUrl: 'mfe2.js', + context: { + title: 'Some input', + instant: true + } + } + ] + } + }; + + beforeAll(() => { + window.Luigi = { mario: 'luigi', luigi: window.luigi }; + }); + + afterAll(() => { + window.Luigi = window.Luigi.luigi; + }); + + beforeEach(() => { + globalThis.IntersectionObserver = jest.fn(function IntersectionObserver() { + this.observe = jest.fn(); + }); + }); + + afterEach(() => { + sb.restore(); + delete globalThis.IntersectionObserver; + }); + + it('render flat compound', (done) => { + const wc_container = document.createElement('div'); + + sb.spy(WebComponentService, 'renderWebComponent'); + sb.stub(WebComponentService, 'registerWCFromUrl').resolves(); + + WebComponentService.renderWebComponentCompound(navNode, wc_container, extendedContext) + .then((compoundCnt) => { + expect(wc_container.children.length).to.equal(1); + + // eventbus test + const evBus = compoundCnt.eventBus; + const listeners = evBus.listeners[eventEmitter + '.' + eventName]; + expect(listeners.length).to.equal(1); + const target = compoundCnt.querySelector('[nodeId=' + listeners[0].wcElementId + ']'); + sb.spy(target, 'dispatchEvent'); + evBus.onPublishEvent(new CustomEvent(eventName), eventEmitter); + assert(target.dispatchEvent.calledOnce); + // IntersectionObserver for lazy loading should not be instantiated + expect(globalThis.IntersectionObserver.mock.instances).to.have.lengthOf(0); + // Check if renderWebComponent is called for each child + assert(WebComponentService.renderWebComponent.calledTwice); + + done(); + }) + .catch((reason) => { + done(reason); + }); + }); + + it('render nested compound', (done) => { + const wc_container = document.createElement('div'); + const node = JSON.parse(JSON.stringify(navNode)); + node.viewUrl = 'mfe.js'; + node.webcomponent = true; + const customElementsMock = { + get: () => { + return false; + } + }; + + customElementsGetSpy.mockImplementation(customElementsMock.get); + + sb.stub(WebComponentService, 'registerWCFromUrl').resolves(); + + WebComponentService.renderWebComponentCompound(node, wc_container, extendedContext).then( + (compoundCnt) => { + expect(WebComponentService.registerWCFromUrl.callCount).to.equal(3); + expect(globalThis.IntersectionObserver.mock.instances).to.have.lengthOf(0); + // eventbus test + const evBus = compoundCnt.eventBus; + sb.spy(compoundCnt, 'dispatchEvent'); + evBus.onPublishEvent(new CustomEvent(eventName), eventEmitter); + assert(compoundCnt.dispatchEvent.calledOnce); + + done(); + }, + () => { + assert.fail('should not be here'); + done(); + } + ); + }); + }); + + describe('Get user settings for wc', () => { + const sb = sinon.createSandbox(); + afterEach(() => { + sb.restore(); + }); + it('get user settings for user settings group', () => { + const wc = { + viewUrl: '/test.js', + label: 'tets', + userSettingsGroup: 'language' + }; + const storedUserSettingsData = { + account: { name: 'luigi', email: 'luigi@tets.com' }, + language: { language: 'de', time: '12h', date: '' } + }; + sb.stub(LuigiConfig, 'readUserSettings').resolves(storedUserSettingsData); + WebComponentService.getUserSettingsForWc(wc).then((userSettings) => { + expect(userSettings).to.deep.equal({ language: 'de', time: '12h', date: '' }); + }); + }); + it('get user settings, no user settings stored', () => { + const wc = { + viewUrl: '/test.js', + label: 'tets', + userSettingsGroup: 'language' + }; + sb.stub(LuigiConfig, 'readUserSettings').resolves(); + WebComponentService.getUserSettingsForWc(wc).then((userSettings) => { + expect(userSettings).equal(null); + }); + }); + }); + + describe('Lazy loading', () => { + describe('setTemporaryHeightForCompoundItemContainer', () => { + const initialHeight = ''; + let mockContainerElement; + + beforeEach(() => { + mockContainerElement = { + style: { + height: initialHeight + } + }; + }); + + it('does not apply anything if noTemporaryContainerHeight is configured', () => { + WebComponentService.setTemporaryHeightForCompoundItemContainer( + mockContainerElement, + { lazyLoadingOptions: { temporaryContainerHeight: '666px', noTemporaryContainerHeight: true } }, + {} + ); + + expect(mockContainerElement.style.height).to.equal(initialHeight); + }); + + it('applies the fallback height if no height is configured', () => { + WebComponentService.setTemporaryHeightForCompoundItemContainer(mockContainerElement, {}, {}); + + expect(mockContainerElement.style.height).to.equal('500px'); + }); + + it('applies the compound setting if it is configured', () => { + WebComponentService.setTemporaryHeightForCompoundItemContainer( + mockContainerElement, + { lazyLoadingOptions: { temporaryContainerHeight: '666px' } }, + {} + ); + + expect(mockContainerElement.style.height).to.equal('666px'); + }); + + it('applies the compound item setting if it is configured', () => { + WebComponentService.setTemporaryHeightForCompoundItemContainer( + mockContainerElement, + {}, + { layoutConfig: { temporaryContainerHeight: '777px' } } + ); + + expect(mockContainerElement.style.height).to.equal('777px'); + }); + + it('applies the compound item setting if it is configured, overriding a compound setting', () => { + WebComponentService.setTemporaryHeightForCompoundItemContainer( + mockContainerElement, + { lazyLoadingOptions: { temporaryContainerHeight: '666px' } }, + { layoutConfig: { temporaryContainerHeight: '777px' } } + ); + + expect(mockContainerElement.style.height).to.equal('777px'); + }); + }); + + describe('removeTemporaryHeightFromCompoundItemContainer', () => { + let mockWcContainerDataGet; + let mockContainerElement; + + beforeEach(() => { + mockWcContainerDataGet = jest.spyOn(WebComponentService.wcContainerData, 'get'); + mockContainerElement = { + style: { + removeProperty: jest.fn() + } + }; + }); + + afterEach(() => { + mockWcContainerDataGet.mockRestore(); + }); + + it('removes the height style property', () => { + mockWcContainerDataGet.mockReturnValue({}); + + WebComponentService.removeTemporaryHeightFromCompoundItemContainer(mockContainerElement); + + expect(mockContainerElement.style.removeProperty.mock.calls).to.have.lengthOf(1); + }); + + it('does not remove the height style property if noTemporaryContainerHeight is set', () => { + mockWcContainerDataGet.mockReturnValue({ noTemporaryContainerHeight: true }); + + WebComponentService.removeTemporaryHeightFromCompoundItemContainer(mockContainerElement); + + expect(mockContainerElement.style.removeProperty.mock.calls).to.have.lengthOf(0); + }); + }); + + describe('createIntersectionObserver', () => { + beforeEach(() => { + globalThis.IntersectionObserver = jest.fn(function IntersectionObserver(callback, options) { + this.callback = callback; + this.options = options; + this.observe = jest.fn(); + }); + }); + + afterEach(() => { + delete globalThis.IntersectionObserver; + }); + + it('correctly applies intersectionRootMargin', () => { + const observer = WebComponentService.createIntersectionObserver({ + compound: { + lazyLoadingOptions: { + enabled: true, + intersectionRootMargin: '50px' + } + } + }); + + expect(observer.options.rootMargin).to.equal('50px'); + }); + + it('correctly applies the fallback if intersectionRootMargin is not set', () => { + const observer = WebComponentService.createIntersectionObserver({ + compound: { + lazyLoadingOptions: { + enabled: true + } + } + }); + + expect(observer.options.rootMargin).to.equal('0px'); + }); + }); + + describe('attachWC with lazy loading', () => { + let container; + let itemPlaceholder; + let mockedRemoveTemporaryHeightFromCompoundItemContainer; + let extendedContext; + + beforeEach(() => { + window.Luigi = { + navigation: 'mock1', + ux: 'mock2', + i18n: () => LuigiI18N + }; + container = document.createElement('div'); + itemPlaceholder = document.createElement('div'); + extendedContext = { context: { someValue: true } }; + mockedRemoveTemporaryHeightFromCompoundItemContainer = jest.spyOn( + WebComponentService, + 'removeTemporaryHeightFromCompoundItemContainer' + ); + }); + + afterEach(() => { + mockedRemoveTemporaryHeightFromCompoundItemContainer.mockRestore(); + delete window.Luigi; + }); + + it('does not call removeTemporaryHeightFromCompoundItemContainer if lazy loading is off', () => { + container.appendChild(itemPlaceholder); + WebComponentService.attachWC('div', itemPlaceholder, container, extendedContext); + + expect(mockedRemoveTemporaryHeightFromCompoundItemContainer.mock.calls).to.have.lengthOf(0); + }); + + it('calls removeTemporaryHeightFromCompoundItemContainer if lazy loading is on', () => { + container.appendChild(itemPlaceholder); + WebComponentService.attachWC( + 'div', + itemPlaceholder, + container, + extendedContext, + undefined, + undefined, + undefined, + true + ); + + expect(mockedRemoveTemporaryHeightFromCompoundItemContainer.mock.calls).to.have.lengthOf(1); + }); + }); + + describe('renderWebComponent with lazy loading', () => { + let mockAttachWc; + let mockRegisterWcFromUrl; + let viewUrl; + let ctx; + let node; + let container; + + beforeEach(() => { + viewUrl = 'someurl'; + ctx = { someValue: true }; + node = {}; + container = document.createElement('div'); + mockAttachWc = jest.spyOn(WebComponentService, 'attachWC'); + mockRegisterWcFromUrl = jest.spyOn(WebComponentService, 'registerWCFromUrl'); + }); + + afterEach(() => { + mockAttachWc.mockRestore(); + mockRegisterWcFromUrl.mockRestore(); + }); + + it('passes on isLazyLoading in case wc already exists', (done) => { + customElementsGetSpy.mockReturnValue(true); + + mockAttachWc.mockImplementation((_1, _2, _3, _4, _5, _6, _7, isLazyLoading) => { + expect(isLazyLoading).to.equal(true); + done(); + }); + + WebComponentService.renderWebComponent(viewUrl, container, ctx, node, undefined, undefined, true); + }); + + it('passes on isLazyLoading in case of custom function', (done) => { + customElementsGetSpy.mockReturnValue(false); + mockAttachWc.mockImplementation((_1, _2, _3, _4, _5, _6, _7, isLazyLoading) => { + expect(isLazyLoading).to.equal(true); + delete window.luigiWCFn; + done(); + }); + + window.luigiWCFn = (viewUrl, wc_id, wc_container, cb) => { + cb(); + }; + + WebComponentService.renderWebComponent(viewUrl, container, ctx, node, undefined, undefined, true); + }); + + it('passes on isLazyLoading in case of new wc', (done) => { + customElementsGetSpy.mockReturnValue(false); + mockAttachWc.mockImplementation((_1, _2, _3, _4, _5, _6, _7, isLazyLoading) => { + expect(isLazyLoading).to.equal(true); + done(); + }); + mockRegisterWcFromUrl.mockImplementation(() => Promise.resolve()); + + WebComponentService.renderWebComponent(viewUrl, container, ctx, node, undefined, undefined, true); + }); + }); + + describe('renderWebComponentCompound with lazy loading', () => { + let mockRenderWebComponent; + let mockRegisterWcFromUrl; + let mockSetTemporaryHeight; + let navNode; + let extendedContext; + let wc_container; + + beforeEach(() => { + globalThis.IntersectionObserver = jest.fn(function IntersectionObserver(callback, options) { + this.callback = callback; + this.options = options; + this.observe = jest.fn(); + }); + customElementsGetSpy.mockReturnValue(false); + + extendedContext = { context: { key: 'value', mario: 'luigi' } }; + wc_container = document.createElement('div'); + navNode = { + compound: { + lazyLoadingOptions: { + enabled: true, + intersectionRootMargin: '50px' + }, + eventListeners: [ + { + source: '*', + name: 'emitterId', + action: 'update', + dataConverter: (data) => { + return 'new text: ' + data; + } + } + ], + children: [ + { + viewUrl: 'mfe1.js', + context: { + title: 'My Awesome Grid' + }, + layoutConfig: { + row: '1', + column: '1 / -1' + }, + eventListeners: [ + { + source: 'emitterId', + name: 'emitterId', + action: 'update', + dataConverter: (data) => { + return 'new text: ' + data; + } + } + ] + }, + { + id: 'emitterId', + viewUrl: 'mfe2.js', + context: { + title: 'Some input', + instant: true + } + } + ] + } + }; + mockRenderWebComponent = jest.spyOn(WebComponentService, 'renderWebComponent'); + mockRegisterWcFromUrl = jest.spyOn(WebComponentService, 'registerWCFromUrl').mockResolvedValue(); + mockSetTemporaryHeight = jest.spyOn(WebComponentService, 'setTemporaryHeightForCompoundItemContainer'); + }); + + afterEach(() => { + mockRenderWebComponent.mockRestore(); + mockRegisterWcFromUrl.mockRestore(); + mockSetTemporaryHeight.mockRestore(); + delete globalThis.IntersectionObserver; + }); + + it('renders a flat compound with lazy loading', (done) => { + WebComponentService.renderWebComponentCompound(navNode, wc_container, extendedContext) + .then(() => { + expect(globalThis.IntersectionObserver.mock.instances).to.have.lengthOf(1); + + const intersectionObserverInstance = globalThis.IntersectionObserver.mock.instances[0]; + + expect(intersectionObserverInstance.observe.mock.calls).to.have.lengthOf(2); + expect(mockSetTemporaryHeight.mock.calls).to.have.lengthOf(2); + done(); + }) + .catch((reason) => { + done(reason); + }); + }); + + it('renders a nested compound', (done) => { + const mockInitWc = jest.spyOn(WebComponentService, 'initWC').mockReturnValue(); + + navNode.viewUrl = 'mfe.js'; + navNode.webcomponent = true; + + WebComponentService.renderWebComponentCompound(navNode, wc_container, extendedContext) + .then(() => { + expect(globalThis.IntersectionObserver.mock.instances).to.have.lengthOf(1); + + const intersectionObserverInstance = globalThis.IntersectionObserver.mock.instances[0]; + + expect(intersectionObserverInstance.observe.mock.calls).to.have.lengthOf(2); + expect(mockSetTemporaryHeight.mock.calls).to.have.lengthOf(2); + + mockInitWc.mockRestore(); + done(); + }) + .catch((reason) => { + done(reason); + }); + }); + + it('passes intersectionRootMargin to IntersectionObserver', (done) => { + WebComponentService.renderWebComponentCompound(navNode, wc_container, extendedContext) + .then(() => { + // expect(globalThis.IntersectionObserver.options).to.be.an('object'); + const intersectionObserverInstance = globalThis.IntersectionObserver.mock.instances[0]; + expect(intersectionObserverInstance.options).to.be.an('object'); + expect(intersectionObserverInstance.options.rootMargin).to.equal('50px'); + done(); + }) + .catch((reason) => { + done(reason); + }); + }); + }); + }); +});