@thoughtspot/visual-embed-sdk
Version:
ThoughtSpot Embed SDK
1,187 lines • 52.1 kB
JavaScript
/**
* 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 { logger } from '../utils/logger';
import { getAuthenticationToken } from '../authToken';
import { AnswerService } from '../utils/graphql/answerService/answerService';
import { getEncodedQueryParamsString, getCssDimension, getOffsetTop, embedEventStatus, setAttributes, getCustomisations, getRuntimeFilters, getDOMNode, getFilterQuery, getQueryParamString, getRuntimeParameters, setStyleProperties, removeStyleProperties, isUndefined, } from '../utils';
import { getThoughtSpotHost, URL_MAX_LENGTH, DEFAULT_EMBED_WIDTH, DEFAULT_EMBED_HEIGHT, getV2BasePath, } from '../config';
import { AuthType, HostEvent, EmbedEvent, Action, Param, ContextMenuTriggerOptions, } from '../types';
import { uploadMixpanelEvent, MIXPANEL_EVENT } from '../mixpanel-service';
import { processEventData, processAuthFailure } from '../utils/processData';
import pkgInfo from '../../package.json';
import { getAuthPromise, renderInQueue, handleAuth, notifyAuthFailure, getInitPromise, getIsInitCalled, } 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';
const { version } = pkgInfo;
/**
* Global prefix for all Thoughtspot postHash Params.
*/
export const THOUGHTSPOT_PARAM_PREFIX = 'ts-';
const TS_EMBED_ID = '_thoughtspot-embed';
/**
* 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 = {};
/**
* Base class for embedding v2 experience
* Note: the v2 version of ThoughtSpot Blink is built on the new stack:
* React+GraphQL
*/
export class TsEmbed {
/**
* Setter for the iframe element
* @param {HTMLIFrameElement} iFrame HTMLIFrameElement
*/
setIframeElement(iFrame) {
this.iFrame = iFrame;
this.hostEventClient.setIframeElement(iFrame);
}
constructor(domSelector, viewConfig) {
/**
* The key to store the embed instance in the DOM node
*/
this.embedNodeKey = '__tsEmbed';
this.isAppInitialized = false;
/**
* 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
*/
this.shouldEncodeUrlQueryParams = false;
this.defaultHiddenActions = [Action.ReportError];
/**
* Handler for fullscreen change events
*/
this.fullscreenChangeHandler = null;
this.subscribedListeners = {};
/**
* Send Custom style as part of payload of APP_INIT
* @param _
* @param responder
*/
this.appInitCb = async (_, responder) => {
try {
const appInitData = await this.getAppInitData();
this.isAppInitialized = true;
responder({
type: EmbedEvent.APP_INIT,
data: appInitData,
});
}
catch (e) {
logger.error(`AppInit failed, Error : ${e === null || e === void 0 ? void 0 : e.message}`);
}
};
/**
* Sends updated auth token to the iFrame to avoid user logout
* @param _
* @param responder
*/
this.updateAuthToken = async (_, responder) => {
const { authType } = this.embedConfig;
let { autoLogin } = this.embedConfig;
// Default autoLogin: true for cookieless if undefined/null, otherwise false
autoLogin = autoLogin !== null && autoLogin !== void 0 ? autoLogin : (authType === AuthType.TrustedAuthTokenCookieless);
if (autoLogin && authType === AuthType.TrustedAuthTokenCookieless) {
try {
const authToken = await getAuthenticationToken(this.embedConfig);
responder({
type: EmbedEvent.AuthExpire,
data: { authToken },
});
}
catch (e) {
logger.error(`${ERROR_MESSAGE.INVALID_TOKEN_ERROR} Error : ${e === null || e === void 0 ? void 0 : e.message}`);
processAuthFailure(e, this.isPreRendered ? this.preRenderWrapper : this.el);
}
}
else if (autoLogin) {
handleAuth();
}
notifyAuthFailure(AuthFailureType.EXPIRY);
};
/**
* Auto Login and send updated authToken to the iFrame to avoid user session logout
* @param _
* @param responder
*/
this.idleSessionTimeout = (_, responder) => {
handleAuth().then(async () => {
let authToken = '';
try {
authToken = await getAuthenticationToken(this.embedConfig);
responder({
type: EmbedEvent.IdleSessionTimeout,
data: { authToken },
});
}
catch (e) {
logger.error(`${ERROR_MESSAGE.INVALID_TOKEN_ERROR} Error : ${e === null || e === void 0 ? void 0 : e.message}`);
processAuthFailure(e, this.isPreRendered ? this.preRenderWrapper : this.el);
}
}).catch((e) => {
logger.error(`Auto Login failed, Error : ${e === null || e === void 0 ? void 0 : e.message}`);
});
notifyAuthFailure(AuthFailureType.IDLE_SESSION_TIMEOUT);
};
/**
* Register APP_INIT event and sendback init payload
*/
this.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);
};
this.showPreRenderByDefault = false;
this.validatePreRenderViewConfig = (viewConfig) => {
var _a;
const preRenderAllowedKeys = ['preRenderId', 'vizId', 'liveboardId'];
const preRenderedObject = (_a = this.insertedDomEl) === null || _a === void 0 ? void 0 : _a[this.embedNodeKey];
if (!preRenderedObject)
return;
if (viewConfig.preRenderId) {
const allOtherKeys = Object.keys(viewConfig).filter((key) => !preRenderAllowedKeys.includes(key) && !key.startsWith('on'));
allOtherKeys.forEach((key) => {
if (!isUndefined(viewConfig[key])
&& !isEqual(viewConfig[key], preRenderedObject.viewConfig[key])) {
logger.warn(`${viewConfig.embedComponentType || 'Component'} was pre-rendered with `
+ `"${key}" as "${JSON.stringify(preRenderedObject.viewConfig[key])}" `
+ `but a different value "${JSON.stringify(viewConfig[key])}" `
+ 'was passed to the Embed component. '
+ 'The new value provided is ignored, the value provided during '
+ 'preRender is used.');
}
});
}
};
this.el = getDOMNode(domSelector);
this.eventHandlerMap = new Map();
this.isError = false;
this.viewConfig = {
excludeRuntimeFiltersfromURL: false,
excludeRuntimeParametersfromURL: false,
...viewConfig,
};
this.registerAppInit();
uploadMixpanelEvent(MIXPANEL_EVENT.VISUAL_SDK_EMBED_CREATE, {
...viewConfig,
});
this.hostEventClient = new HostEventClient(this.iFrame);
this.isReadyForRenderPromise = getInitPromise().then(async () => {
const embedConfig = getEmbedConfig();
this.embedConfig = embedConfig;
if (!embedConfig.authTriggerContainer && !embedConfig.useEventForSAMLPopup) {
this.embedConfig.authTriggerContainer = domSelector;
}
this.thoughtSpotHost = getThoughtSpotHost(embedConfig);
this.thoughtSpotV2Base = getV2BasePath(embedConfig);
this.shouldEncodeUrlQueryParams = embedConfig.shouldEncodeUrlQueryParams;
});
}
/**
* Throws error encountered during initialization.
*/
throwInitError() {
this.handleError('You need to init the ThoughtSpot SDK module first');
}
/**
* Handles errors within the SDK
* @param error The error message or object
*/
handleError(error) {
this.isError = true;
this.executeCallbacks(EmbedEvent.Error, {
error,
});
// Log error
logger.error(error);
}
/**
* Extracts the type field from the event payload
* @param event The window message event
*/
getEventType(event) {
var _a, _b;
// eslint-disable-next-line no-underscore-dangle
return ((_a = event.data) === null || _a === void 0 ? void 0 : _a.type) || ((_b = event.data) === null || _b === void 0 ? void 0 : _b.__type);
}
/**
* Extracts the port field from the event payload
* @param event The window message event
* @returns
*/
getEventPort(event) {
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
*/
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
*/
isFullAppEmbedWithVisiblePrimaryNavbar() {
const appViewConfig = this.viewConfig;
// 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
*/
formatEventData(event, eventType) {
const eventData = {
...event.data,
type: eventType,
};
if (!eventData.data) {
eventData.data = event.data.payload;
}
return eventData;
}
/**
* 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.
*/
subscribeToEvents() {
this.unsubscribeToEvents();
const messageEventListener = (event) => {
const eventType = this.getEventType(event);
const eventPort = this.getEventPort(event);
const eventData = this.formatEventData(event, eventType);
if (event.source === this.iFrame.contentWindow) {
this.executeCallbacks(eventType, processEventData(eventType, eventData, this.thoughtSpotHost, this.isPreRendered ? this.preRenderWrapper : this.el), eventPort);
}
};
window.addEventListener('message', messageEventListener);
const onlineEventListener = (e) => {
this.trigger(HostEvent.Reload);
};
window.addEventListener('online', onlineEventListener);
const offlineEventListener = (e) => {
const offlineWarning = 'Network not Detected. Embed is offline. Please reconnect and refresh';
this.executeCallbacks(EmbedEvent.Error, {
offlineWarning,
});
logger.warn(offlineWarning);
};
window.addEventListener('offline', offlineEventListener);
this.subscribedListeners = {
message: messageEventListener,
online: onlineEventListener,
offline: offlineEventListener,
};
}
unsubscribeToEvents() {
Object.keys(this.subscribedListeners).forEach((key) => {
window.removeEventListener(key, this.subscribedListeners[key]);
});
}
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.el);
throw e;
}
return authToken;
}
async getDefaultAppInitData() {
var _a, _b;
const authToken = await this.getAuthTokenForCookielessInit();
return {
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: ((_a = this.viewConfig) === null || _a === void 0 ? void 0 : _a.hiddenHomeLeftNavItems)
? (_b = this.viewConfig) === null || _b === void 0 ? void 0 : _b.hiddenHomeLeftNavItems
: [],
customVariablesForThirdPartyTools: this.embedConfig.customVariablesForThirdPartyTools || {},
hiddenListColumns: this.viewConfig.hiddenListColumns || [],
};
}
async getAppInitData() {
return this.getDefaultAppInitData();
}
/**
* Constructs the base URL string to load the ThoughtSpot app.
* @param query
*/
getEmbedBasePath(query) {
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}#`;
}
/**
* Common query params set for all the embed modes.
* @param queryParams
* @returns queryParams
*/
getBaseQueryParams(queryParams = {}) {
var _a, _b, _c, _d;
let hostAppUrl = ((_a = window === null || window === void 0 ? void 0 : window.location) === null || _a === void 0 ? void 0 : _a.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 = (_b = this.embedConfig.blockNonEmbedFullAppAccess) !== null && _b !== void 0 ? _b : 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, 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('You cannot have both hidden actions and visible actions');
return queryParams;
}
if (Array.isArray(visibleTabs) && Array.isArray(hiddenTabs)) {
this.handleError('You cannot have both hidden Tabs and visible Tabs');
return queryParams;
}
if (primaryAction) {
queryParams[Param.PrimaryAction] = primaryAction;
}
if (disabledActions === null || disabledActions === void 0 ? void 0 : disabledActions.length) {
queryParams[Param.DisableActions] = disabledActions;
}
if (disabledActionReason) {
queryParams[Param.DisableActionReason] = disabledActionReason;
}
if (exposeTranslationIDs) {
queryParams[Param.ExposeTranslationIDs] = exposeTranslationIDs;
}
queryParams[Param.HideActions] = [...this.defaultHiddenActions, ...(hiddenActions !== null && hiddenActions !== void 0 ? 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 === null || customizations === void 0 ? void 0 : customizations.iconSpriteUrl) || (embedCustomizations === null || embedCustomizations === void 0 ? void 0 : embedCustomizations.iconSpriteUrl);
if (spriteUrl) {
queryParams[Param.IconSpriteUrl] = spriteUrl.replace('https://', '');
}
const stringIDsUrl = ((_c = customizations === null || customizations === void 0 ? void 0 : customizations.content) === null || _c === void 0 ? void 0 : _c.stringIDsUrl)
|| ((_d = embedCustomizations === null || embedCustomizations === void 0 ? void 0 : embedCustomizations.content) === null || _d === void 0 ? void 0 : _d.stringIDsUrl);
if (stringIDsUrl) {
queryParams[Param.StringIDsUrl] = stringIDsUrl;
}
if (showAlerts !== undefined) {
queryParams[Param.ShowAlerts] = showAlerts;
}
if (locale !== undefined) {
queryParams[Param.Locale] = locale;
}
if (linkOverride) {
queryParams[Param.LinkOverride] = linkOverride;
}
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.
*/
getV1EmbedBasePath(queryString) {
const queryParams = this.shouldEncodeUrlQueryParams
? `?base64UrlEncodedFlags=${getEncodedQueryParamsString(queryString)}`
: `?${queryString}`;
const host = this.thoughtSpotHost;
const path = `${host}/${queryParams}#`;
return path;
}
getEmbedParams() {
const queryParams = this.getBaseQueryParams();
return getQueryParamString(queryParams);
}
getRootIframeSrc() {
const query = this.getEmbedParams();
return this.getEmbedBasePath(query);
}
createIframeEl(frameSrc) {
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;';
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;
}
handleInsertionIntoDOM(child) {
if (this.isPreRendered) {
this.insertIntoDOMForPreRender(child);
}
else {
this.insertIntoDOM(child);
}
if (this.insertedDomEl instanceof Node) {
this.insertedDomEl[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.
*/
async renderIFrame(url) {
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) => {
var _a;
const initTimestamp = Date.now();
this.executeCallbacks(EmbedEvent.Init, {
data: {
timestamp: initTimestamp,
},
type: EmbedEvent.Init,
});
uploadMixpanelEvent(MIXPANEL_EVENT.VISUAL_SDK_RENDER_START);
return (_a = getAuthPromise()) === null || _a === void 0 ? void 0 : _a.then((isLoggedIn) => {
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 === null || data === void 0 ? void 0 : 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();
});
}
this.subscribeToEvents();
}).catch((error) => {
nextInQueue();
uploadMixpanelEvent(MIXPANEL_EVENT.VISUAL_SDK_RENDER_FAILED, {
error: JSON.stringify(error),
});
this.handleInsertionIntoDOM(this.embedConfig.loginFailedMessage);
this.handleError(error);
});
});
}
createPreRenderWrapper() {
var _a;
const preRenderIds = this.getPreRenderIds();
(_a = document.getElementById(preRenderIds.wrapper)) === null || _a === void 0 ? void 0 : _a.remove();
const preRenderWrapper = document.createElement('div');
preRenderWrapper.id = preRenderIds.wrapper;
const initialPreRenderWrapperStyle = {
position: 'absolute',
width: '100vw',
height: '100vh',
};
setStyleProperties(preRenderWrapper, initialPreRenderWrapperStyle);
return preRenderWrapper;
}
connectPreRendered() {
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.insertedDomEl = this.preRenderWrapper;
this.isRendered = true;
}
return this.isPreRenderAvailable();
}
isPreRenderAvailable() {
return (this.isRendered
&& this.isPreRendered
&& Boolean(this.preRenderWrapper && this.preRenderChild));
}
createPreRenderChild(child) {
var _a;
const preRenderIds = this.getPreRenderIds();
(_a = document.getElementById(preRenderIds.child)) === null || _a === void 0 ? void 0 : _a.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;
}
insertIntoDOMForPreRender(child) {
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);
}
this.insertedDomEl = preRenderWrapper;
if (this.showPreRenderByDefault) {
this.showPreRender();
}
else {
this.hidePreRender();
}
document.body.appendChild(preRenderWrapper);
}
insertIntoDOM(child) {
var _a;
if (this.viewConfig.insertAsSibling) {
if (typeof child === 'string') {
const div = document.createElement('div');
div.innerHTML = child;
div.id = TS_EMBED_ID;
// eslint-disable-next-line no-param-reassign
child = div;
}
if (((_a = this.el.nextElementSibling) === null || _a === void 0 ? void 0 : _a.id) === TS_EMBED_ID) {
this.el.nextElementSibling.remove();
}
this.el.parentElement.insertBefore(child, this.el.nextSibling);
this.insertedDomEl = child;
}
else if (typeof child === 'string') {
this.el.innerHTML = child;
this.insertedDomEl = this.el.children[0];
}
else {
this.el.innerHTML = '';
this.el.appendChild(child);
this.insertedDomEl = child;
}
}
/**
* Sets the height of the iframe
* @param height The height in pixels
*/
setIFrameHeight(height) {
this.iFrame.style.height = getCssDimension(height);
}
/**
* 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
*/
executeCallbacks(eventType, data, eventPort) {
const eventHandlers = this.eventHandlerMap.get(eventType) || [];
const allHandlers = this.eventHandlerMap.get(EmbedEvent.ALL) || [];
const callbacks = [...eventHandlers, ...allHandlers];
const dataStatus = (data === null || data === void 0 ? void 0 : 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)) {
callbackObj.callback(data, (payload) => {
this.triggerEventOnPort(eventPort, payload);
});
}
});
}
/**
* Returns the ThoughtSpot hostname or IP address.
*/
getThoughtSpotHost() {
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
*/
getCompatibleEventType(eventType) {
return V1EventMap[eventType] || eventType;
}
/**
* Calculates the iframe center for the current visible viewPort
* of iframe using Scroll position of Host App, offsetTop for iframe
* in Host app. ViewPort height of the tab.
* @returns iframe Center in visible viewport,
* Iframe height,
* View port height.
*/
getIframeCenter() {
const offsetTopClient = getOffsetTop(this.iFrame);
const scrollTopClient = window.scrollY;
const viewPortHeight = window.innerHeight;
const iframeHeight = this.iFrame.offsetHeight;
const iframeScrolled = scrollTopClient - offsetTopClient;
let iframeVisibleViewPort;
let iframeOffset;
if (iframeScrolled < 0) {
iframeVisibleViewPort = viewPortHeight - (offsetTopClient - scrollTopClient);
iframeVisibleViewPort = Math.min(iframeHeight, iframeVisibleViewPort);
iframeOffset = 0;
}
else {
iframeVisibleViewPort = Math.min(iframeHeight - iframeScrolled, viewPortHeight);
iframeOffset = iframeScrolled;
}
const iframeCenter = iframeOffset + iframeVisibleViewPort / 2;
return {
iframeCenter,
iframeScrolled,
iframeHeight,
viewPortHeight,
iframeVisibleViewPort,
};
}
/**
* 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
* });
* ```
*/
on(messageType, callback, options = { start: false }, isRegisteredBySDK = false) {
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);
* ```
*/
off(messageType, callback) {
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
*/
triggerEventOnPort(eventPort, payload) {
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');
}
}
/**
* Triggers an event to the embedded app
* @param {HostEvent} messageType The event type
* @param {any} data The payload to send with the message
* @returns A promise that resolves with the response from the embedded app
*/
async trigger(messageType, data = {}) {
uploadMixpanelEvent(`${MIXPANEL_EVENT.VISUAL_SDK_TRIGGER}-${messageType}`);
if (!this.isRendered) {
this.handleError('Please call render before triggering events');
return null;
}
if (!messageType) {
this.handleError('Host event type is undefined');
return null;
}
// send an empty object, this is needed for liveboard default handlers
return this.hostEventClient.triggerHostEvent(messageType, data);
}
/**
* 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<UIPassthroughRequest>} - A promise that resolves with the response
* from the embedded app.
*/
async triggerUIPassThrough(apiName, parameters) {
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
*/
async render() {
if (!getIsInitCalled()) {
logger.error(ERROR_MESSAGE.RENDER_CALLED_BEFORE_INIT);
}
await this.isReadyForRenderPromise;
this.isRendered = true;
return this;
}
getIframeSrc() {
return '';
}
handleRenderForPrerender() {
return this.render();
}
/**
* Creates the preRender shell
* @param showPreRenderByDefault - Show the preRender after render, hidden by default
*/
async preRender(showPreRenderByDefault = false) {
if (!this.viewConfig.preRenderId) {
logger.error(ERROR_MESSAGE.PRERENDER_ID_MISSING);
return this;
}
this.isPreRendered = true;
this.showPreRenderByDefault = showPreRenderByDefault;
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
*/
getThoughtSpotPostUrlParams(additionalParams = {}) {
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, key) => {
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));
let tsParams = params.toString();
tsParams = tsParams ? `?${tsParams}` : '';
return tsParams;
}
/**
* Destroys the ThoughtSpot embed, and remove any nodes from the DOM.
* @version SDK: 1.19.1 | ThoughtSpot: *
*/
destroy() {
var _a;
try {
this.removeFullscreenChangeHandler();
(_a = this.insertedDomEl) === null || _a === void 0 ? void 0 : _a.parentNode.removeChild(this.insertedDomEl);
this.unsubscribeToEvents();
}
catch (e) {
logger.log('Error destroying TS Embed', e);
}
}
getUnderlyingFrameElement() {
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
*/
async prerenderGeneric() {
if (!getIsInitCalled()) {
logger.error(ERROR_MESSAGE.RENDER_CALLED_BEFORE_INIT);
}
await this.isReadyForRenderPromise;
const prerenderFrameSrc = this.getRootIframeSrc();
this.isRendered = true;
return this.renderIFrame(prerenderFrameSrc);
}
beforePrerenderVisible() {
// Override in subclass
}
/**
* Displays the PreRender component.
* If the component is not preRendered, it attempts to create and render it.
* Also, synchronizes the style of the PreRender component with the embedding
* element.
*/
async showPreRender() {
if (!this.viewConfig.preRenderId) {
logger.error(ERROR_MESSAGE.PRERENDER_ID_MISSING);
return this;
}
if (!this.isPreRenderAvailable()) {
const isAvailable = this.connectPreRendered();
if (!isAvailable) {
// if the Embed component is not preRendered , Render it now and
return this.preRender(true);
}
this.validatePreRenderViewConfig(this.viewConfig);
}
if (this.el) {
this.syncPreRenderStyle();
if (!this.viewConfig.doNotTrackPreRenderSize) {
this.resizeObserver = new ResizeObserver((entries) => {
entries.forEach((entry) => {
if (entry.contentRect && entry.target === this.el) {
setStyleProperties(this.preRenderWrapper, {
width: `${entry.contentRect.width}px`,
height: `${entry.contentRect.height}px`,
});
}
});
});
this.resizeObserver.observe(this.el);
}
}
this.beforePrerenderVisible();
removeStyleProperties(this.preRenderWrapper, ['z-index', 'opacity', 'pointer-events']);
this.subscribeToEvents();
// Setup fullscreen change handler for prerendered components
if (this.iFrame) {
this.setupFullscreenChangeHandler();
}
return this;
}
/**
* 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.
*/
syncPreRenderStyle() {
if (!this.isPreRenderAvailable() || !this.el) {
logger.error(ERROR_MESSAGE.SYNC_STYLE_CALLED_BEFORE_RENDER);
return;
}
const elBoundingClient = this.el.getBoundingClientRect();
setStyleProperties(this.preRenderWrapper, {
top: `${elBoundingClient.y + window.scrollY}px`,
left: `${elBoundingClient.x + window.scrollX}px`,
width: `${elBoundingClient.width}px`,
height: `${elBoundingClient.height}px`,
});
}
/**
* Hides the PreRender component if it is available.
* If the component is not preRendered, it issues a warning.
*/
hidePreRender() {
if (!this.isPreRenderAvailable()) {
// if the embed component is not preRendered , nothing to hide
logger.warn('PreRender should be called before hiding it using hidePreRender.');
return;
}
const preRenderHideStyles = {
opacity: '0',
pointerEvents: 'none',
zIndex: '-1000',
position: 'absolute ',
};
setStyleProperties(this.preRenderWrapper, preRenderHideStyles);
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
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.
*/
getPreRenderIds() {
return {
wrapper: `tsEmbed-pre-render-wrapper-${this.viewConfig.preRenderId}`,
child: `tsEmbed-pre-render-child-${this.viewConfig.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
*/
async getAnswerService(vizId) {
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
*/
setupFullscreenChangeHandler() {
var _a;
const embedConfig = getEmbedConfig();
const disableFullscreenPresentation = (_a = embedConfig === null || embedConfig === void 0 ? void 0 : embedConfig.disableFullscreenPresentation) !== null && _a !== void 0 ? _a : 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
*/
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 cla