From 2695409da8ad33b3dc4dbbcbc503a742124a630c Mon Sep 17 00:00:00 2001 From: Justin Mathew Date: Thu, 19 Dec 2024 05:30:40 +0530 Subject: [PATCH] SCAL-222310 : init --- src/embed/hostEventClient/contracts.ts | 116 + .../hostEventClient/host-event-client.spec.ts | 164 ++ .../hostEventClient/host-event-client.ts | 72 + src/embed/ts-embed.ts | 2467 +++++++++-------- src/types.ts | 46 +- 5 files changed, 1619 insertions(+), 1246 deletions(-) create mode 100644 src/embed/hostEventClient/contracts.ts create mode 100644 src/embed/hostEventClient/host-event-client.spec.ts create mode 100644 src/embed/hostEventClient/host-event-client.ts diff --git a/src/embed/hostEventClient/contracts.ts b/src/embed/hostEventClient/contracts.ts new file mode 100644 index 00000000..783bee7e --- /dev/null +++ b/src/embed/hostEventClient/contracts.ts @@ -0,0 +1,116 @@ +import { HostEvent } from '../../types'; + +export enum UiPassthroughEvent { + addVizToPinboard = 'addVizToPinboard', + saveAnswer = 'saveAnswer', + getA3AnalysisColumns = 'getA3AnalysisColumns', + getPinboardTabInfo = 'getPinboardTabInfo', + getDiscoverabilityStatus = 'getDiscoverabilityStatus', + getAvailableUiPassthroughs = 'getAvailableUiPassthroughs', + getAnswerPageConfig = 'getAnswerPageConfig', + getPinboardPageConfig = 'getPinboardPageConfig', + UiPassthroughEventNotFound = 'UiPassthroughEventNotFound', +} + +export type UiPassthroughContractBase = { + [UiPassthroughEvent.addVizToPinboard]: { + request: { + vizId?: string; + newVizName: string; + newVizDescription?: string; + pinboardId?: string; + tabId?: string; + newPinboardName?: string; + newTabName?: string; + pinFromStore?: boolean; + }; + response: { + pinboardId: string; + tabId: string; + vizId: string; + errors?: any; + }; + }; + [UiPassthroughEvent.saveAnswer]: { + request: { + name: string; + description: string; + vizId: string; + isDiscoverable?: boolean; + }; + response: { + answerId: string, + errors?: any; + }; + }; + [UiPassthroughEvent.getA3AnalysisColumns]: { + request: { + vizId?: string; + }; + response: { + data?: any; + errors?: any; + }; + }; + [UiPassthroughEvent.getPinboardTabInfo]: { + request: any; + response: any; + }; + [UiPassthroughEvent.getDiscoverabilityStatus]: { + request: any; + response: { + shouldShowDiscoverability: boolean; + isDiscoverabilityCheckboxUnselectedPerOrg: boolean; + }; + }; + [UiPassthroughEvent.getAvailableUiPassthroughs]: { + request: any; + response: { + keys: string[]; + }; + }; + [UiPassthroughEvent.getAnswerPageConfig]: { + request: { + vizId?: string; + }; + response: any; + }; + [UiPassthroughEvent.getPinboardPageConfig]: { + request: any; + response: any; + }; + [UiPassthroughEvent.UiPassthroughEventNotFound]: { + request: any; + response: any; + }; +}; + +export type UiPassthroughRequest = UiPassthroughContractBase[T]['request']; +export type UiPassthroughResponse = UiPassthroughContractBase[T]['response']; + +export type UiPassthroughArrayResponse = + Promise; + error?: any; + }>> + +export type EmbedApiHostEventMapping = { + [HostEvent.Pin]: UiPassthroughEvent.addVizToPinboard; + [HostEvent.SaveAnswer]: UiPassthroughEvent.saveAnswer; + 'hostEventNotMapped': UiPassthroughEvent.UiPassthroughEventNotFound; +} + +export type FlattenType = T extends infer R ? { [K in keyof R]: R[K] } : never; + +export type HostEventRequest = + HostEventT extends keyof EmbedApiHostEventMapping ? + FlattenType> : any; + +export type HostEventResponse = + HostEventT extends keyof EmbedApiHostEventMapping ? + { + value?: UiPassthroughRequest + error?: any; + } + : any; diff --git a/src/embed/hostEventClient/host-event-client.spec.ts b/src/embed/hostEventClient/host-event-client.spec.ts new file mode 100644 index 00000000..fdfc1b9a --- /dev/null +++ b/src/embed/hostEventClient/host-event-client.spec.ts @@ -0,0 +1,164 @@ +import { getIFrameEl } from '../../test/test-utils'; +import { HostEvent } from '../../types'; +import { processTrigger } from '../../utils/processTrigger'; +import { + UiPassthroughEvent, + UiPassthroughRequest, + UiPassthroughArrayResponse, + HostEventRequest, +} from './contracts'; +import { HostEventClient } from './host-event-client'; + +jest.mock('../../utils/processTrigger'); + +const mockProcessTrigger = processTrigger as jest.Mock; + +const createHostEventClient = () => { + const mockIframe = document.createElement('iframe'); + const mockThoughtSpotHost = 'http://localhost'; + const client = new HostEventClient(mockIframe, mockThoughtSpotHost); + return { client, mockIframe, mockThoughtSpotHost }; +}; + +describe('HostEventClient', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('executeUiPassthroughApi', () => { + it('should call processTrigger with correct parameters and return response', async () => { + const { client, mockIframe, mockThoughtSpotHost } = createHostEventClient(); + + const apiName = UiPassthroughEvent.addVizToPinboard; + const parameters: UiPassthroughRequest = { + newVizName: 'testViz', + }; + const triggerResponse = Promise.resolve([ + { value: { pinboardId: 'testPinboard', tabId: 'testTab', vizId: 'testVizId' } }, + ]); + + mockProcessTrigger.mockResolvedValue(triggerResponse); + + const result = await client.executeUiPassthroughApi(getIFrameEl(), apiName, parameters); + + expect(mockProcessTrigger).toHaveBeenCalledWith( + mockIframe, + HostEvent.UiPassthrough, + mockThoughtSpotHost, + { + type: apiName, + parameters, + }, + ); + expect(result).toEqual(await triggerResponse); + }); + }); + + describe('handleUiPassthroughForHostEvent', () => { + it('should return the value from the first valid response', async () => { + const { client } = createHostEventClient(); + const apiName = UiPassthroughEvent.addVizToPinboard; + const parameters: UiPassthroughRequest = { + newVizName: 'testViz', + }; + const triggerResponse = Promise.resolve([ + { value: { pinboardId: 'testPinboard', tabId: 'testTab', vizId: 'testVizId' } }, + ]); + mockProcessTrigger.mockResolvedValue(triggerResponse); + + const result = await client.handleUiPassthroughForHostEvent(getIFrameEl(), + apiName, parameters); + + expect(result).toEqual({ + pinboardId: 'testPinboard', + tabId: 'testTab', + vizId: 'testVizId', + }); + }); + + it('should throw an error if no valid response is found', async () => { + const { client } = createHostEventClient(); + const apiName = UiPassthroughEvent.addVizToPinboard; + const parameters: UiPassthroughRequest = { + newVizName: 'testViz', + }; + const triggerResponse: UiPassthroughArrayResponse = Promise.resolve([]); + mockProcessTrigger.mockResolvedValue(triggerResponse); + + await expect(client.handleUiPassthroughForHostEvent(getIFrameEl(), apiName, parameters)) + .rejects.toThrow('No answer found'); + }); + + it('should throw an error if the response contains errors', async () => { + const { client } = createHostEventClient(); + const apiName = UiPassthroughEvent.addVizToPinboard; + const parameters: UiPassthroughRequest = { + newVizName: 'testViz', + }; + const triggerResponse: UiPassthroughArrayResponse = Promise.resolve([ + { error: 'Some error' }, + ]); + mockProcessTrigger.mockResolvedValue(triggerResponse); + + await expect(client.handleUiPassthroughForHostEvent(getIFrameEl(), apiName, parameters)) + .rejects.toThrow(JSON.stringify({ errors: 'Some error' })); + }); + }); + + describe('executeHostEvent', () => { + it('should call handleUiPassthroughForHostEvent for Pin event', async () => { + const { client } = createHostEventClient(); + const hostEvent = HostEvent.Pin; + const payload: HostEventRequest = { + newVizName: 'testViz', + }; + const mockResponse = { + pinboardId: 'testPinboard', + tabId: 'testTab', + vizId: 'testVizId', + }; + jest.spyOn(client, 'handleUiPassthroughForHostEvent').mockResolvedValue(mockResponse); + + const result = await client.executeHostEvent(getIFrameEl(), hostEvent, payload); + + expect(client.handleUiPassthroughForHostEvent).toHaveBeenCalledWith( + UiPassthroughEvent.addVizToPinboard, + payload, + ); + expect(result).toEqual(mockResponse); + }); + + it('should call handleUiPassthroughForHostEvent for SaveAnswer event', async () => { + const { client } = createHostEventClient(); + const hostEvent = HostEvent.SaveAnswer; + const payload: HostEventRequest = { + name: 'Test Answer', + description: 'Test Description', + vizId: 'testVizId', + }; + const mockResponse = { answerId: 'testAnswerId' }; + jest.spyOn(client, 'handleUiPassthroughForHostEvent').mockResolvedValue(mockResponse); + + const result = await client.executeHostEvent(getIFrameEl(), hostEvent, payload); + + expect(client.handleUiPassthroughForHostEvent).toHaveBeenCalledWith( + UiPassthroughEvent.saveAnswer, + payload, + ); + expect(result).toEqual(mockResponse); + }); + + it('should call hostEventFallback for unmapped events', async () => { + const { client } = createHostEventClient(); + const hostEvent = 'testEvent' as HostEvent; + const payload = { data: 'testData' }; + const mockResponse = { fallbackResponse: 'data' }; + jest.spyOn(client, 'hostEventFallback').mockResolvedValue(mockResponse); + + const result = await client.executeHostEvent(getIFrameEl(), hostEvent, payload); + + expect(client.hostEventFallback).toHaveBeenCalledWith(hostEvent, payload); + expect(result).toEqual(mockResponse); + }); + }); +}); diff --git a/src/embed/hostEventClient/host-event-client.ts b/src/embed/hostEventClient/host-event-client.ts new file mode 100644 index 00000000..2f8fdb6d --- /dev/null +++ b/src/embed/hostEventClient/host-event-client.ts @@ -0,0 +1,72 @@ +import { HostEvent } from '../../types'; +import { processTrigger } from '../../utils/processTrigger'; +import { + UiPassthroughArrayResponse, + UiPassthroughEvent, HostEventRequest, HostEventResponse, + UiPassthroughRequest, +} from './contracts'; + +export class HostEventClient { + thoughtSpotHost: string; + + constructor(iFrame: HTMLIFrameElement, thoughtSpotHost: string) { + this.thoughtSpotHost = thoughtSpotHost; + } + + async executeUiPassthroughApi(iFrame: HTMLIFrameElement, apiName: UiPassthroughEvent, + parameters: UiPassthroughRequest): + UiPassthroughArrayResponse { + const res = await processTrigger(iFrame, HostEvent.UiPassthrough, this.thoughtSpotHost, { + type: apiName, + parameters, + }); + + return res; + } + + async handleUiPassthroughForHostEvent( + iFrame: HTMLIFrameElement, + apiName: UiPassthroughEvent, + parameters: UiPassthroughRequest, + ): + UiPassthroughArrayResponse { + const response = (await this.executeUiPassthroughApi(iFrame, apiName, parameters)) + ?.filter?.((r) => r.error || r.value)[0]; + + if (!response) { + throw new Error('No answer found'); + } + + const errors = response.error || (response.value as any)?.errors; + if (errors) { + throw new Error(JSON.stringify({ errors: response.error })); + } + + return { ...response.value }; + } + + async hostEventFallback( + iFrame: HTMLIFrameElement, hostEvent: HostEvent, data: any, + ): Promise { + return processTrigger(iFrame, hostEvent, this.thoughtSpotHost, data); + } + + async executeHostEvent( + iFrame:HTMLIFrameElement, hostEvent: HostEvent, payload?: HostEventRequest, + ): + Promise> { + if (hostEvent === HostEvent.Pin && typeof payload === 'object') { + return this.handleUiPassthroughForHostEvent( + iFrame, + UiPassthroughEvent.addVizToPinboard, payload, + ); + } + + if (hostEvent === HostEvent.SaveAnswer && typeof payload === 'object') { + return this.handleUiPassthroughForHostEvent(iFrame, + UiPassthroughEvent.saveAnswer, payload); + } + + return this.hostEventFallback(iFrame, hostEvent, payload); + } +} diff --git a/src/embed/ts-embed.ts b/src/embed/ts-embed.ts index 31ccb6d3..4ad89f8c 100644 --- a/src/embed/ts-embed.ts +++ b/src/embed/ts-embed.ts @@ -9,6 +9,7 @@ import isEqual from 'lodash/isEqual'; import isEmpty from 'lodash/isEmpty'; import isObject from 'lodash/isObject'; +import { HostEventRequest, HostEventResponse } from './hostEventClient/contracts'; import { logger } from '../utils/logger'; import { getAuthenticationToken } from '../authToken'; import { AnswerService } from '../utils/graphql/answerService/answerService'; @@ -62,6 +63,7 @@ import { import { AuthFailureType } from '../auth'; import { getEmbedConfig } from './embedConfig'; import { ERROR_MESSAGE } from '../errors'; +import { HostEventClient } from './hostEventClient/host-event-client'; const { version } = pkgInfo; @@ -85,1166 +87,1175 @@ const V1EventMap = {}; * 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 el: 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; - - protected viewConfig: ViewConfig; - - 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; - - constructor(domSelector: DOMSelector, viewConfig?: ViewConfig) { - this.el = getDOMNode(domSelector); - // TODO: handle error - this.embedConfig = getEmbedConfig(); - if (!this.embedConfig.authTriggerContainer && !this.embedConfig.useEventForSAMLPopup) { - this.embedConfig.authTriggerContainer = domSelector; - } - this.thoughtSpotHost = getThoughtSpotHost(this.embedConfig); - this.thoughtSpotV2Base = getV2BasePath(this.embedConfig); - this.eventHandlerMap = new Map(); - this.isError = false; - this.viewConfig = { - excludeRuntimeFiltersfromURL: false, - excludeRuntimeParametersfromURL: false, - ...viewConfig, - }; - this.shouldEncodeUrlQueryParams = this.embedConfig.shouldEncodeUrlQueryParams; - this.registerAppInit(); - uploadMixpanelEvent(MIXPANEL_EVENT.VISUAL_SDK_EMBED_CREATE, { - ...viewConfig, - }); - } - - /** - * Throws error encountered during initialization. - */ - private throwInitError() { - this.handleError('You need to init the ThoughtSpot SDK module first'); - } - - /** - * Handles errors within the SDK - * @param error The error message or object - */ - protected handleError(error: string | Record) { - 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 - */ - private getEventType(event: MessageEvent) { - // eslint-disable-next-line no-underscore-dangle - 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; - } - - /** - * 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 = {}; - - /** - * 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.unsubscribeToEvents(); - const messageEventListener = (event: MessageEvent) => { - 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: Event) => { - this.trigger(HostEvent.Reload); - }; - window.addEventListener('online', onlineEventListener); - - const offlineEventListener = (e: Event) => { - 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, - }; - } - - private unsubscribeToEvents() { - Object.keys(this.subscribedListeners).forEach((key) => { - window.removeEventListener(key, this.subscribedListeners[key]); - }); - } - - /** - * Send Custom style as part of payload of APP_INIT - * @param _ - * @param responder - */ - private appInitCb = async (_: any, responder: any) => { - let authToken = ''; - if (this.embedConfig.authType === AuthType.TrustedAuthTokenCookieless) { - try { - authToken = await getAuthenticationToken(this.embedConfig); - } catch (e) { - processAuthFailure(e, this.isPreRendered ? this.preRenderWrapper : this.el); - return; - } - } - this.isAppInitialized = true; - responder({ - type: EmbedEvent.APP_INIT, - data: { - 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 - : [], - }, - }); - }; - - /** - * Sends updated auth token to the iFrame to avoid user logout - * @param _ - * @param responder - */ - private updateAuthToken = async (_: any, responder: any) => { - const { autoLogin = false, authType } = this.embedConfig; // Set autoLogin default to false - if (authType === AuthType.TrustedAuthTokenCookieless) { - let authToken = ''; - try { - authToken = await getAuthenticationToken(this.embedConfig); - responder({ - type: EmbedEvent.AuthExpire, - data: { authToken }, - }); - } catch (e) { - logger.error(`${ERROR_MESSAGE.INVALID_TOKEN_ERROR} Error : ${e?.message}`); - processAuthFailure(e, this.isPreRendered ? this.preRenderWrapper : this.el); - } - } else if (autoLogin) { - handleAuth(); - } - notifyAuthFailure(AuthFailureType.EXPIRY); - }; - - /** - * 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); - }; - - /** - * 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}#`; - } - - /** - * 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'; - } - 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] = this.embedConfig.blockNonEmbedFullAppAccess - ?? true; - 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, - enableFlipTooltipToContextMenu = false, - } = this.viewConfig; - - const { additionalFlags: additionalFlagsFromInit } = this.embedConfig; - - const additionalFlags = { - ...additionalFlagsFromInit, - ...additionalFlagsFromView, - }; - - if (enableFlipTooltipToContextMenu) { - queryParams[Param.EnableFlipTooltipToContextMenu] = enableFlipTooltipToContextMenu; - } - - 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 (disabledActions?.length) { - queryParams[Param.DisableActions] = disabledActions; - } - if (disabledActionReason) { - queryParams[Param.DisableActionReason] = disabledActionReason; - } - 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] = true; - } else if (contextMenuTrigger === ContextMenuTriggerOptions.RIGHT_CLICK) { - queryParams[Param.ContextMenuTrigger] = false; - } - - const spriteUrl = customizations?.iconSpriteUrl - || this.embedConfig.customizations?.iconSpriteUrl; - if (spriteUrl) { - queryParams[Param.IconSpriteUrl] = spriteUrl.replace('https://', ''); - } - - 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; - } - - 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.getBaseQueryParams(); - return getQueryParamString(queryParams); - } - - 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;'; - - const { - height: frameHeight, - width: frameWidth, - ...restParams - } = this.viewConfig.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}`; - iFrame.style.border = '0'; - iFrame.name = 'ThoughtSpot Embedded Analytics'; - return iFrame; - } - - protected handleInsertionIntoDOM(child: string | Node): void { - 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. - */ - 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); - return getAuthPromise() - ?.then((isLoggedIn: boolean) => { - if (!isLoggedIn) { - this.handleInsertionIntoDOM(this.embedConfig.loginFailedMessage); - return; - } - - this.iFrame = 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, - }); - }); - 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); - }); - }); - } - - 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', - width: '100vw', - height: '100vh', - }; - setStyleProperties(preRenderWrapper, initialPreRenderWrapperStyle); - - return preRenderWrapper; - } - - protected preRenderWrapper: HTMLElement; - - protected preRenderChild: HTMLElement; - - protected connectPreRendered(): boolean { - const preRenderIds = this.getPreRenderIds(); - this.preRenderWrapper = this.preRenderWrapper - || document.getElementById(preRenderIds.wrapper); - - this.preRenderChild = this.preRenderChild || document.getElementById(preRenderIds.child); - - if (this.preRenderWrapper && this.preRenderChild) { - this.isPreRendered = true; - if (this.preRenderChild instanceof HTMLIFrameElement) { - this.iFrame = this.preRenderChild; - } - this.insertedDomEl = this.preRenderWrapper; - this.isRendered = true; - } - - return this.isPreRenderAvailable(); - } - - protected isPreRenderAvailable(): boolean { - return ( - this.isRendered - && this.isPreRendered - && 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; - } - - 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.iFrame = preRenderChild; - } - this.insertedDomEl = preRenderWrapper; - - if (this.showPreRenderByDefault) { - this.showPreRender(); - } else { - this.hidePreRender(); - } - - document.body.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; - // eslint-disable-next-line no-param-reassign - child = div; - } - if (this.el.nextElementSibling?.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 - */ - protected setIFrameHeight(height: number | string): void { - 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 - */ - protected executeCallbacks( - eventType: EmbedEvent, - data: any, - eventPort?: MessagePort | void, - ): void { - 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) - ) { - callbackObj.callback(data, (payload) => { - this.triggerEventOnPort(eventPort, payload); - }); - } - }); - } - - /** - * 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; - } - - /** - * 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. - */ - protected 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 - * }); - * ``` - */ - public on( - messageType: EmbedEvent, - callback: MessageCallback, - 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'); - } - } - - /** - * Triggers an event to the embedded app - * @param messageType The event type - * @param data The payload to send with the message - */ - public trigger(messageType: HostEvent, data: any = {}): Promise { - 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; - } - return processTrigger(this.iFrame, messageType, this.thoughtSpotHost, data); - } - - /** - * 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 { - this.isRendered = true; - - return this; - } - - public getIframeSrc(): string { - return ''; - } - - protected handleRenderForPrerender() { - this.render(); - } - - /** - * Creates the preRender shell - * @param showPreRenderByDefault - Show the preRender after render, hidden by default - */ - public preRender(showPreRenderByDefault = false): TsEmbed { - if (!this.viewConfig.preRenderId) { - logger.error(ERROR_MESSAGE.PRERENDER_ID_MISSING); - return this; - } - this.isPreRendered = true; - this.showPreRenderByDefault = showPreRenderByDefault; - this.handleRenderForPrerender(); - return this; - } - - /** - * 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; - } - - /** - * Destroys the ThoughtSpot embed, and remove any nodes from the DOM. - * @version SDK: 1.19.1 | ThoughtSpot: * - */ - public destroy(): void { - try { - this.insertedDomEl?.parentNode.removeChild(this.insertedDomEl); - this.unsubscribeToEvents(); - } 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 { - const prerenderFrameSrc = this.getRootIframeSrc(); - this.isRendered = true; - return this.renderIFrame(prerenderFrameSrc); - } - - protected beforePrerenderVisible(): void { - // Override in subclass - } - - private validatePreRenderViewConfig = (viewConfig: ViewConfig) => { - const preRenderAllowedKeys = ['preRenderId', 'vizId', 'liveboardId']; - const preRenderedObject = this.insertedDomEl?.[this.embedNodeKey] as TsEmbed; - 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.', - ); - } - }); - } - }; - - /** - * 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. - */ - public showPreRender(): void { - if (!this.viewConfig.preRenderId) { - logger.error(ERROR_MESSAGE.PRERENDER_ID_MISSING); - return; - } - if (!this.isPreRenderAvailable()) { - const isAvailable = this.connectPreRendered(); - - if (!isAvailable) { - // if the Embed component is not preRendered , Render it now and - this.preRender(true); - return; - } - 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(); - } - - /** - * 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.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. - */ - public hidePreRender(): void { - 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. - */ - public 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 - */ - public async getAnswerService(vizId?: string): Promise { - const { session } = await this.trigger(HostEvent.GetAnswerSession, vizId ? { vizId } : {}); - - return new AnswerService(session, null, this.embedConfig.thoughtSpotHost); - } + /** + * 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 el: 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; + + protected viewConfig: ViewConfig; + + 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; + + protected hostEventClient: HostEventClient; + + constructor(domSelector: DOMSelector, viewConfig?: ViewConfig) { + this.el = getDOMNode(domSelector); + // TODO: handle error + this.embedConfig = getEmbedConfig(); + if (!this.embedConfig.authTriggerContainer && !this.embedConfig.useEventForSAMLPopup) { + this.embedConfig.authTriggerContainer = domSelector; + } + this.thoughtSpotHost = getThoughtSpotHost(this.embedConfig); + this.thoughtSpotV2Base = getV2BasePath(this.embedConfig); + this.eventHandlerMap = new Map(); + this.isError = false; + this.viewConfig = { + excludeRuntimeFiltersfromURL: false, + excludeRuntimeParametersfromURL: false, + ...viewConfig, + }; + this.shouldEncodeUrlQueryParams = this.embedConfig.shouldEncodeUrlQueryParams; + this.registerAppInit(); + uploadMixpanelEvent(MIXPANEL_EVENT.VISUAL_SDK_EMBED_CREATE, { + ...viewConfig, + }); + this.hostEventClient = new HostEventClient(this.iFrame, this.embedConfig.thoughtSpotHost); + } + + /** + * Throws error encountered during initialization. + */ + private throwInitError() { + this.handleError('You need to init the ThoughtSpot SDK module first'); + } + + /** + * Handles errors within the SDK + * @param error The error message or object + */ + protected handleError(error: string | Record) { + 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 + */ + private getEventType(event: MessageEvent) { + // eslint-disable-next-line no-underscore-dangle + 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; + } + + /** + * 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 = {}; + + /** + * 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.unsubscribeToEvents(); + const messageEventListener = (event: MessageEvent) => { + 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: Event) => { + this.trigger(HostEvent.Reload); + }; + + window.addEventListener('online', onlineEventListener); + + const offlineEventListener = (e: Event) => { + 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, + }; + } + + private unsubscribeToEvents() { + Object.keys(this.subscribedListeners).forEach((key) => { + window.removeEventListener(key, this.subscribedListeners[key]); + }); + } + + /** + * Send Custom style as part of payload of APP_INIT + * @param _ + * @param responder + */ + private appInitCb = async (_: any, responder: any) => { + let authToken = ''; + if (this.embedConfig.authType === AuthType.TrustedAuthTokenCookieless) { + try { + authToken = await getAuthenticationToken(this.embedConfig); + } catch (e) { + processAuthFailure(e, this.isPreRendered ? this.preRenderWrapper : this.el); + return; + } + } + this.isAppInitialized = true; + responder({ + type: EmbedEvent.APP_INIT, + data: { + 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 + : [], + }, + }); + }; + + /** + * Sends updated auth token to the iFrame to avoid user logout + * @param _ + * @param responder + */ + private updateAuthToken = async (_: any, responder: any) => { + const { autoLogin = false, authType } = this.embedConfig; // Set autoLogin default to false + if (authType === AuthType.TrustedAuthTokenCookieless) { + let authToken = ''; + try { + authToken = await getAuthenticationToken(this.embedConfig); + responder({ + type: EmbedEvent.AuthExpire, + data: { authToken }, + }); + } catch (e) { + logger.error(`${ERROR_MESSAGE.INVALID_TOKEN_ERROR} Error : ${e?.message}`); + processAuthFailure(e, this.isPreRendered ? this.preRenderWrapper : this.el); + } + } else if (autoLogin) { + handleAuth(); + } + notifyAuthFailure(AuthFailureType.EXPIRY); + }; + + /** + * 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); + }; + + /** + * 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}#`; + } + + /** + * 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'; + } + 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] = this.embedConfig.blockNonEmbedFullAppAccess + ?? true; + 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, + enableFlipTooltipToContextMenu = false, + } = this.viewConfig; + + const { additionalFlags: additionalFlagsFromInit } = this.embedConfig; + + const additionalFlags = { + ...additionalFlagsFromInit, + ...additionalFlagsFromView, + }; + + if (enableFlipTooltipToContextMenu) { + queryParams[Param.EnableFlipTooltipToContextMenu] = enableFlipTooltipToContextMenu; + } + + 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 (disabledActions?.length) { + queryParams[Param.DisableActions] = disabledActions; + } + if (disabledActionReason) { + queryParams[Param.DisableActionReason] = disabledActionReason; + } + 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] = true; + } else if (contextMenuTrigger === ContextMenuTriggerOptions.RIGHT_CLICK) { + queryParams[Param.ContextMenuTrigger] = false; + } + + const spriteUrl = customizations?.iconSpriteUrl + || this.embedConfig.customizations?.iconSpriteUrl; + if (spriteUrl) { + queryParams[Param.IconSpriteUrl] = spriteUrl.replace('https://', ''); + } + + 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; + } + + 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.getBaseQueryParams(); + return getQueryParamString(queryParams); + } + + 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;'; + + const { + height: frameHeight, + width: frameWidth, + ...restParams + } = this.viewConfig.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}`; + iFrame.style.border = '0'; + iFrame.name = 'ThoughtSpot Embedded Analytics'; + return iFrame; + } + + protected handleInsertionIntoDOM(child: string | Node): void { + 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. + */ + 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); + return getAuthPromise() + ?.then((isLoggedIn: boolean) => { + if (!isLoggedIn) { + this.handleInsertionIntoDOM(this.embedConfig.loginFailedMessage); + return; + } + + this.iFrame = 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, + }); + }); + 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); + }); + }); + } + + 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', + width: '100vw', + height: '100vh', + }; + setStyleProperties(preRenderWrapper, initialPreRenderWrapperStyle); + + return preRenderWrapper; + } + + protected preRenderWrapper: HTMLElement; + + protected preRenderChild: HTMLElement; + + protected connectPreRendered(): boolean { + const preRenderIds = this.getPreRenderIds(); + this.preRenderWrapper = this.preRenderWrapper + || document.getElementById(preRenderIds.wrapper); + + this.preRenderChild = this.preRenderChild || document.getElementById(preRenderIds.child); + + if (this.preRenderWrapper && this.preRenderChild) { + this.isPreRendered = true; + if (this.preRenderChild instanceof HTMLIFrameElement) { + this.iFrame = this.preRenderChild; + } + this.insertedDomEl = this.preRenderWrapper; + this.isRendered = true; + } + + return this.isPreRenderAvailable(); + } + + protected isPreRenderAvailable(): boolean { + return ( + this.isRendered + && this.isPreRendered + && 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; + } + + 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.iFrame = preRenderChild; + } + this.insertedDomEl = preRenderWrapper; + + if (this.showPreRenderByDefault) { + this.showPreRender(); + } else { + this.hidePreRender(); + } + + document.body.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; + // eslint-disable-next-line no-param-reassign + child = div; + } + if (this.el.nextElementSibling?.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 + */ + protected setIFrameHeight(height: number | string): void { + 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 + */ + protected executeCallbacks( + eventType: EmbedEvent, + data: any, + eventPort?: MessagePort | void, + ): void { + 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) + ) { + callbackObj.callback(data, (payload) => { + this.triggerEventOnPort(eventPort, payload); + }); + } + }); + } + + /** + * 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; + } + + /** + * 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. + */ + protected 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 + * }); + * ``` + */ + public on( + messageType: EmbedEvent, + callback: MessageCallback, + 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'); + } + } + + /** + * Triggers an event to the embedded app + * @param messageType The event type + * @param data The payload to send with the message + */ + public trigger( + messageType: HostEventT, + data?: HostEventRequest, + ): + Promise> { + 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; + } + + return this.hostEventClient.executeHostEvent(this.iFrame, messageType, data); + } + + /** + * 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 { + this.isRendered = true; + + return this; + } + + public getIframeSrc(): string { + return ''; + } + + protected handleRenderForPrerender() { + this.render(); + } + + /** + * Creates the preRender shell + * @param showPreRenderByDefault - Show the preRender after render, hidden by default + */ + public preRender(showPreRenderByDefault = false): TsEmbed { + if (!this.viewConfig.preRenderId) { + logger.error(ERROR_MESSAGE.PRERENDER_ID_MISSING); + return this; + } + this.isPreRendered = true; + this.showPreRenderByDefault = showPreRenderByDefault; + this.handleRenderForPrerender(); + return this; + } + + /** + * 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; + } + + /** + * Destroys the ThoughtSpot embed, and remove any nodes from the DOM. + * @version SDK: 1.19.1 | ThoughtSpot: * + */ + public destroy(): void { + try { + this.insertedDomEl?.parentNode.removeChild(this.insertedDomEl); + this.unsubscribeToEvents(); + } 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 { + const prerenderFrameSrc = this.getRootIframeSrc(); + this.isRendered = true; + return this.renderIFrame(prerenderFrameSrc); + } + + protected beforePrerenderVisible(): void { + // Override in subclass + } + + private validatePreRenderViewConfig = (viewConfig: ViewConfig) => { + const preRenderAllowedKeys = ['preRenderId', 'vizId', 'liveboardId']; + const preRenderedObject = this.insertedDomEl?.[this.embedNodeKey] as TsEmbed; + 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.', + ); + } + }); + } + }; + + /** + * 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. + */ + public showPreRender(): void { + if (!this.viewConfig.preRenderId) { + logger.error(ERROR_MESSAGE.PRERENDER_ID_MISSING); + return; + } + if (!this.isPreRenderAvailable()) { + const isAvailable = this.connectPreRendered(); + + if (!isAvailable) { + // if the Embed component is not preRendered , Render it now and + this.preRender(true); + return; + } + 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(); + } + + /** + * 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.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. + */ + public hidePreRender(): void { + 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. + */ + public 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 + */ + public async getAnswerService(vizId?: string): Promise { + const { session } = await this.trigger(HostEvent.GetAnswerSession, vizId ? { vizId } : {}); + + return new AnswerService(session, null, this.embedConfig.thoughtSpotHost); + } } /** @@ -1254,72 +1265,72 @@ export class TsEmbed { * @inheritdoc */ export class V1Embed extends TsEmbed { - protected viewConfig: ViewConfig; - - constructor(domSelector: DOMSelector, viewConfig: ViewConfig) { - super(domSelector, viewConfig); - this.viewConfig = { excludeRuntimeFiltersfromURL: false, ...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: EmbedEvent, - callback: MessageCallback, - options: MessageOptions = { start: false }, - ): typeof TsEmbed.prototype { - const eventType = this.getCompatibleEventType(messageType); - return super.on(eventType, callback, options); - } - - /** - * Only for testing purposes. - * @hidden - */ - // eslint-disable-next-line camelcase - public test__executeCallbacks = this.executeCallbacks; + protected viewConfig: ViewConfig; + + constructor(domSelector: DOMSelector, viewConfig: ViewConfig) { + super(domSelector, viewConfig); + this.viewConfig = { excludeRuntimeFiltersfromURL: false, ...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: EmbedEvent, + callback: MessageCallback, + options: MessageOptions = { start: false }, + ): typeof TsEmbed.prototype { + const eventType = this.getCompatibleEventType(messageType); + return super.on(eventType, callback, options); + } + + /** + * Only for testing purposes. + * @hidden + */ + // eslint-disable-next-line camelcase + public test__executeCallbacks = this.executeCallbacks; } diff --git a/src/types.ts b/src/types.ts index e42dfc1e..b0d191df 100644 --- a/src/types.ts +++ b/src/types.ts @@ -611,7 +611,7 @@ export interface EmbedConfig { } // eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface LayoutConfig {} +export interface LayoutConfig { } /** * Embedded iframe configuration @@ -1666,8 +1666,8 @@ export enum EmbedEvent { * @example *```js * liveboardEmbed.on(EmbedEvent.LiveboardRendered, payload => { - console.log('Liveboard is rendered', payload); - }) + console.log('Liveboard is rendered', payload); + }) *``` * The following example shows how to trigger * `SetVisibleVizs` event using LiveboardRendered embed event: @@ -1714,8 +1714,8 @@ export enum EmbedEvent { * * **Note**: This event is deprecated in v1.21.0. * To fire an event when a download action is initiated on a chart or table, - * use `EmbedEvent.DownloadAsPng`, `EmbedEvent.DownloadAsPDF`, `EmbedEvent.DownloadAsCSV`, - * or `EmbedEvent.DownloadAsXLSX` + * use `EmbedEvent.DownloadAsPng`, `EmbedEvent.DownloadAsPDF`, + * `EmbedEvent.DownloadAsCSV`, or `EmbedEvent.DownloadAsXLSX` * @version SDK: 1.11.0 | ThoughtSpot: 8.3.0.cl, 8.4.1.sw * @example *```js @@ -2212,7 +2212,7 @@ export enum EmbedEvent { * the users and implement the logic to allow or restrict search execution. * You can can also show custom error text if the search query must be * restricted due to your application or business requirements. - + * Prerequisite: Set `isOnBeforeGetVizDataInterceptEnabled` to `true` * for this embed event to get emitted. * @param: payload @@ -2348,9 +2348,9 @@ export enum HostEvent { * ```js * searchembed.trigger(HostEvent.Search, { searchQuery: "[sales] by [item type]", - dataSources: ["cd252e5c-b552-49a8-821d-3eadaa049cca"], - execute: true - }); + dataSources: ["cd252e5c-b552-49a8-821d-3eadaa049cca"], + execute: true + }); * ``` */ Search = 'search', @@ -3199,6 +3199,16 @@ export enum HostEvent { * @version SDK: 1.36.0 | Thoughtspot: 10.6.0.cl */ UpdatePersonalisedView = 'UpdatePersonalisedView', + /** + * Triggers the action to get the current view of the liveboard + * @version SDK: 1.34.0 | Thoughtspot: 10.6.0.cl + */ + SaveAnswer = 'saveAnswer', + /** + * EmbedApi + * @hidden + */ + UiPassthrough = 'UiPassthrough', } /** @@ -4481,15 +4491,15 @@ export interface ColumnValue { [key: string]: any; }; value: - | string - | number - | boolean - | { - v: { - s: number; - e: number; - }; - }; + | string + | number + | boolean + | { + v: { + s: number; + e: number; + }; + }; } export interface VizPoint {