UNPKG

@thoughtspot/visual-embed-sdk

Version:
1,357 lines (1,218 loc) 84.6 kB
/** * Copyright (c) 2022 * * Base classes * @summary Base classes * @author Ayon Ghosh <ayon.ghosh@thoughtspot.com> */ import isEqual from 'lodash/isEqual'; import isEmpty from 'lodash/isEmpty'; import isObject from 'lodash/isObject'; import { TriggerPayload, TriggerResponse, UIPassthroughArrayResponse, UIPassthroughEvent, UIPassthroughRequest, } from './hostEventClient/contracts'; import { logger } from '../utils/logger'; import { getAuthenticationToken } from '../authToken'; import { AnswerService } from '../utils/graphql/answerService/answerService'; import { getEncodedQueryParamsString, getCssDimension, getOffsetTop, embedEventStatus, setAttributes, getCustomisations, getRuntimeFilters, getDOMNode, querySelectorAcrossShadowRoot, getFilterQuery, getQueryParamString, getRuntimeParameters, setStyleProperties, removeStyleProperties, isUndefined, getHostEventsConfig, getValueFromWindow, } from '../utils'; import { getCustomActions } from '../utils/custom-actions'; import { getThoughtSpotHost, URL_MAX_LENGTH, DEFAULT_EMBED_WIDTH, DEFAULT_EMBED_HEIGHT, getV2BasePath, } from '../config'; import { AuthType, DOMSelector, HostEvent, EmbedEvent, MessageCallback, Action, Param, EmbedConfig, MessageOptions, MessageCallbackObj, ContextMenuTriggerOptions, DefaultAppInitData, AllEmbedViewConfig as ViewConfig, EmbedErrorDetailsEvent, ErrorDetailsTypes, EmbedErrorCodes, MessagePayload, ContextType, ContextObject, } from '../types'; import { uploadMixpanelEvent, MIXPANEL_EVENT } from '../mixpanel-service'; import { processEventData, processAuthFailure } from '../utils/processData'; import { version } from '../utils/sdk-version'; import { getAuthPromise, renderInQueue, handleAuth, notifyAuthFailure, getInitPromise, getIsInitCalled, getIsInitCompleted, } from './base'; import { AuthFailureType } from '../auth'; import { getEmbedConfig } from './embedConfig'; import { ERROR_MESSAGE } from '../errors'; import { getPreauthInfo } from '../utils/sessionInfoService'; import { HostEventClient } from './hostEventClient/host-event-client'; import { getInterceptInitData, handleInterceptEvent, processApiInterceptResponse, processLegacyInterceptResponse, } from '../api-intercept'; /** * Global prefix for all ThoughtSpot postHash Params. */ export const THOUGHTSPOT_PARAM_PREFIX = 'ts-'; const TS_EMBED_ID = '_thoughtspot-embed'; /** * dataset key used to stash a custom preRenderContainer's original inline * `position` while we override it to `relative`. Stored on the container (not * per-instance) so the override can be reverted on destroy even when multiple * pre-rendered embeds share the same container. */ const PRERENDER_CONTAINER_ORIGINAL_POSITION_KEY = 'tsEmbedOriginalPosition'; const PRERENDER_WRAPPER_ID_PREFIX = 'tsEmbed-pre-render-wrapper-'; /** * The event id map from v2 event names to v1 event id * v1 events are the classic embed events implemented in Blink v1 * We cannot rename v1 event types to maintain backward compatibility * @internal */ const V1EventMap: Record<string, any> = {}; /** * Base class for embedding v2 experience * Note: the v2 version of ThoughtSpot Blink is built on the new stack: * React+GraphQL */ export class TsEmbed { /** * The DOM node which was inserted by the SDK to either * render the iframe or display an error message. * This is useful for removing the DOM node when the * embed instance is destroyed. */ protected insertedDomEl: Node; /** * The DOM node where the ThoughtSpot app is to be embedded. */ protected hostElement: HTMLElement; /** * The key to store the embed instance in the DOM node */ protected embedNodeKey = '__tsEmbed'; protected isAppInitialized = false; /** * A reference to the iframe within which the ThoughtSpot app * will be rendered. */ protected iFrame: HTMLIFrameElement; /** * Setter for the iframe element * @param {HTMLIFrameElement} iFrame HTMLIFrameElement */ protected setIframeElement(iFrame: HTMLIFrameElement): void { this.iFrame = iFrame; this.hostEventClient.setIframeElement(iFrame); } protected viewConfig: ViewConfig & { visibleTabs?: string[]; hiddenTabs?: string[]; showAlerts?: boolean; }; protected embedConfig: EmbedConfig; /** * The ThoughtSpot hostname or IP address */ protected thoughtSpotHost: string; /* * This is the base to access ThoughtSpot V2. */ protected thoughtSpotV2Base: string; /** * A map of event handlers for particular message types triggered * by the embedded app; multiple event handlers can be registered * against a particular message type. */ private eventHandlerMap: Map<string, MessageCallbackObj[]>; /** * A flag that is set to true post render. */ protected isRendered: boolean; /** * A flag to mark if an error has occurred. */ private isError: boolean; /** * A flag that is set to true post preRender. */ private isPreRendered: boolean; /** * Should we encode URL Query Params using base64 encoding which ThoughtSpot * will generate for embedding. This provides additional security to * ThoughtSpot clusters against Cross site scripting attacks. * @default false */ private shouldEncodeUrlQueryParams = false; private defaultHiddenActions = [Action.ReportError]; private resizeObserver: ResizeObserver; private preRenderContainerEl: HTMLElement | null = null; private containerScrollListener: (() => void) | null = null; protected hostEventClient: HostEventClient; protected isReadyForRenderPromise; protected shouldWaitForRenderPromise: boolean; /** * Handler for fullscreen change events */ private fullscreenChangeHandler: (() => void) | null = null; constructor(domSelector: DOMSelector, viewConfig?: ViewConfig) { this.hostElement = getDOMNode(domSelector); this.eventHandlerMap = new Map(); this.isError = false; this.viewConfig = { excludeRuntimeFiltersfromURL: true, excludeRuntimeParametersfromURL: true, ...viewConfig, }; this.registerAppInit(); uploadMixpanelEvent(MIXPANEL_EVENT.VISUAL_SDK_EMBED_CREATE, { ...viewConfig, }); const embedConfig = getEmbedConfig(); if (embedConfig) { this.embedConfig = embedConfig; this.thoughtSpotHost = getThoughtSpotHost(embedConfig); this.thoughtSpotV2Base = getV2BasePath(embedConfig); } this.hostEventClient = new HostEventClient(this.iFrame); this.shouldWaitForRenderPromise = !getIsInitCompleted(); const afterInit = () => { // Prefer the config captured at construction time; fall back to // getEmbedConfig() for the case where init() // hadn't been called yet. this.embedConfig = embedConfig ?? getEmbedConfig(); if (!this.embedConfig) { logger.error('embedConfig unavailable in afterInit; init() may not have completed'); return; } if (!this.embedConfig.authTriggerContainer && !this.embedConfig.useEventForSAMLPopup) { this.embedConfig.authTriggerContainer = domSelector; } this.thoughtSpotHost = getThoughtSpotHost(this.embedConfig); this.thoughtSpotV2Base = getV2BasePath(this.embedConfig); this.shouldEncodeUrlQueryParams = this.embedConfig.shouldEncodeUrlQueryParams; }; if (!this.shouldWaitForRenderPromise) { afterInit(); } else { this.isReadyForRenderPromise = getInitPromise() .then(afterInit) .catch((err) => { logger.error('SDK init failed before embed could be configured', err); this.throwInitError(); }) .finally(() => { this.shouldWaitForRenderPromise = false; }); } } /** * Throws error encountered during initialization. */ private throwInitError() { this.handleError({ errorType: ErrorDetailsTypes.VALIDATION_ERROR, message: ERROR_MESSAGE.INIT_SDK_REQUIRED, code: EmbedErrorCodes.INIT_ERROR, error: ERROR_MESSAGE.INIT_SDK_REQUIRED, }); } /** * Handles errors within the SDK * @param error The error message or object * @param errorDetails The error details */ protected handleError(errorDetails: EmbedErrorDetailsEvent) { this.isError = true; this.executeCallbacks(EmbedEvent.Error, errorDetails); // Log error logger.error(errorDetails); } /** * Extracts the type field from the event payload * @param event The window message event */ private getEventType(event: MessageEvent) { return event.data?.type || event.data?.__type; } /** * Extracts the port field from the event payload * @param event The window message event * @returns */ private getEventPort(event: MessageEvent) { if (event.ports.length && event.ports[0]) { return event.ports[0]; } return null; } /** * Checks if preauth cache is enabled * from the view config and embed config * @returns boolean */ private isPreAuthCacheEnabled() { // Disable preauth cache when: // 1. overrideOrgId is present since: // - cached auth info would be for wrong org // - info call response changes for each different overrideOrgId // 2. disablePreauthCache is explicitly set to true // 3. FullAppEmbed has primary navbar visible since: // - primary navbar requires fresh auth state for navigation // - cached auth may not reflect current user permissions const isDisabled = this.viewConfig.overrideOrgId !== undefined || this.embedConfig.disablePreauthCache === true || this.isFullAppEmbedWithVisiblePrimaryNavbar(); return !isDisabled; } /** * Checks if current embed is FullAppEmbed with visible primary navbar * @returns boolean */ private isFullAppEmbedWithVisiblePrimaryNavbar(): boolean { const appViewConfig = this.viewConfig as any; // Check if this is a FullAppEmbed (AppEmbed) // showPrimaryNavbar defaults to true if not explicitly set to false return ( appViewConfig.embedComponentType === 'AppEmbed' && appViewConfig.showPrimaryNavbar === true ); } /** * fix for ts7.sep.cl * will be removed for ts7.oct.cl * @param event * @param eventType * @hidden */ private formatEventData(event: MessageEvent, eventType: string) { const eventData = { ...event.data, type: eventType, }; if (!eventData.data) { eventData.data = event.data.payload; } return eventData; } private subscribedListeners: Record<string, any> = {}; /** * Subscribe to network events (online/offline) that should * work regardless of auth status */ private subscribeToNetworkEvents() { this.unsubscribeToNetworkEvents(); const onlineEventListener = (e: Event) => { this.trigger(HostEvent.Reload); }; window.addEventListener('online', onlineEventListener); const offlineEventListener = (e: Event) => { const errorDetails = { errorType: ErrorDetailsTypes.NETWORK, message: ERROR_MESSAGE.OFFLINE_WARNING, code: EmbedErrorCodes.NETWORK_ERROR, offlineWarning: ERROR_MESSAGE.OFFLINE_WARNING, }; this.executeCallbacks(EmbedEvent.Error, errorDetails); logger.warn(errorDetails); }; window.addEventListener('offline', offlineEventListener); this.subscribedListeners.online = onlineEventListener; this.subscribedListeners.offline = offlineEventListener; } private handleApiInterceptEvent({ eventData, eventPort, }: { eventData: any; eventPort: MessagePort | void; }) { const executeEvent = (_eventType: EmbedEvent, data: any) => { this.executeCallbacks(_eventType, data, eventPort); }; const getUnsavedAnswerTml = async (props: { sessionId?: string; vizId?: string }) => { const response = await this.triggerUIPassThrough( UIPassthroughEvent.GetUnsavedAnswerTML, props, ); return response.filter((item) => item.value)?.[0]?.value; }; handleInterceptEvent({ eventData, executeEvent, viewConfig: this.viewConfig, getUnsavedAnswerTml, }); } private messageEventListener = (event: MessageEvent<any>) => { const eventType = this.getEventType(event); const eventPort = this.getEventPort(event); const eventData = this.formatEventData(event, eventType); if (event.source === this.iFrame.contentWindow) { const processedEventData = processEventData( eventType, eventData, this.thoughtSpotHost, this.isPreRendered ? this.preRenderWrapper : this.hostElement, ); if (eventType === EmbedEvent.ApiIntercept) { this.handleApiInterceptEvent({ eventData, eventPort }); return; } this.executeCallbacks(eventType, processedEventData, eventPort); } }; /** * Subscribe to message events that depend on successful iframe setup */ private subscribeToMessageEvents() { this.unsubscribeToMessageEvents(); window.addEventListener('message', this.messageEventListener); this.subscribedListeners.message = this.messageEventListener; } /** * Adds event listeners for both network and message events. * This maintains backward compatibility with the existing method. * Adds a global event listener to window for "message" events. * ThoughtSpot detects if a particular event is targeted to this * embed instance through an identifier contained in the payload, * and executes the registered callbacks accordingly. */ private subscribeToEvents() { this.subscribeToNetworkEvents(); this.subscribeToMessageEvents(); } private unsubscribeToNetworkEvents() { if (this.subscribedListeners.online) { window.removeEventListener('online', this.subscribedListeners.online); delete this.subscribedListeners.online; } if (this.subscribedListeners.offline) { window.removeEventListener('offline', this.subscribedListeners.offline); delete this.subscribedListeners.offline; } } private unsubscribeToMessageEvents() { if (this.subscribedListeners.message) { window.removeEventListener('message', this.subscribedListeners.message); delete this.subscribedListeners.message; } } private unsubscribeToEvents() { Object.keys(this.subscribedListeners).forEach((key) => { window.removeEventListener(key, this.subscribedListeners[key]); }); } protected async getAuthTokenForCookielessInit() { let authToken = ''; if (this.embedConfig.authType !== AuthType.TrustedAuthTokenCookieless) return authToken; try { authToken = await getAuthenticationToken(this.embedConfig); } catch (e) { processAuthFailure(e, this.isPreRendered ? this.preRenderWrapper : this.hostElement); throw e; } return authToken; } protected async getDefaultAppInitData(): Promise<DefaultAppInitData> { const authToken = await this.getAuthTokenForCookielessInit(); const customActionsResult = getCustomActions([ ...(this.viewConfig.customActions || []), ...(this.embedConfig.customActions || []), ]); if (customActionsResult.errors.length > 0) { this.handleError({ errorType: ErrorDetailsTypes.VALIDATION_ERROR, message: customActionsResult.errors, code: EmbedErrorCodes.CUSTOM_ACTION_VALIDATION, error: { type: EmbedErrorCodes.CUSTOM_ACTION_VALIDATION, message: customActionsResult.errors, }, }); } const baseInitData = { customisations: getCustomisations(this.embedConfig, this.viewConfig), authToken, runtimeFilterParams: this.viewConfig.excludeRuntimeFiltersfromURL ? getRuntimeFilters(this.viewConfig.runtimeFilters) : null, runtimeParameterParams: this.viewConfig.excludeRuntimeParametersfromURL ? getRuntimeParameters(this.viewConfig.runtimeParameters || []) : null, hiddenHomepageModules: this.viewConfig.hiddenHomepageModules || [], reorderedHomepageModules: this.viewConfig.reorderedHomepageModules || [], hostConfig: this.embedConfig.hostConfig, hiddenHomeLeftNavItems: this.viewConfig?.hiddenHomeLeftNavItems ? this.viewConfig?.hiddenHomeLeftNavItems : [], customVariablesForThirdPartyTools: this.embedConfig.customVariablesForThirdPartyTools || {}, hiddenListColumns: this.viewConfig.hiddenListColumns || [], customActions: customActionsResult.actions, embedExpiryInAuthToken: this.viewConfig.refreshAuthTokenOnNearExpiry ?? true, ...getInterceptInitData(this.viewConfig), ...getHostEventsConfig(this.viewConfig), }; return baseInitData; } protected async getAppInitData() { return this.getDefaultAppInitData(); } /** * Send Custom style as part of payload of APP_INIT * @param _ * @param responder */ private appInitCb = async (_: any, responder: any) => { try { const appInitData = await this.getAppInitData(); this.isAppInitialized = true; responder({ type: EmbedEvent.APP_INIT, data: appInitData, }); } catch (e) { logger.error(`AppInit failed, Error : ${e?.message}`); } }; /** * Helper method to refresh/update auth token for TrustedAuthTokenCookieless auth type * @param responder - Function to send response back * @param eventType - The embed event type to send * @param forceRefresh - Whether to force refresh the token * @returns Promise that resolves if token was refreshed, rejects otherwise */ private async refreshAuthTokenForCookieless( responder: (data: any) => void, eventType: EmbedEvent, forceRefresh: boolean = false, ): Promise<void> { const { authType, autoLogin } = this.embedConfig; const isAutoLoginTrue = autoLogin ?? authType === AuthType.TrustedAuthTokenCookieless; if (isAutoLoginTrue && authType === AuthType.TrustedAuthTokenCookieless) { const authToken = await getAuthenticationToken(this.embedConfig, forceRefresh); responder({ type: eventType, data: { authToken }, }); } } private handleAuthFailure = (error: Error) => { logger.error(`${ERROR_MESSAGE.INVALID_TOKEN_ERROR} Error : ${error?.message}`); processAuthFailure(error, this.isPreRendered ? this.preRenderWrapper : this.hostElement); }; /** * Refresh the auth token if the autoLogin is true and the authType is TrustedAuthTokenCookieless * @param _ * @param responder */ private tokenRefresh = async ( _: MessagePayload, responder: (data: { type: EmbedEvent; data: { authToken: string } }) => void, ) => { try { await this.refreshAuthTokenForCookieless(responder, EmbedEvent.RefreshAuthToken, true); } catch (e) { this.handleAuthFailure(e); } }; /** * Sends updated auth token to the iFrame to avoid user logout * @param _ * @param responder */ private updateAuthToken = async (_: MessagePayload, responder: any) => { const { authType, autoLogin: autoLoginConfig } = this.embedConfig; // Default autoLogin: true for cookieless if undefined/null, otherwise // false const autoLogin = autoLoginConfig ?? authType === AuthType.TrustedAuthTokenCookieless; try { await this.refreshAuthTokenForCookieless(responder, EmbedEvent.AuthExpire, false); } catch (e) { this.handleAuthFailure(e); } if (autoLogin && authType !== AuthType.TrustedAuthTokenCookieless) { handleAuth(); } notifyAuthFailure(AuthFailureType.EXPIRY); }; /** * Auto Login and send updated authToken to the iFrame to avoid user session logout * @param _ * @param responder */ private idleSessionTimeout = (_: any, responder: any) => { handleAuth() .then(async () => { let authToken = ''; try { authToken = await getAuthenticationToken(this.embedConfig); responder({ type: EmbedEvent.IdleSessionTimeout, data: { authToken }, }); } catch (e) { this.handleAuthFailure(e); } }) .catch((e) => { logger.error(`Auto Login failed, Error : ${e?.message}`); }); notifyAuthFailure(AuthFailureType.IDLE_SESSION_TIMEOUT); }; /** * Register APP_INIT event and sendback init payload */ private registerAppInit = () => { this.on(EmbedEvent.APP_INIT, this.appInitCb, { start: false }, true); this.on(EmbedEvent.AuthExpire, this.updateAuthToken, { start: false }, true); this.on(EmbedEvent.IdleSessionTimeout, this.idleSessionTimeout, { start: false }, true); const embedListenerReadyHandler = this.createEmbedContainerHandler( EmbedEvent.EmbedListenerReady, ); this.on(EmbedEvent.EmbedListenerReady, embedListenerReadyHandler, { start: false }, true); const authInitHandler = this.createEmbedContainerHandler(EmbedEvent.AuthInit); this.on(EmbedEvent.AuthInit, authInitHandler, { start: false }, true); this.on(EmbedEvent.RefreshAuthToken, this.tokenRefresh, { start: false }, true); }; /** * Constructs the base URL string to load the ThoughtSpot app. * @param query */ protected getEmbedBasePath(query: string): string { let queryString = query.startsWith('?') ? query : `?${query}`; if (this.shouldEncodeUrlQueryParams) { queryString = `?base64UrlEncodedFlags=${getEncodedQueryParamsString( queryString.substr(1), )}`; } const basePath = [this.thoughtSpotHost, this.thoughtSpotV2Base, queryString] .filter((x) => x.length > 0) .join('/'); return `${basePath}#`; } protected async getUpdateEmbedParamsObject() { let queryParams = this.getEmbedParamsObject(); const appInitData = await this.getAppInitData(); queryParams = { ...this.viewConfig, ...queryParams, ...appInitData }; return queryParams; } /** * Common query params set for all the embed modes. * @param queryParams * @returns queryParams */ protected getBaseQueryParams(queryParams: Record<any, any> = {}) { let hostAppUrl = window?.location?.host || ''; // The below check is needed because TS Cloud firewall, blocks // localhost/127.0.0.1 in any url param. if (hostAppUrl.includes('localhost') || hostAppUrl.includes('127.0.0.1')) { hostAppUrl = 'local-host'; } const blockNonEmbedFullAppAccess = this.embedConfig.blockNonEmbedFullAppAccess ?? true; queryParams[Param.EmbedApp] = true; queryParams[Param.HostAppUrl] = encodeURIComponent(hostAppUrl); queryParams[Param.ViewPortHeight] = window.innerHeight; queryParams[Param.ViewPortWidth] = window.innerWidth; queryParams[Param.Version] = version; queryParams[Param.AuthType] = this.embedConfig.authType; queryParams[Param.blockNonEmbedFullAppAccess] = blockNonEmbedFullAppAccess; queryParams[Param.AutoLogin] = this.embedConfig.autoLogin; if (this.embedConfig.disableLoginRedirect === true || this.embedConfig.autoLogin === true) { queryParams[Param.DisableLoginRedirect] = true; } if (this.embedConfig.authType === AuthType.EmbeddedSSO) { queryParams[Param.ForceSAMLAutoRedirect] = true; } if (this.embedConfig.authType === AuthType.TrustedAuthTokenCookieless) { queryParams[Param.cookieless] = true; } if (this.embedConfig.pendoTrackingKey) { queryParams[Param.PendoTrackingKey] = this.embedConfig.pendoTrackingKey; } if (this.embedConfig.numberFormatLocale) { queryParams[Param.NumberFormatLocale] = this.embedConfig.numberFormatLocale; } if (this.embedConfig.dateFormatLocale) { queryParams[Param.DateFormatLocale] = this.embedConfig.dateFormatLocale; } if (this.embedConfig.currencyFormat) { queryParams[Param.CurrencyFormat] = this.embedConfig.currencyFormat; } const { disabledActions, disabledActionReason, hiddenActions, visibleActions, hiddenTabs, visibleTabs, showAlerts, additionalFlags: additionalFlagsFromView, locale, customizations, contextMenuTrigger, linkOverride, enableLinkOverridesV2, insertInToSlide, disableRedirectionLinksInNewTab, overrideOrgId, exposeTranslationIDs, primaryAction, } = this.viewConfig; const { additionalFlags: additionalFlagsFromInit } = this.embedConfig; const additionalFlags = { ...additionalFlagsFromInit, ...additionalFlagsFromView, }; if (Array.isArray(visibleActions) && Array.isArray(hiddenActions)) { this.handleError({ errorType: ErrorDetailsTypes.VALIDATION_ERROR, message: ERROR_MESSAGE.CONFLICTING_ACTIONS_CONFIG, code: EmbedErrorCodes.CONFLICTING_ACTIONS_CONFIG, error: ERROR_MESSAGE.CONFLICTING_ACTIONS_CONFIG, }); return queryParams; } if (Array.isArray(visibleTabs) && Array.isArray(hiddenTabs)) { this.handleError({ errorType: ErrorDetailsTypes.VALIDATION_ERROR, message: ERROR_MESSAGE.CONFLICTING_TABS_CONFIG, code: EmbedErrorCodes.CONFLICTING_TABS_CONFIG, error: ERROR_MESSAGE.CONFLICTING_TABS_CONFIG, }); return queryParams; } if (primaryAction) { queryParams[Param.PrimaryAction] = primaryAction; } if (disabledActions?.length) { queryParams[Param.DisableActions] = disabledActions; } if (disabledActionReason) { queryParams[Param.DisableActionReason] = disabledActionReason; } if (exposeTranslationIDs) { queryParams[Param.ExposeTranslationIDs] = exposeTranslationIDs; } queryParams[Param.HideActions] = [...this.defaultHiddenActions, ...(hiddenActions ?? [])]; if (Array.isArray(visibleActions)) { queryParams[Param.VisibleActions] = visibleActions; } if (Array.isArray(hiddenTabs)) { queryParams[Param.HiddenTabs] = hiddenTabs; } if (Array.isArray(visibleTabs)) { queryParams[Param.VisibleTabs] = visibleTabs; } /** * Default behavior for context menu will be left-click * from version 9.2.0.cl the user have an option to override context * menu click */ if (contextMenuTrigger === ContextMenuTriggerOptions.LEFT_CLICK) { queryParams[Param.ContextMenuTrigger] = 'left'; } else if (contextMenuTrigger === ContextMenuTriggerOptions.RIGHT_CLICK) { queryParams[Param.ContextMenuTrigger] = 'right'; } else if (contextMenuTrigger === ContextMenuTriggerOptions.BOTH_CLICKS) { queryParams[Param.ContextMenuTrigger] = 'both'; } const embedCustomizations = this.embedConfig.customizations; const spriteUrl = customizations?.iconSpriteUrl || embedCustomizations?.iconSpriteUrl; if (spriteUrl) { queryParams[Param.IconSpriteUrl] = spriteUrl.replace('https://', ''); } const stringIDsUrl = customizations?.content?.stringIDsUrl || embedCustomizations?.content?.stringIDsUrl; if (stringIDsUrl) { queryParams[Param.StringIDsUrl] = stringIDsUrl; } if (showAlerts !== undefined) { queryParams[Param.ShowAlerts] = showAlerts; } if (locale !== undefined) { queryParams[Param.Locale] = locale; } if (!disableRedirectionLinksInNewTab && (enableLinkOverridesV2 || linkOverride)) { queryParams[Param.EnableLinkOverridesV2] = true; queryParams[Param.LinkOverride] = true; } if (insertInToSlide) { queryParams[Param.ShowInsertToSlide] = insertInToSlide; } if (disableRedirectionLinksInNewTab) { queryParams[Param.DisableRedirectionLinksInNewTab] = disableRedirectionLinksInNewTab; } if (overrideOrgId !== undefined) { queryParams[Param.OverrideOrgId] = overrideOrgId; } if (this.isPreAuthCacheEnabled()) { queryParams[Param.preAuthCache] = true; } queryParams[Param.OverrideNativeConsole] = true; queryParams[Param.ClientLogLevel] = this.embedConfig.logLevel; if (isObject(additionalFlags) && !isEmpty(additionalFlags)) { Object.assign(queryParams, additionalFlags); } // Do not add any flags below this, as we want additional flags to // override other flags return queryParams; } /** * Constructs the base URL string to load v1 of the ThoughtSpot app. * This is used for embedding Liveboards, visualizations, and full application. * @param queryString The query string to append to the URL. * @param isAppEmbed A Boolean parameter to specify if you are embedding * the full application. */ protected getV1EmbedBasePath(queryString: string): string { const queryParams = this.shouldEncodeUrlQueryParams ? `?base64UrlEncodedFlags=${getEncodedQueryParamsString(queryString)}` : `?${queryString}`; const host = this.thoughtSpotHost; const path = `${host}/${queryParams}#`; return path; } protected getEmbedParams() { const queryParams = this.getEmbedParamsObject(); return getQueryParamString(queryParams); } protected getEmbedParamsObject() { const params = this.getBaseQueryParams(); return params; } protected getRootIframeSrc() { const query = this.getEmbedParams(); return this.getEmbedBasePath(query); } protected createIframeEl(frameSrc: string): HTMLIFrameElement { const iFrame = document.createElement('iframe'); iFrame.src = frameSrc; iFrame.id = TS_EMBED_ID; iFrame.setAttribute('data-ts-iframe', 'true'); // according to screenfull.js documentation // allowFullscreen, webkitallowfullscreen and mozallowfullscreen must be // true iFrame.allowFullscreen = true; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore iFrame.webkitallowfullscreen = true; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore iFrame.mozallowfullscreen = true; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore iFrame.allow = 'clipboard-read; clipboard-write; fullscreen; local-network-access;'; const frameParams = this.viewConfig.frameParams; const { height: frameHeight, width: frameWidth, ...restParams } = frameParams || {}; const width = getCssDimension(frameWidth || DEFAULT_EMBED_WIDTH); const height = getCssDimension(frameHeight || DEFAULT_EMBED_HEIGHT); setAttributes(iFrame, restParams); iFrame.style.width = `${width}`; iFrame.style.height = `${height}`; // Set minimum height to the frame so that, // scaling down on the fullheight doesn't make it too small. iFrame.style.minHeight = `${height}`; iFrame.style.border = '0'; iFrame.name = 'ThoughtSpot Embedded Analytics'; return iFrame; } /** * Returns true if this embed instance is configured for pre-rendering. */ protected isPreRenderEmbed() { return !!this.viewConfig.preRenderId; } protected handleInsertionIntoDOM(child: string | Node): void { if (this.isPreRenderEmbed()) { this.insertIntoDOMForPreRender(child); } else { this.insertIntoDOM(child); } if (this.insertedDomEl instanceof Node) { (this.insertedDomEl as any)[this.embedNodeKey] = this; } if (this.preRenderWrapper) { (this.preRenderWrapper as any)[this.embedNodeKey] = this; } } /** * Renders the embedded ThoughtSpot app in an iframe and sets up * event listeners. * @param url - The URL of the embedded ThoughtSpot app. */ protected async renderIFrame(url: string): Promise<any> { if (this.isError) { return null; } if (!this.thoughtSpotHost) { this.throwInitError(); } if (url.length > URL_MAX_LENGTH) { // warn: The URL is too long } return renderInQueue((nextInQueue) => { const initTimestamp = Date.now(); this.executeCallbacks(EmbedEvent.Init, { data: { timestamp: initTimestamp, }, type: EmbedEvent.Init, }); uploadMixpanelEvent(MIXPANEL_EVENT.VISUAL_SDK_RENDER_START); // Always subscribe to network events, regardless of auth status this.subscribeToNetworkEvents(); return getAuthPromise() ?.then((isLoggedIn: boolean) => { if (!isLoggedIn) { this.handleInsertionIntoDOM(this.embedConfig.loginFailedMessage); return; } this.setIframeElement(this.iFrame || this.createIframeEl(url)); this.iFrame.addEventListener('load', () => { nextInQueue(); const loadTimestamp = Date.now(); this.executeCallbacks(EmbedEvent.Load, { data: { timestamp: loadTimestamp, }, type: EmbedEvent.Load, }); uploadMixpanelEvent(MIXPANEL_EVENT.VISUAL_SDK_RENDER_COMPLETE, { elWidth: this.iFrame.clientWidth, elHeight: this.iFrame.clientHeight, timeTookToLoad: loadTimestamp - initTimestamp, }); // Send info event if preauth cache is enabled if (this.isPreAuthCacheEnabled()) { getPreauthInfo().then((data) => { if (data?.info) { this.trigger(HostEvent.InfoSuccess, data); } }); } // Setup fullscreen change handler after iframe is // loaded and ready this.setupFullscreenChangeHandler(); }); this.iFrame.addEventListener('error', () => { nextInQueue(); }); this.handleInsertionIntoDOM(this.iFrame); const prefetchIframe = document.querySelectorAll('.prefetchIframe'); if (prefetchIframe.length) { prefetchIframe.forEach((el) => { el.remove(); }); } // Subscribe to message events only after successful // auth and iframe setup this.subscribeToMessageEvents(); }) .catch((error) => { nextInQueue(); uploadMixpanelEvent(MIXPANEL_EVENT.VISUAL_SDK_RENDER_FAILED, { error: JSON.stringify(error), }); this.handleInsertionIntoDOM(this.embedConfig.loginFailedMessage); this.handleError({ errorType: ErrorDetailsTypes.API, message: error.message || ERROR_MESSAGE.LOGIN_FAILED, code: EmbedErrorCodes.LOGIN_FAILED, error: error, }); }); }); } protected createPreRenderWrapper(): HTMLDivElement { const preRenderIds = this.getPreRenderIds(); document.getElementById(preRenderIds.wrapper)?.remove(); const preRenderWrapper = document.createElement('div'); preRenderWrapper.id = preRenderIds.wrapper; const initialPreRenderWrapperStyle = { position: 'absolute', top: '0', left: '0', width: '100vw', height: '100vh', }; setStyleProperties(preRenderWrapper, initialPreRenderWrapperStyle); return preRenderWrapper; } protected preRenderWrapper: HTMLElement; protected preRenderChild: HTMLElement; /** * Checks for an existing pre-rendered component and connects to it. * * If a matching pre-rendered component is found in the DOM, this method * sets the internal properties of the embed object to reference it. * * @returns True if a connection was successfully established, false otherwise. */ protected connectPreRendered(): boolean { const preRenderIds = this.getPreRenderIds(); const preRenderWrapperElement = document.getElementById(preRenderIds.wrapper); this.preRenderWrapper = this.preRenderWrapper || preRenderWrapperElement; this.preRenderChild = this.preRenderChild || document.getElementById(preRenderIds.child); if (this.preRenderWrapper && this.preRenderChild) { this.isPreRendered = true; if (this.preRenderChild instanceof HTMLIFrameElement) { this.setIframeElement(this.preRenderChild); } this.isRendered = true; } return this.isPreRenderConnected(); } protected isPreRenderConnected(): boolean { return Boolean(this.preRenderWrapper && this.preRenderChild); } protected createPreRenderChild(child: string | Node): HTMLElement { const preRenderIds = this.getPreRenderIds(); document.getElementById(preRenderIds.child)?.remove(); if (child instanceof HTMLElement) { child.id = preRenderIds.child; return child; } const divChildNode = document.createElement('div'); setStyleProperties(divChildNode, { width: '100%', height: '100%' }); divChildNode.id = preRenderIds.child; if (typeof child === 'string') { divChildNode.innerHTML = child; } else { divChildNode.appendChild(child); } return divChildNode; } /** * Creates the in-flow placeholder div inserted into the host element when * showPreRender() is called. The wrapper observes this element to stay * aligned with the host layout. */ private createPreRenderPlaceholder(): HTMLDivElement { const placeholder = document.createElement('div'); const id = this.getPreRenderIds(); const { width: frameWidth, height: frameHeight } = this.viewConfig.frameParams || {}; const width = getCssDimension(frameWidth || DEFAULT_EMBED_WIDTH); const height = getCssDimension(frameHeight || DEFAULT_EMBED_HEIGHT); placeholder.style.width = width; placeholder.style.height = height; // we can improve this , lol placeholder.id = id.placeHolder; return placeholder; } /** * Resolves the configured preRenderContainer to a live element, falling * back to `document.body`. A string selector is re-queried on every call so * a remounted container (E.g.: React replacing the node) resolves to the * fresh element; an element passed directly cannot be re-resolved. */ private resolvePreRenderContainerTarget(): HTMLElement { const containerConfig = this.viewConfig.preRenderContainer; let container: Element | null = null; if (typeof containerConfig === 'string') { try { // Resolve against the host's shadow root too, so a selector can // target a container inside the same shadow DOM as the embed — // document.querySelector alone cannot pierce shadow boundaries. container = querySelectorAcrossShadowRoot(containerConfig, this.hostElement); } catch (e) { logger.error(`Invalid CSS selector for preRenderContainer: ${containerConfig}`, e); } } else if (containerConfig) { container = containerConfig; } return (container as HTMLElement) ?? document.body; } /** * Makes the container a positioning context for the absolutely positioned * wrapper, stashing the original inline `position` on the element (once) so * destroy() can restore it exactly, leaving no trace. Recording it on the * element rather than per-instance lets the override be reverted even when * embeds share the same container. */ private applyPreRenderContainerPositioning(container: HTMLElement): void { if (container === document.body) { return; } const pos = window.getComputedStyle(container).position; if (pos === 'static') { if (container.dataset[PRERENDER_CONTAINER_ORIGINAL_POSITION_KEY] === undefined) { container.dataset[ PRERENDER_CONTAINER_ORIGINAL_POSITION_KEY ] = container.style.position; } container.style.position = 'relative'; } } /** * Re-attaches the wrapper to a live container when the previously resolved * one has been detached or no longer holds the wrapper — e.g. the host app * remounted a custom preRenderContainer, which would otherwise leave a stale * reference and collapse the wrapper. Only string selectors can be * re-resolved; a container passed as an element is left untouched. */ private reconcilePreRenderContainer(): void { const wrapper = this.preRenderWrapper; const stored = this.preRenderContainerEl; // Nothing to reconcile until this instance resolved its container. if (!wrapper || !stored) { return; } const storedIsLive = stored === document.body || document.contains(stored); if (storedIsLive && stored.contains(wrapper)) { return; } const resolved = this.resolvePreRenderContainerTarget(); // Re-resolution yielded the same (still stale) element — nothing we can // do, e.g. a detached container passed as an HTMLElement. if (resolved === stored && stored.contains(wrapper)) { return; } if (this.containerScrollListener && stored !== resolved) { if (stored !== document.body) { stored.removeEventListener('scroll', this.containerScrollListener); } if (resolved !== document.body) { resolved.addEventListener('scroll', this.containerScrollListener); } } this.applyPreRenderContainerPositioning(resolved); if (wrapper.parentNode !== resolved) { resolved.appendChild(wrapper); } this.preRenderContainerEl = resolved; } protected insertIntoDOMForPreRender(child: string | Node): void { const preRenderChild = this.createPreRenderChild(child); const preRenderWrapper = this.createPreRenderWrapper(); preRenderWrapper.appendChild(preRenderChild); this.preRenderChild = preRenderChild; this.preRenderWrapper = preRenderWrapper; if (preRenderChild instanceof HTMLIFrameElement) { this.setIframeElement(preRenderChild); } if (this.iFrame) { this.iFrame.style.height = '100%'; this.iFrame.style.width = '100%'; } if (this.showPreRenderByDefault) { this.showPreRender(); } else { this.hidePreRender(); } const targetContainer = this.resolvePreRenderContainerTarget(); this.preRenderContainerEl = targetContainer; this.applyPreRenderContainerPositioning(targetContainer); targetContainer.appendChild(preRenderWrapper); } private showPreRenderByDefault = false; protected insertIntoDOM(child: string | Node): void { if (this.viewConfig.insertAsSibling) { if (typeof child === 'string') { const div = document.createElement('div'); div.innerHTML = child; div.id = TS_EMBED_ID; child = div; } if (this.hostElement.nextElementSibling?.id === TS_EMBED_ID) { this.hostElement.nextElementSibling.remove(); } this.hostElement.parentElement.insertBefore(child, this.hostElement.nextSibling); this.insertedDomEl = child; } else if (typeof child === 'string') { this.hostElement.innerHTML = child; this.insertedDomEl = this.hostElement.children[0]; } else { this.hostElement.innerHTML = ''; this.hostElement.appendChild(child); this.insertedDomEl = child; } } /** * Sets the height of the iframe * @param height The height in pixels */ protected setIFrameHeight(height: number | string): void { if (this.isPreRendered) { if (this.insertedDomEl) { (this.insertedDomEl as HTMLElement).style.height = getCssDimension(height); } else if (this.preRenderWrapper) { this.preRenderWrapper.style.height = getCssDimension(height); } } else { // normal (non-preRender) mode: size the iframe directly this.iFrame.style.height = getCssDimension(height); } } /** * We can process the customer given payload before sending it to the embed port * Embed event handler -> responder -> createEmbedEventResponder -> send response * @param eventPort The event port for a specific MessageChannel * @param eventType The event type * @returns */ protected createEmbedEventResponder = ( eventPort: MessagePort | void, eventType: EmbedEvent, ) => { const getPayloadToSend = (payload: any) => { if (eventType === EmbedEvent.OnBeforeGetVizDataIntercept) { return processLegacyInterceptResponse(payload); } if (eventType === EmbedEvent.ApiIntercept) { return processApiInterceptResponse(payload); } return payload; }; return (payload: any) => { const payloadToSend = getPayloadToSend(payload); this.triggerEventOnPort(eventPort, payloadToSend); }; }; private shouldSkipEvent(eventType: EmbedEvent, data: any): boolean { const errorType = data?.errorType ?? data?.data?.code; if ( eventType === EmbedEvent.Error && errorType === EmbedErrorCodes.HOST_EVENT_VALIDATION && (!getHostEventsConfig(this.viewConfig).useHostEventsV2 || getHo