@thoughtspot/visual-embed-sdk
Version:
ThoughtSpot Embed SDK
1,357 lines (1,218 loc) • 84.6 kB
text/typescript
/**
* 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