/** * Copyright (c) 2022 * * Base classes * @summary Base classes * @author Ayon Ghosh */ import isEqual from 'lodash/isEqual'; import isEmpty from 'lodash/isEmpty'; import isObject from 'lodash/isObject'; import { UIPassthroughArrayResponse, UIPassthroughEvent, UIPassthroughRequest, } from '../contracts/ui-passthrough-contracts'; // Contract resolution comes from the shared contracts module (the single // source of truth for event payload shapes) rather than the legacy // UI-passthrough-only mapping. import { HostEventRequest, TriggerData, TriggerResponse, } from '../contracts/host-event-contracts'; import { EmbedEventPayload } from '../contracts/embed-event-payloads'; import { isMessageFromIframe } from '../utils/transport/iframe-transport'; import { logger } from '../utils/logger'; import { getAuthenticationToken } from '../authToken'; import { AnswerService } from '../utils/graphql/answerService/answerService'; import { getEncodedQueryParamsString, getCssDimension, embedEventStatus, setAttributes, getCustomisations, getRuntimeFilters, getDOMNode, querySelectorAcrossShadowRoot, getFilterQuery, getQueryParamString, getRuntimeParameters, setStyleProperties, removeStyleProperties, isUndefined, getHostEventsConfig, getValueFromWindow, deserializeParam, } 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, EmbedErrorSeverity, ErrorDetailsTypes, EmbedErrorCodes, MessagePayload, ContextType, ContextObject, PreRenderConfig, BaseViewConfig, } 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 container applies UpdateEmbedParams through React state, so a delivered // post is not the same as the params being in effect. const UPDATE_EMBED_PARAMS_SETTLE_MS = 200; // The container ignores runtimeFilterParams/runtimeParameterParams unless truthy, so // null or '' leaves the previous embed's values in place. '&' is truthy and parses to {}. const NO_RUNTIME_PARAMS = '&'; /** * 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 = {}; /** * 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 embedContainerLoadedKey = '__tsEmbedContainerLoaded'; 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; /** * 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 = document.body; 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, severity: EmbedErrorSeverity.SEV1, 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, { severity: EmbedErrorSeverity.SEV3, ...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 = {}; /** * 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, severity: EmbedErrorSeverity.SEV2, 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) => { const eventType = this.getEventType(event); const eventPort = this.getEventPort(event); const eventData = this.formatEventData(event, eventType); if (isMessageFromIframe(event, this.iFrame, this.thoughtSpotHost)) { 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 { 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 { 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() { const queryParams = this.getEmbedParamsObject(); // Values are URL-serialized (e.g. dataSources as '["guid"]'); parse // them back so the event payload matches a URL load (SCAL-334713). Object.keys(queryParams).forEach((key) => { queryParams[key] = deserializeParam(queryParams[key]); }); const appInitData = await this.getAppInitData(); return { ...this.viewConfig, ...queryParams, ...appInitData, // A show cycle has no URL to carry these, so the payload always states them // — including "none", which is the case that leaks the previous filters. runtimeFilterParams: getFilterQuery(this.viewConfig.runtimeFilters ?? []) || NO_RUNTIME_PARAMS, runtimeParameterParams: getRuntimeParameters(this.viewConfig.runtimeParameters ?? []) || NO_RUNTIME_PARAMS, }; } /** * Common query params set for all the embed modes. * @param queryParams * @returns queryParams */ protected getBaseQueryParams(queryParams: Record = {}) { 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, overrideHistoryState, 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 (overrideHistoryState !== undefined) { queryParams[Param.OverrideHistoryState] = overrideHistoryState; } 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 a merged {@link PreRenderConfig} object where each key prefers the * value from `preRenderConfig` over its deprecated top-level `viewConfig` * counterpart (`id`/`preRenderId`, `containerSelector`/`preRenderContainer`, * `doNotTrackSize`/`doNotTrackPreRenderSize`) for backward compatibility. */ protected getPreRenderConfig(): PreRenderConfig { const viewCfg = this.viewConfig as BaseViewConfig; const preRenderCfg = viewCfg.preRenderConfig ?? {}; return { ...preRenderCfg, id: preRenderCfg.id ?? viewCfg.preRenderId, containerSelector: preRenderCfg.containerSelector ?? viewCfg.preRenderContainer, doNotTrackSize: preRenderCfg.doNotTrackSize ?? viewCfg.doNotTrackPreRenderSize, }; } /** * Returns true if this embed instance is configured for pre-rendering. */ protected isPreRenderEmbed() { return !!this.getPreRenderConfig().id; } 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 { 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, severity: EmbedErrorSeverity.SEV1, 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; } // TODO(SCAL-338011): move the pre-render code out to its own file, the way // full height did. It is spread across this class and getting messy. 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; this.inheritPreRenderContainer(); } 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.getPreRenderConfig().containerSelector; 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; } private inheritPreRenderContainer(): void { const preRenderedObject = this.getPreRenderObj(); if (!preRenderedObject || preRenderedObject === (this as TsEmbed)) { return; } const ownerContainer = preRenderedObject.preRenderContainerEl ?? document.body; if ( this.getPreRenderConfig().containerSelector && this.resolvePreRenderContainerTarget() !== ownerContainer ) { logger.warn( 'preRenderContainer is applied only by the component that creates the preRender; ' + 'the one passed here is ignored. Set it on the PreRender component instead.', ); } this.preRenderContainerEl = ownerContainer; this.applyPreRenderContainerPositioning(); } private getCustomPreRenderContainer(): HTMLElement | null { const container = this.preRenderContainerEl; return container && container !== document.body ? container : null; } /** * Makes the resolved 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(): void { const container = this.getCustomPreRenderContainer(); if (!container) { 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.preRenderContainerEl = resolved; this.applyPreRenderContainerPositioning(); if (wrapper.parentNode !== resolved) { resolved.appendChild(wrapper); } } 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.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 || getHostEventsConfig(this.viewConfig).shouldBypassPayloadValidation) ) { logger.warn(`Host Event Validation failed: ${data?.data?.message}`); return true; } return false; } /** * Executes all registered event handlers for a particular event type * @param eventType The event type * @param data The payload invoked with the event handler * @param eventPort The event Port for a specific MessageChannel */ protected executeCallbacks( eventType: EmbedEvent, data: any, eventPort?: MessagePort | void, ): void { if (this.shouldSkipEvent(eventType, data)) return; const eventHandlers = this.eventHandlerMap.get(eventType) || []; const allHandlers = this.eventHandlerMap.get(EmbedEvent.ALL) || []; const callbacks = [...eventHandlers, ...allHandlers]; const dataStatus = data?.status || embedEventStatus.END; callbacks.forEach((callbackObj) => { if ( // When start status is true it trigger only start releated // payload (callbackObj.options.start && dataStatus === embedEventStatus.START) || // When start status is false it trigger only end releated // payload (!callbackObj.options.start && dataStatus === embedEventStatus.END) ) { const responder = this.createEmbedEventResponder(eventPort, eventType); callbackObj.callback(data, responder); } }); } /** * Returns the ThoughtSpot hostname or IP address. */ protected getThoughtSpotHost(): string { return this.thoughtSpotHost; } /** * Gets the v1 event type (if applicable) for the EmbedEvent type * @param eventType The v2 event type * @returns The corresponding v1 event type if one exists * or else the v2 event type itself */ protected getCompatibleEventType(eventType: EmbedEvent): EmbedEvent { return V1EventMap[eventType] || eventType; } /** * Registers an event listener to trigger an alert when the ThoughtSpot app * sends an event of a particular message type to the host application. * @param messageType The message type * @param callback A callback as a function * @param options The message options * @param isSelf * @param isRegisteredBySDK * @example * ```js * tsEmbed.on(EmbedEvent.Error, (data) => { * console.error(data); * }); * ``` * @example * ```js * tsEmbed.on(EmbedEvent.Save, (data) => { * console.log("Answer save clicked", data); * }, { * start: true // This will trigger the callback on start of save * }); * ``` */ public on( messageType: EmbedEventT, callback: ( payload: EmbedEventPayload, responder?: (data: any) => void, ) => void, options: MessageOptions = { start: false }, isRegisteredBySDK = false, ): typeof TsEmbed.prototype { uploadMixpanelEvent(`${MIXPANEL_EVENT.VISUAL_SDK_ON}-${messageType}`, { isRegisteredBySDK, }); if (this.isRendered) { logger.warn('Please register event handlers before calling render'); } const callbacks = this.eventHandlerMap.get(messageType) || []; callbacks.push({ options, callback }); this.eventHandlerMap.set(messageType, callbacks); return this; } /** * Removes an event listener for a particular event type. * @param messageType The message type * @param callback The callback to remove * @example * ```js * const errorHandler = (data) => { console.error(data); }; * tsEmbed.on(EmbedEvent.Error, errorHandler); * tsEmbed.off(EmbedEvent.Error, errorHandler); * ``` */ public off(messageType: EmbedEvent, callback: MessageCallback): typeof TsEmbed.prototype { const callbacks = this.eventHandlerMap.get(messageType) || []; const index = callbacks.findIndex((cb) => cb.callback === callback); if (index > -1) { callbacks.splice(index, 1); } return this; } /** * Triggers an event on specific Port registered against * for the EmbedEvent * @param eventType The message type * @param data The payload to send * @param eventPort * @param payload */ private triggerEventOnPort(eventPort: MessagePort | void, payload: any) { if (eventPort) { try { eventPort.postMessage({ type: payload.type, data: payload.data, }); } catch (e) { eventPort.postMessage({ error: e }); logger.log(e); } } else { logger.log('Event Port is not defined'); } } /** * @hidden * Internal state to track if the embed container is loaded. * This is used to trigger events after the embed container is loaded. */ public isEmbedContainerLoaded = false; /** * @hidden * Internal state to track the callbacks to be executed after the embed container * is loaded. * This is used to trigger events after the embed container is loaded. */ private embedContainerReadyCallbacks: Array<() => void> = []; protected getPreRenderObj(): T { const embedObj = (this.preRenderWrapper as any)?.[this.embedNodeKey] as T; if (embedObj === (this as any)) { logger.debug('embedObj is same as this'); } return embedObj; } protected takeOverPreRender(): void { if (!this.preRenderWrapper) return; (this.preRenderWrapper as any)[this.embedNodeKey] = this; } // The flag lives on the wrapper because it describes the iframe: an instance // hidden when the container announced itself would otherwise report false forever. private markEmbedContainerLoaded() { this.isEmbedContainerLoaded = true; if (this.preRenderWrapper) { (this.preRenderWrapper as any)[this.embedContainerLoadedKey] = true; } } private checkEmbedContainerLoaded() { if (this.isEmbedContainerLoaded) return true; if ((this.preRenderWrapper as any)?.[this.embedContainerLoadedKey]) { this.isEmbedContainerLoaded = true; return true; } // Older builds stamped the flag on the instance, not the wrapper. const preRenderObj = this.getPreRenderObj(); if (preRenderObj && preRenderObj.isEmbedContainerLoaded) { this.isEmbedContainerLoaded = true; } return this.isEmbedContainerLoaded; } private executeEmbedContainerReadyCallbacks() { logger.debug('executePendingEvents', this.embedContainerReadyCallbacks); this.embedContainerReadyCallbacks.forEach((callback) => { callback?.(); }); this.embedContainerReadyCallbacks = []; } /** * Executes a callback after the embed container is loaded. * @param callback The callback to execute */ protected executeAfterEmbedContainerLoaded(callback: () => void) { if (this.checkEmbedContainerLoaded()) { callback?.(); } else { logger.debug('pushing callback to embedContainerReadyCallbacks', callback); this.embedContainerReadyCallbacks.push(callback); } } protected createEmbedContainerHandler = (source: EmbedEvent.AuthInit | EmbedEvent.EmbedListenerReady) => () => { const processEmbedContainerReady = () => { logger.debug('processEmbedContainerReady'); this.markEmbedContainerLoaded(); this.executeEmbedContainerReadyCallbacks(); }; if (source === EmbedEvent.AuthInit) { const AUTH_INIT_FALLBACK_DELAY = 1000; // Wait for 1 second to ensure the embed container is loaded // This is a workaround to ensure the embed container is loaded // this is needed until all clusters have EmbedListenerReady event setTimeout(processEmbedContainerReady, AUTH_INIT_FALLBACK_DELAY); } else if (source === EmbedEvent.EmbedListenerReady) { processEmbedContainerReady(); } }; /** * Triggers an event to the embedded app * * Payload typing: from SDK 1.52.0 (ThoughtSpot Cloud 26.9.0.cl), unknown fields * on a known event's payload fail to compile. From SDK 1.54.0 (26.11.0.cl) the * payload is checked strictly against the event contract — update call sites now. * @param {HostEvent} messageType The event type * @param {TriggerData} data The payload, typed against the event's contract * @param {ContextType} context Optional context type to specify the context from which the event is triggered. * Use ContextType.Search for search answer context, ContextType.Answer for answer/explore context, * ContextType.Liveboard for liveboard context, or ContextType.Spotter for spotter context. * Available from SDK version 1.45.2 | ThoughtSpot: 26.3.0.cl * @returns A promise that resolves with the response from the embedded app * @example * ```js * // Trigger Pin event with context (SDK: 1.45.2+) * import { HostEvent, ContextType } from '@thoughtspot/visual-embed-sdk'; * embed.trigger(HostEvent.Pin, { * vizId: "123", * liveboardId: "456" * }, ContextType.Search); * ``` * @version SDK: 1.45.2 | ThoughtSpot: 26.3.0.cl (for context parameter) */ public async trigger< HostEventT extends HostEvent, PayloadT = HostEventRequest, ContextT extends ContextType = ContextType, >( messageType: HostEventT, // Contract shape is the contextual type: payload fields autocomplete // and unknown fields on object literals are flagged. Strict checks // land in SDK 1.54.0 — see TriggerData. data: TriggerData = {} as any, context?: ContextT, ): Promise> { uploadMixpanelEvent(`${MIXPANEL_EVENT.VISUAL_SDK_TRIGGER}-${messageType}`); if (!this.isRendered) { this.handleError({ errorType: ErrorDetailsTypes.VALIDATION_ERROR, message: ERROR_MESSAGE.RENDER_BEFORE_EVENTS_REQUIRED, code: EmbedErrorCodes.RENDER_NOT_CALLED, error: ERROR_MESSAGE.RENDER_BEFORE_EVENTS_REQUIRED, }); return null; } if (!messageType) { this.handleError({ errorType: ErrorDetailsTypes.VALIDATION_ERROR, message: ERROR_MESSAGE.HOST_EVENT_TYPE_UNDEFINED, code: EmbedErrorCodes.HOST_EVENT_TYPE_UNDEFINED, error: ERROR_MESSAGE.HOST_EVENT_TYPE_UNDEFINED, }); return null; } // Check if iframe exists before triggering - // this prevents the error when auth fails if (!this.iFrame) { logger.debug( `Cannot trigger ${messageType} - iframe not available (likely due to auth failure)`, ); return null; } // send an empty object, this is needed for liveboard default handlers return this.hostEventClient.triggerHostEvent(messageType, data, context).catch( ( err: Error & { isValidationError?: boolean; embedErrorDetails?: { errorType: ErrorDetailsTypes; message: string; code: EmbedErrorCodes; error: string; }; }, ): Promise => { if (err?.isValidationError) { const errorDetails = err.embedErrorDetails ?? { errorType: ErrorDetailsTypes.VALIDATION_ERROR, message: err.message || ERROR_MESSAGE.UPDATEFILTERS_INVALID_PAYLOAD, code: EmbedErrorCodes.UPDATEFILTERS_INVALID_PAYLOAD, error: err.message, }; this.handleError(errorDetails); } throw err; }, ); } /** * Triggers an event to the embedded app, skipping the UI flow. * @param {UIPassthroughEvent} apiName - The name of the API to be triggered. * @param {UIPassthroughRequest} parameters - The parameters to be passed to the API. * @returns {Promise} - A promise that resolves with the response * from the embedded app. */ public async triggerUIPassThrough( apiName: UIPassthroughEventT, parameters: UIPassthroughRequest, ): Promise> { const response = this.hostEventClient.triggerUIPassthroughApi(apiName, parameters); return response; } /** * Marks the ThoughtSpot object to have been rendered * Needs to be overridden by subclasses to do the actual * rendering of the iframe. * @param args */ public async render(): Promise { uploadMixpanelEvent(MIXPANEL_EVENT.VISUAL_SDK_RENDER_CALLED, { embedComponentType: this.viewConfig.embedComponentType, }); if (!getIsInitCalled()) { logger.error(ERROR_MESSAGE.RENDER_CALLED_BEFORE_INIT); } if (this.shouldWaitForRenderPromise) await this.isReadyForRenderPromise; this.isRendered = true; return this; } public getIframeSrc(): string { return ''; } protected handleRenderForPrerender() { return this.render(); } /** * Context object for the embedded component. * @returns {ContextObject} The current context object containing the page type and object ids. * @example * ```js * const context = await embed.getCurrentContext(); * console.log(context); * * // Example output * { * stack: [ * { * name: 'Liveboard', * type: ContextType.Liveboard, * objectIds: { * liveboardId: '123', * }, * }, * ], * currentContext: { * name: 'Liveboard', * type: ContextType.Liveboard, * objectIds: { * liveboardId: '123', * }, * }, * } * ``` * @version SDK: 1.45.2 | ThoughtSpot: 26.3.0.cl */ public async getCurrentContext(): Promise { return new Promise((resolve) => { this.executeAfterEmbedContainerLoaded(async () => { const context = await this.trigger(HostEvent.GetPageContext, {}); resolve(context); }); }); } /** * Generates the event name for a "Subscribed" embed event. * * This helper appends the "Subscribed" suffix to a given host or action event, * allowing you to listen for subscription lifecycle events in a consistent format. * * @param eventName - The host or action event to generate the subscribed event name for. * @returns The formatted event name (e.g., "Save Subscribed"). * * @version SDK: 1.47.2 | ThoughtSpot: 26.3.0.cl */ public subscribedEvent(eventName: HostEvent | Action): string { return `${eventName} ${EmbedEvent.Subscribed}`; } /** * Creates the preRender shell * @param showPreRenderByDefault - Show the preRender after render, hidden by default */ public async preRender( showPreRenderByDefault = false, replaceExistingPreRender = false, ): Promise { uploadMixpanelEvent(MIXPANEL_EVENT.VISUAL_SDK_PRE_RENDER, { showPreRenderByDefault, replaceExistingPreRender, }); if (!this.getPreRenderConfig().id) { logger.error(ERROR_MESSAGE.PRERENDER_ID_MISSING); return this; } this.isPreRendered = true; this.showPreRenderByDefault = showPreRenderByDefault; const isAlreadyRendered = this.connectPreRendered(); if (isAlreadyRendered && !replaceExistingPreRender) { if (this.showPreRenderByDefault) { this.showPreRender(); } return this; } return this.handleRenderForPrerender(); } /** * Get the Post Url Params for THOUGHTSPOT from the current * host app URL. * THOUGHTSPOT URL params starts with a prefix "ts-" * @version SDK: 1.14.0 | ThoughtSpot: 8.4.0.cl, 8.4.1-sw */ public getThoughtSpotPostUrlParams( additionalParams: { [key: string]: string | number } = {}, ): string { const urlHash = window.location.hash; const queryParams = window.location.search; const postHashParams = urlHash.split('?'); const postURLParams = postHashParams[postHashParams.length - 1]; const queryParamsObj = new URLSearchParams(queryParams); const postURLParamsObj = new URLSearchParams(postURLParams); const params = new URLSearchParams(); const addKeyValuePairCb = (value: string, key: string): void => { if (key.startsWith(THOUGHTSPOT_PARAM_PREFIX)) { params.append(key, value); } }; queryParamsObj.forEach(addKeyValuePairCb); postURLParamsObj.forEach(addKeyValuePairCb); Object.entries(additionalParams).forEach(([k, v]) => params.append(k, v as string)); let tsParams = params.toString(); tsParams = tsParams ? `?${tsParams}` : ''; return tsParams; } /** * Reverts the custom preRenderContainer's `position` to the value it had * before we overrode it to `relative` (see insertIntoDOMForPreRender). * * We restore the original inline value rather than forcing `static`, and we * skip the restore if another preRender wrapper is still mounted inside the * same container — a shared container still needs the positioning context. */ private restorePreRenderContainerPosition(): void { const container = this.preRenderContainerEl; if (!container || container === document.body) { return; } // Drop our reference up front so a destroyed embed never pins a // detached container in memory; restoration uses the local handle. this.preRenderContainerEl = document.body; const originalPosition = container.dataset[PRERENDER_CONTAINER_ORIGINAL_POSITION_KEY]; if (originalPosition === undefined) { // We never overrode this container's position; nothing to restore. return; } // This instance's own wrapper has already been removed by now, so any // match here belongs to another embed still sharing the container — it // continues to rely on the positioning context, so leave it in place. const hasOtherWrapper = container.querySelector( `[id^="${PRERENDER_WRAPPER_ID_PREFIX}"]`, ); if (hasOtherWrapper) { return; } container.style.position = originalPosition; delete container.dataset[PRERENDER_CONTAINER_ORIGINAL_POSITION_KEY]; } /** * Detaches and clears the container scroll listener, if one is attached. */ private removeContainerScrollListener(): void { if (!this.containerScrollListener) { return; } const customContainer = this.getCustomPreRenderContainer(); customContainer?.removeEventListener('scroll', this.containerScrollListener); this.containerScrollListener = null; } /** * Destroys the ThoughtSpot embed, and remove any nodes from the DOM. * @version SDK: 1.19.1 | ThoughtSpot: * */ public destroy(): void { try { this.removeFullscreenChangeHandler(); this.removeContainerScrollListener(); this.unsubscribeToEvents(); this.preRenderWrapper?.remove(); this.restorePreRenderContainerPosition(); if (!this.isRendered) { return; } if (!getEmbedConfig().waitForCleanupOnDestroy) { this.trigger(HostEvent.DestroyEmbed); this.insertedDomEl?.parentNode?.removeChild(this.insertedDomEl); } else { const cleanupTimeout = getEmbedConfig().cleanupTimeout; Promise.race([ this.trigger(HostEvent.DestroyEmbed), new Promise((resolve) => setTimeout(resolve, cleanupTimeout)), ]) .catch((e) => { logger.log('Error destroying TS Embed', e); }) .finally(() => { try { this.insertedDomEl?.parentNode?.removeChild(this.insertedDomEl); } catch (e) { logger.log('Error removing DOM element on destroy', e); } }); } } catch (e) { logger.log('Error destroying TS Embed', e); } } public getUnderlyingFrameElement(): HTMLIFrameElement { return this.iFrame; } /** * Prerenders a generic instance of the TS component. * This means without the path but with the flags already applied. * This is useful for prerendering the component in the background. * @version SDK: 1.22.0 * @returns */ public async prerenderGeneric(): Promise { if (!getIsInitCalled()) { logger.error(ERROR_MESSAGE.RENDER_CALLED_BEFORE_INIT); } if (this.shouldWaitForRenderPromise) await this.isReadyForRenderPromise; const prerenderFrameSrc = this.getRootIframeSrc(); this.isRendered = true; return this.renderIFrame(prerenderFrameSrc); } // Subclasses that navigate the pre-render on show must await this before // triggering Navigate. Resolves even on failure, so navigation is never blocked. protected preRenderParamsApplied: Promise = Promise.resolve(); protected beforePrerenderVisible(): void { // We can ignore this as its a bit expensive and the newer customers // have moved on to UpdateEmbedParams supported clusters // this.validatePreRenderViewConfig(this.viewConfig); removed in #517 logger.debug('triggering UpdateEmbedParams', this.viewConfig); // Created synchronously: a queued navigation needs something to await // whether the container is already loaded or not. this.preRenderParamsApplied = new Promise((resolve) => { this.executeAfterEmbedContainerLoaded(async () => { try { const params = await this.getUpdateEmbedParamsObject(); this.trigger(HostEvent.UpdateEmbedParams, params); } catch (error) { logger.error(ERROR_MESSAGE.UPDATE_PARAMS_FAILED, error); this.handleError({ errorType: ErrorDetailsTypes.API, message: error?.message || ERROR_MESSAGE.UPDATE_PARAMS_FAILED, code: EmbedErrorCodes.UPDATE_PARAMS_FAILED, error: error?.message || error, }); } finally { setTimeout(resolve, UPDATE_EMBED_PARAMS_SETTLE_MS); } }); }); } /** * Displays the pre-rendered component inside the host element. * If the component has not been pre-rendered yet, it initiates rendering first. * Inserts a placeholder element into the host and positions the pre-render * wrapper to overlay it. */ public async showPreRender(): Promise { uploadMixpanelEvent(MIXPANEL_EVENT.VISUAL_SDK_SHOW_PRE_RENDER, { preRenderId: this.getPreRenderConfig().id, embedComponentType: this.viewConfig.embedComponentType, }); if (this.shouldWaitForRenderPromise) await this.isReadyForRenderPromise; if (!this.getPreRenderConfig().id) { logger.error(ERROR_MESSAGE.PRERENDER_ID_MISSING); return this; } if (!this.isPreRenderConnected()) { // this will call showPreRender down the line return this.preRender(true); } this.isRendered = true; this.beforePrerenderVisible(); if (this.hostElement) { this.insertedDomEl = this.createPreRenderPlaceholder(); if ((this.viewConfig as { fullHeight: boolean }).fullHeight) { // If fullHeight has already sized the wrapper, seed the placeholder // with the same height so syncPreRenderStyle gets an accurate rect. const existingHeight = this.preRenderWrapper.style.height; if (existingHeight) { (this.insertedDomEl as HTMLDivElement).style.height = existingHeight; } } const placeHolderId = this.getPreRenderIds().placeHolder; // Remove any stale placeholder from a previous cycle. It is located // via a subtree-wide querySelector, so it may be nested deeper than a // direct child (E.g.: with fullHeight the host app can wrap it). Use // Element.remove() — which detaches from whatever the real parent is — // rather than hostElement.removeChild(), which throws NotFoundError // when the match is not a direct child. Mirrors the wrapper/child // cleanup in createPreRenderWrapper()/createPreRenderChild(). this.hostElement.querySelector(`#${placeHolderId}`)?.remove(); this.hostElement.appendChild(this.insertedDomEl); this.syncPreRenderStyle(); const customContainer = this.getCustomPreRenderContainer(); if (customContainer && !this.containerScrollListener) { this.containerScrollListener = () => this.syncPreRenderStyle(); customContainer.addEventListener('scroll', this.containerScrollListener); } if (!this.getPreRenderConfig().doNotTrackSize) { const observeTarget = (this.insertedDomEl as HTMLElement) ?? this.hostElement; this.resizeObserver = new ResizeObserver((entries) => { entries.forEach((entry) => { if (entry.target === observeTarget) { this.syncPreRenderStyle(); } }); }); this.resizeObserver.observe(observeTarget); } } removeStyleProperties(this.preRenderWrapper, [ 'z-index', 'opacity', 'pointer-events', 'overflow', ]); this.subscribeToEvents(); // Setup fullscreen change handler for prerendered components if (this.iFrame) { this.setupFullscreenChangeHandler(); } // Last, so everything above still sees the instance being taken over from. this.takeOverPreRender(); return this; } protected getPreRenderPlaceHolderElement() { return this.insertedDomEl as HTMLDivElement; } /** * Synchronizes the style properties of the PreRender component with the embedding * element. This function adjusts the position, width, and height of the PreRender * component * to match the dimensions and position of the embedding element. * @throws {Error} Throws an error if the embedding element (passed as domSelector) * is not defined or not found. */ public syncPreRenderStyle(): void { if (!this.isPreRenderConnected() || !this.getPreRenderPlaceHolderElement()) { logger.error(ERROR_MESSAGE.SYNC_STYLE_CALLED_BEFORE_RENDER); return; } if (!this.getPreRenderPlaceHolderElement().isConnected) { logger.debug('syncPreRenderStyle skipped: placeholder is detached'); return; } // Self-heal if the resolved container was remounted/detached, so we // never measure a stale node (which would collapse the wrapper). this.reconcilePreRenderContainer(); const elBoundingClient = this.getPreRenderPlaceHolderElement().getBoundingClientRect(); const containerEl = this.getCustomPreRenderContainer(); const containerRect = containerEl?.getBoundingClientRect() ?? { x: 0, y: 0 }; const scrollX = containerEl ? containerEl.scrollLeft : window.scrollX; const scrollY = containerEl ? containerEl.scrollTop : window.scrollY; setStyleProperties(this.preRenderWrapper, { top: `${elBoundingClient.y - containerRect.y + scrollY}px`, left: `${elBoundingClient.x - containerRect.x + scrollX}px`, width: `${elBoundingClient.width}px`, height: `${elBoundingClient.height}px`, position: 'absolute', }); } /** * Hides the PreRender component if it is available. * If the component is not preRendered, it issues a warning. */ public hidePreRender(): void { uploadMixpanelEvent(MIXPANEL_EVENT.VISUAL_SDK_HIDE_PRE_RENDER, { preRenderId: this.getPreRenderConfig().id, embedComponentType: this.viewConfig.embedComponentType, }); logger.debug('HidePreRender Called'); if (!this.isPreRenderConnected()) { // if the embed component is not preRendered , nothing to hide logger.warn('PreRender should be called before hiding it using hidePreRender.'); return; } const { zIndex } = this.getPreRenderConfig(); const preRenderHideStyles = { opacity: '0', pointerEvents: 'none', zIndex: zIndex !== undefined ? String(zIndex) : '-1000', position: 'absolute', top: '0', left: '0', overflow: 'hidden', }; setStyleProperties(this.preRenderWrapper, preRenderHideStyles); this.removeContainerScrollListener(); if (this.resizeObserver) { this.resizeObserver.disconnect(); } const placeHolderEle = this.getPreRenderPlaceHolderElement(); if (placeHolderEle) { placeHolderEle.parentElement.removeChild(placeHolderEle); } this.unsubscribeToEvents(); } /** * Retrieves unique HTML element IDs for PreRender-related elements. * These IDs are constructed based on the provided 'preRenderId' from 'viewConfig'. * @returns {object} An object containing the IDs for the PreRender elements. * @property {string} wrapper - The HTML element ID for the PreRender wrapper. * @property {string} child - The HTML element ID for the PreRender child. */ public getPreRenderIds() { const preRenderId = this.getPreRenderConfig().id; return { wrapper: `${PRERENDER_WRAPPER_ID_PREFIX}${preRenderId}`, child: `tsEmbed-pre-render-child-${preRenderId}`, placeHolder: `tsEmbed-pre-render-placeholder-${preRenderId}`, }; } /** * Returns the answerService which can be used to make arbitrary graphql calls on top * session. * @param vizId [Optional] to get for a specific viz in case of a Liveboard. * @version SDK: 1.25.0 | ThoughtSpot: 9.10.0 */ public async getAnswerService(vizId?: string): Promise { const { session } = await this.trigger(HostEvent.GetAnswerSession, vizId ? { vizId } : {}); return new AnswerService(session, null, this.embedConfig.thoughtSpotHost); } /** * Set up fullscreen change detection to automatically trigger ExitPresentMode * when user exits fullscreen mode */ private setupFullscreenChangeHandler() { const embedConfig = getEmbedConfig(); const disableFullscreenPresentation = embedConfig?.disableFullscreenPresentation ?? true; if (disableFullscreenPresentation) { return; } if (this.fullscreenChangeHandler) { document.removeEventListener('fullscreenchange', this.fullscreenChangeHandler); } this.fullscreenChangeHandler = () => { const isFullscreen = !!document.fullscreenElement; if (!isFullscreen) { logger.info('Exited fullscreen mode - triggering ExitPresentMode'); // Only trigger if iframe is available and contentWindow is // accessible if (this.iFrame && this.iFrame.contentWindow) { this.trigger(HostEvent.ExitPresentMode); } else { logger.debug('Skipping ExitPresentMode - iframe contentWindow not available'); } } }; document.addEventListener('fullscreenchange', this.fullscreenChangeHandler); } /** * Remove fullscreen change handler */ private removeFullscreenChangeHandler() { if (this.fullscreenChangeHandler) { document.removeEventListener('fullscreenchange', this.fullscreenChangeHandler); this.fullscreenChangeHandler = null; } } } /** * Base class for embedding v1 experience * Note: The v1 version of ThoughtSpot Blink works on the AngularJS stack * which is currently under migration to v2 * @inheritdoc */ export class V1Embed extends TsEmbed { protected viewConfig: ViewConfig; constructor(domSelector: DOMSelector, viewConfig: ViewConfig) { super(domSelector, viewConfig); this.viewConfig = { excludeRuntimeFiltersfromURL: true, excludeRuntimeParametersfromURL: true, ...viewConfig, }; } /** * Render the app in an iframe and set up event handlers * @param iframeSrc */ protected renderV1Embed(iframeSrc: string): Promise { return this.renderIFrame(iframeSrc); } protected getRootIframeSrc(): string { const queryParams = this.getEmbedParams(); let queryString = queryParams; if (!this.viewConfig.excludeRuntimeParametersfromURL) { const runtimeParameters = this.viewConfig.runtimeParameters; const parameterQuery = getRuntimeParameters(runtimeParameters || []); queryString = [parameterQuery, queryParams].filter(Boolean).join('&'); } if (!this.viewConfig.excludeRuntimeFiltersfromURL) { const runtimeFilters = this.viewConfig.runtimeFilters; const filterQuery = getFilterQuery(runtimeFilters || []); queryString = [filterQuery, queryString].filter(Boolean).join('&'); } return this.viewConfig.enableV2Shell_experimental ? this.getEmbedBasePath(queryString) : this.getV1EmbedBasePath(queryString); } /** * @inheritdoc * @example * ```js * tsEmbed.on(EmbedEvent.Error, (data) => { * console.error(data); * }); * ``` * @example * ```js * tsEmbed.on(EmbedEvent.Save, (data) => { * console.log("Answer save clicked", data); * }, { * start: true // This will trigger the callback on start of save * }); * ``` */ public on( messageType: EmbedEventT, callback: ( payload: EmbedEventPayload, responder?: (data: any) => void, ) => void, options: MessageOptions = { start: false }, ): typeof TsEmbed.prototype { // Mirror the base TsEmbed.on generic signature so the enriched // EmbedEventPayload (e.g. CustomAction's answerService) flows through // the override too, and the class hierarchy stays assignable. const eventType = this.getCompatibleEventType(messageType) as EmbedEventT; return super.on(eventType, callback, options); } /** * Only for testing purposes. * @hidden */ public test__executeCallbacks = this.executeCallbacks; }