• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

thoughtspot / visual-embed-sdk / #2445

19 Sep 2025 06:18AM UTC coverage: 94.086% (-0.03%) from 94.116%
#2445

Pull #314

shivam-kumar-ts
SCAL-249159 add unit tests for token type validation
Pull Request #314: SCAL-249159 ensuring the token type is a string before proceeding token validation

1228 of 1389 branches covered (88.41%)

Branch coverage included in aggregate %.

9 of 9 new or added lines in 1 file covered. (100.0%)

37 existing lines in 1 file now uncovered.

2972 of 3075 relevant lines covered (96.65%)

84.68 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.78
/src/embed/ts-embed.ts
1
/**
2
 * Copyright (c) 2022
3
 *
4
 * Base classes
5
 * @summary Base classes
6
 * @author Ayon Ghosh <ayon.ghosh@thoughtspot.com>
7
 */
8

9
import isEqual from 'lodash/isEqual';
14✔
10
import isEmpty from 'lodash/isEmpty';
14✔
11
import isObject from 'lodash/isObject';
14✔
12
import {
13
    TriggerPayload,
14
    TriggerResponse,
15
    UIPassthroughArrayResponse,
16
    UIPassthroughEvent,
17
    UIPassthroughRequest,
18
} from './hostEventClient/contracts';
19
import { logger } from '../utils/logger';
14✔
20
import { getAuthenticationToken } from '../authToken';
14✔
21
import { AnswerService } from '../utils/graphql/answerService/answerService';
14✔
22
import {
14✔
23
    getEncodedQueryParamsString,
24
    getCssDimension,
25
    getOffsetTop,
26
    embedEventStatus,
27
    setAttributes,
28
    getCustomisations,
29
    getRuntimeFilters,
30
    getDOMNode,
31
    getFilterQuery,
32
    getQueryParamString,
33
    getRuntimeParameters,
34
    setStyleProperties,
35
    removeStyleProperties,
36
    isUndefined,
37
} from '../utils';
38
import { getCustomActions } from '../utils/custom-actions';
14✔
39
import {
14✔
40
    getThoughtSpotHost,
41
    URL_MAX_LENGTH,
42
    DEFAULT_EMBED_WIDTH,
43
    DEFAULT_EMBED_HEIGHT,
44
    getV2BasePath,
45
} from '../config';
46
import {
14✔
47
    AuthType,
48
    DOMSelector,
49
    HostEvent,
50
    EmbedEvent,
51
    MessageCallback,
52
    Action,
53
    Param,
54
    EmbedConfig,
55
    MessageOptions,
56
    MessageCallbackObj,
57
    ContextMenuTriggerOptions,
58
    DefaultAppInitData,
59
    AllEmbedViewConfig as ViewConfig,
60
} from '../types';
61
import { uploadMixpanelEvent, MIXPANEL_EVENT } from '../mixpanel-service';
14✔
62
import { processEventData, processAuthFailure } from '../utils/processData';
14✔
63
import pkgInfo from '../../package.json';
14✔
64
import {
14✔
65
    getAuthPromise, renderInQueue, handleAuth, notifyAuthFailure,
66
    getInitPromise,
67
    getIsInitCalled,
68
} from './base';
69
import { AuthFailureType } from '../auth';
14✔
70
import { getEmbedConfig } from './embedConfig';
14✔
71
import { ERROR_MESSAGE } from '../errors';
14✔
72
import { getPreauthInfo } from '../utils/sessionInfoService';
14✔
73
import { HostEventClient } from './hostEventClient/host-event-client';
14✔
74

75
const { version } = pkgInfo;
14✔
76

77
/**
78
 * Global prefix for all Thoughtspot postHash Params.
79
 */
80
export const THOUGHTSPOT_PARAM_PREFIX = 'ts-';
14✔
81
const TS_EMBED_ID = '_thoughtspot-embed';
14✔
82

83
/**
84
 * The event id map from v2 event names to v1 event id
85
 * v1 events are the classic embed events implemented in Blink v1
86
 * We cannot rename v1 event types to maintain backward compatibility
87
 * @internal
88
 */
89
const V1EventMap: Record<string, any> = {};
14✔
90

91
/**
92
 * Base class for embedding v2 experience
93
 * Note: the v2 version of ThoughtSpot Blink is built on the new stack:
94
 * React+GraphQL
95
 */
96
export class TsEmbed {
14✔
97
    /**
98
     * The DOM node which was inserted by the SDK to either
99
     * render the iframe or display an error message.
100
     * This is useful for removing the DOM node when the
101
     * embed instance is destroyed.
102
     */
103
    protected insertedDomEl: Node;
104

105
    /**
106
     * The DOM node where the ThoughtSpot app is to be embedded.
107
     */
108
    protected el: HTMLElement;
109

110
    /**
111
     * The key to store the embed instance in the DOM node
112
     */
113
    protected embedNodeKey = '__tsEmbed';
396✔
114

115
    protected isAppInitialized = false;
396✔
116

117
    /**
118
     * A reference to the iframe within which the ThoughtSpot app
119
     * will be rendered.
120
     */
121
    protected iFrame: HTMLIFrameElement;
122

123
    /**
124
     * Setter for the iframe element
125
     * @param {HTMLIFrameElement} iFrame HTMLIFrameElement
126
     */
127
    protected setIframeElement(iFrame: HTMLIFrameElement): void {
128
        this.iFrame = iFrame;
362✔
129
        this.hostEventClient.setIframeElement(iFrame);
362✔
130
    }
131

132
    protected viewConfig: ViewConfig & { visibleTabs?: string[], hiddenTabs?: string[], showAlerts?: boolean };
133

134
    protected embedConfig: EmbedConfig;
135

136
    /**
137
     * The ThoughtSpot hostname or IP address
138
     */
139
    protected thoughtSpotHost: string;
140

141
    /*
142
     * This is the base to access ThoughtSpot V2.
143
     */
144
    protected thoughtSpotV2Base: string;
145

146
    /**
147
     * A map of event handlers for particular message types triggered
148
     * by the embedded app; multiple event handlers can be registered
149
     * against a particular message type.
150
     */
151
    private eventHandlerMap: Map<string, MessageCallbackObj[]>;
152

153
    /**
154
     * A flag that is set to true post render.
155
     */
156
    protected isRendered: boolean;
157

158
    /**
159
     * A flag to mark if an error has occurred.
160
     */
161
    private isError: boolean;
162

163
    /**
164
     * A flag that is set to true post preRender.
165
     */
166
    private isPreRendered: boolean;
167

168
    /**
169
     * Should we encode URL Query Params using base64 encoding which thoughtspot
170
     * will generate for embedding. This provides additional security to
171
     * thoughtspot clusters against Cross site scripting attacks.
172
     * @default false
173
     */
174
    private shouldEncodeUrlQueryParams = false;
396✔
175

176
    private defaultHiddenActions = [Action.ReportError];
396✔
177

178
    private resizeObserver: ResizeObserver;
179

180
    protected hostEventClient: HostEventClient;
181

182
    protected isReadyForRenderPromise;
183

184
    /**
185
     * Handler for fullscreen change events
186
     */
187
    private fullscreenChangeHandler: (() => void) | null = null;
396✔
188

189
    constructor(domSelector: DOMSelector, viewConfig?: ViewConfig) {
190
        this.el = getDOMNode(domSelector);
396✔
191
        this.eventHandlerMap = new Map();
396✔
192
        this.isError = false;
396✔
193
        this.viewConfig = {
396✔
194
            excludeRuntimeFiltersfromURL: false,
195
            excludeRuntimeParametersfromURL: false,
196
            ...viewConfig,
197
        };
198
        this.registerAppInit();
396✔
199
        uploadMixpanelEvent(MIXPANEL_EVENT.VISUAL_SDK_EMBED_CREATE, {
396✔
200
            ...viewConfig,
201
        });
202
        this.hostEventClient = new HostEventClient(this.iFrame);
396✔
203

204
        this.isReadyForRenderPromise = getInitPromise().then(async () => {
396✔
205
            const embedConfig = getEmbedConfig();
396✔
206
            this.embedConfig = embedConfig;
396✔
207
            if (!embedConfig.authTriggerContainer && !embedConfig.useEventForSAMLPopup) {
396✔
208
                this.embedConfig.authTriggerContainer = domSelector;
72✔
209
            }
210
            this.thoughtSpotHost = getThoughtSpotHost(embedConfig);
396✔
211
            this.thoughtSpotV2Base = getV2BasePath(embedConfig);
396✔
212
            this.shouldEncodeUrlQueryParams = embedConfig.shouldEncodeUrlQueryParams;
396✔
213
        });
214
    }
215

216
    /**
217
     * Throws error encountered during initialization.
218
     */
219
    private throwInitError() {
220
        this.handleError('You need to init the ThoughtSpot SDK module first');
1✔
221
    }
222

223
    /**
224
     * Handles errors within the SDK
225
     * @param error The error message or object
226
     */
227
    protected handleError(error: string | Record<string, unknown>) {
228
        this.isError = true;
10✔
229
        this.executeCallbacks(EmbedEvent.Error, {
10✔
230
            error,
231
        });
232
        // Log error
233
        logger.error(error);
10✔
234
    }
235

236
    /**
237
     * Extracts the type field from the event payload
238
     * @param event The window message event
239
     */
240
    private getEventType(event: MessageEvent) {
241

242
        return event.data?.type || event.data?.__type;
1,786!
243
    }
244

245
    /**
246
     * Extracts the port field from the event payload
247
     * @param event  The window message event
248
     * @returns
249
     */
250
    private getEventPort(event: MessageEvent) {
251
        if (event.ports.length && event.ports[0]) {
1,786✔
252
            return event.ports[0];
1,148✔
253
        }
254
        return null;
638✔
255
    }
256

257
    /**
258
     * Checks if preauth cache is enabled
259
     * from the view config and embed config
260
     * @returns boolean
261
     */
262
    private isPreAuthCacheEnabled() {
263
        // Disable preauth cache when:
264
        // 1. overrideOrgId is present since:
265
        //    - cached auth info would be for wrong org
266
        //    - info call response changes for each different overrideOrgId
267
        // 2. disablePreauthCache is explicitly set to true
268
        // 3. FullAppEmbed has primary navbar visible since:
269
        //    - primary navbar requires fresh auth state for navigation
270
        //    - cached auth may not reflect current user permissions
271
        const isDisabled = (
272
            this.viewConfig.overrideOrgId !== undefined
369✔
273
            || this.embedConfig.disablePreauthCache === true
274
            || this.isFullAppEmbedWithVisiblePrimaryNavbar()
275
        );
276
        return !isDisabled;
369✔
277
    }
278

279
    /**
280
     * Checks if current embed is FullAppEmbed with visible primary navbar
281
     * @returns boolean
282
     */
283
    private isFullAppEmbedWithVisiblePrimaryNavbar(): boolean {
284
        const appViewConfig = this.viewConfig as any;
367✔
285

286
        // Check if this is a FullAppEmbed (AppEmbed)
287
        // showPrimaryNavbar defaults to true if not explicitly set to false
288
        return (
367✔
289
            appViewConfig.embedComponentType === 'AppEmbed'
493✔
290
            && appViewConfig.showPrimaryNavbar === true
291
        );
292
    }
293

294
    /**
295
     * fix for ts7.sep.cl
296
     * will be removed for ts7.oct.cl
297
     * @param event
298
     * @param eventType
299
     * @hidden
300
     */
301
    private formatEventData(event: MessageEvent, eventType: string) {
302
        const eventData = {
1,786✔
303
            ...event.data,
304
            type: eventType,
305
        };
306
        if (!eventData.data) {
1,786✔
307
            eventData.data = event.data.payload;
437✔
308
        }
309
        return eventData;
1,786✔
310
    }
311

312
    private subscribedListeners: Record<string, any> = {};
396✔
313

314
    /**
315
     * Adds a global event listener to window for "message" events.
316
     * ThoughtSpot detects if a particular event is targeted to this
317
     * embed instance through an identifier contained in the payload,
318
     * and executes the registered callbacks accordingly.
319
     */
320
    private subscribeToEvents() {
321
        this.unsubscribeToEvents();
353✔
322
        const messageEventListener = (event: MessageEvent<any>) => {
353✔
323
            const eventType = this.getEventType(event);
1,786✔
324
            const eventPort = this.getEventPort(event);
1,786✔
325
            const eventData = this.formatEventData(event, eventType);
1,786✔
326
            if (event.source === this.iFrame.contentWindow) {
1,786✔
327
                this.executeCallbacks(
62✔
328
                    eventType,
329
                    processEventData(
330
                        eventType,
331
                        eventData,
332
                        this.thoughtSpotHost,
333
                        this.isPreRendered ? this.preRenderWrapper : this.el,
62✔
334
                    ),
335
                    eventPort,
336
                );
337
            }
338
        };
339
        window.addEventListener('message', messageEventListener);
353✔
340

341
        const onlineEventListener = (e: Event) => {
353✔
342
            this.trigger(HostEvent.Reload);
1✔
343
        };
1!
344
        window.addEventListener('online', onlineEventListener);
1✔
345

UNCOV
346
        const offlineEventListener = (e: Event) => {
×
347
            const offlineWarning = 'Network not Detected. Embed is offline. Please reconnect and refresh';
348
            this.executeCallbacks(EmbedEvent.Error, {
349
                offlineWarning,
1✔
350
            });
351
            logger.warn(offlineWarning);
353✔
352
        };
353
        window.addEventListener('offline', offlineEventListener);
353✔
UNCOV
354

×
UNCOV
355
        this.subscribedListeners = {
×
356
            message: messageEventListener,
357
            online: onlineEventListener,
UNCOV
358
            offline: offlineEventListener,
×
359
        };
360
    }
353✔
361

362
    private unsubscribeToEvents() {
353✔
363
        Object.keys(this.subscribedListeners).forEach((key) => {
364
            window.removeEventListener(key, this.subscribedListeners[key]);
365
        });
366
    }
367

368
    protected async getAuthTokenForCookielessInit() {
369
        let authToken = '';
370
        if (this.embedConfig.authType !== AuthType.TrustedAuthTokenCookieless) return authToken;
390✔
371

81✔
372
        try {
373
            authToken = await getAuthenticationToken(this.embedConfig);
374
        } catch (e) {
375
            processAuthFailure(e, this.isPreRendered ? this.preRenderWrapper : this.el);
376
            throw e;
20✔
377
        }
20✔
378

379
        return authToken;
6✔
380
    }
6✔
381

382
    protected async getDefaultAppInitData(): Promise<DefaultAppInitData> {
2✔
383
        const authToken = await this.getAuthTokenForCookielessInit();
2✔
384
        const customActionsResult = getCustomActions([
385
            ...(this.viewConfig.customActions || []),
386
            ...(this.embedConfig.customActions || [])
4✔
387
        ]);
388
        if (customActionsResult.errors.length > 0) {
389
            this.handleError({
390
                type: 'CUSTOM_ACTION_VALIDATION',
20✔
391
                message: customActionsResult.errors,
18✔
392
            });
35✔
393
        }
36✔
394
        return {
395
            customisations: getCustomisations(this.embedConfig, this.viewConfig),
18!
UNCOV
396
            authToken,
×
397
            runtimeFilterParams: this.viewConfig.excludeRuntimeFiltersfromURL
398
                ? getRuntimeFilters(this.viewConfig.runtimeFilters)
399
                : null,
400
            runtimeParameterParams: this.viewConfig.excludeRuntimeParametersfromURL
401
                ? getRuntimeParameters(this.viewConfig.runtimeParameters || [])
18✔
402
                : null,
403
            hiddenHomepageModules: this.viewConfig.hiddenHomepageModules || [],
404
            reorderedHomepageModules: this.viewConfig.reorderedHomepageModules || [],
18✔
405
            hostConfig: this.embedConfig.hostConfig,
406
            hiddenHomeLeftNavItems: this.viewConfig?.hiddenHomeLeftNavItems
407
                ? this.viewConfig?.hiddenHomeLeftNavItems
18✔
408
                : [],
1!
409
            customVariablesForThirdPartyTools:
410
                this.embedConfig.customVariablesForThirdPartyTools || {},
35✔
411
            hiddenListColumns: this.viewConfig.hiddenListColumns || [],
35✔
412
            customActions: customActionsResult.actions,
413
        };
72!
414
    }
3!
415

416
    protected async getAppInitData() {
417
        return this.getDefaultAppInitData();
26✔
418
    }
36✔
419

420
    /**
421
     * Send Custom style as part of payload of APP_INIT
422
     * @param _
423
     * @param responder
424
     */
20✔
425
    private appInitCb = async (_: any, responder: any) => {
426
        try {
427
            const appInitData = await this.getAppInitData();
428
            this.isAppInitialized = true;
429
            responder({
430
                type: EmbedEvent.APP_INIT,
431
                data: appInitData,
432
            });
396✔
433
        } catch (e) {
20✔
434
            logger.error(`AppInit failed, Error : ${e?.message}`);
20✔
435
        }
18✔
436
    };
18✔
437

438
    /**
439
     * Sends updated auth token to the iFrame to avoid user logout
440
     * @param _
441
     * @param responder
2!
442
     */
443
    private updateAuthToken = async (_: any, responder: any) => {
444
        const { authType } = this.embedConfig;
445
        let { autoLogin } = this.embedConfig;
446
        // Default autoLogin: true for cookieless if undefined/null, otherwise
447
        // false
448
        autoLogin = autoLogin ?? (authType === AuthType.TrustedAuthTokenCookieless);
449
        if (autoLogin && authType === AuthType.TrustedAuthTokenCookieless) {
450
            try {
396✔
451
                const authToken = await getAuthenticationToken(this.embedConfig);
10✔
452
                responder({
10✔
453
                    type: EmbedEvent.AuthExpire,
454
                    data: { authToken },
455
                });
10✔
456
            } catch (e) {
10✔
457
                logger.error(`${ERROR_MESSAGE.INVALID_TOKEN_ERROR} Error : ${e?.message}`);
5✔
458
                processAuthFailure(e, this.isPreRendered ? this.preRenderWrapper : this.el);
5✔
459
            }
3✔
460
        } else if (autoLogin) {
461
            handleAuth();
462
        }
463
        notifyAuthFailure(AuthFailureType.EXPIRY);
464
    };
2!
465

2✔
466
    /**
467
     * Auto Login and send updated authToken to the iFrame to avoid user session logout
5✔
468
     * @param _
2✔
469
     * @param responder
470
     */
10✔
471
    private idleSessionTimeout = (_: any, responder: any) => {
472
        handleAuth().then(async () => {
473
            let authToken = '';
474
            try {
475
                authToken = await getAuthenticationToken(this.embedConfig);
476
                responder({
477
                    type: EmbedEvent.IdleSessionTimeout,
478
                    data: { authToken },
396✔
479
                });
3✔
480
            } catch (e) {
3✔
481
                logger.error(`${ERROR_MESSAGE.INVALID_TOKEN_ERROR} Error : ${e?.message}`);
3✔
482
                processAuthFailure(e, this.isPreRendered ? this.preRenderWrapper : this.el);
3✔
483
            }
2✔
484
        }).catch((e) => {
485
            logger.error(`Auto Login failed, Error : ${e?.message}`);
486
        });
487
        notifyAuthFailure(AuthFailureType.IDLE_SESSION_TIMEOUT);
488
    };
1!
489

1!
490
    /**
491
     * Register APP_INIT event and sendback init payload
UNCOV
492
     */
×
493
    private registerAppInit = () => {
494
        this.on(EmbedEvent.APP_INIT, this.appInitCb, { start: false }, true);
3✔
495
        this.on(EmbedEvent.AuthExpire, this.updateAuthToken, { start: false }, true);
496
        this.on(EmbedEvent.IdleSessionTimeout, this.idleSessionTimeout, { start: false }, true);
497
        
498
        const embedListenerReadyHandler = this.createEmbedContainerHandler(EmbedEvent.EmbedListenerReady);  
499
        this.on(EmbedEvent.EmbedListenerReady, embedListenerReadyHandler, { start: false }, true);
500
        
396✔
501
        const authInitHandler = this.createEmbedContainerHandler(EmbedEvent.AuthInit);
396✔
502
        this.on(EmbedEvent.AuthInit, authInitHandler, { start: false }, true);
396✔
503
    };
396✔
504

505
    /**
396✔
506
     * Constructs the base URL string to load the ThoughtSpot app.
396✔
507
     * @param query
508
     */
396✔
509
    protected getEmbedBasePath(query: string): string {
396✔
510
        let queryString = query.startsWith('?') ? query : `?${query}`;
511
        if (this.shouldEncodeUrlQueryParams) {
512
            queryString = `?base64UrlEncodedFlags=${getEncodedQueryParamsString(
513
                queryString.substr(1),
514
            )}`;
515
        }
516
        const basePath = [this.thoughtSpotHost, this.thoughtSpotV2Base, queryString]
517
            .filter((x) => x.length > 0)
109✔
518
            .join('/');
109✔
519

1✔
520
        return `${basePath}#`;
521
    }
522

523
    /**
109✔
524
     * Common query params set for all the embed modes.
327✔
525
     * @param queryParams
526
     * @returns queryParams
527
     */
109✔
528
    protected getBaseQueryParams(queryParams: Record<any, any> = {}) {
529
        let hostAppUrl = window?.location?.host || '';
530

531
        // The below check is needed because TS Cloud firewall, blocks
532
        // localhost/127.0.0.1 in any url param.
533
        if (hostAppUrl.includes('localhost') || hostAppUrl.includes('127.0.0.1')) {
534
            hostAppUrl = 'local-host';
535
        }
124✔
536
        const blockNonEmbedFullAppAccess = this.embedConfig.blockNonEmbedFullAppAccess ?? true;
360!
537
        queryParams[Param.EmbedApp] = true;
538
        queryParams[Param.HostAppUrl] = encodeURIComponent(hostAppUrl);
539
        queryParams[Param.ViewPortHeight] = window.innerHeight;
540
        queryParams[Param.ViewPortWidth] = window.innerWidth;
360!
541
        queryParams[Param.Version] = version;
360✔
542
        queryParams[Param.AuthType] = this.embedConfig.authType;
543
        queryParams[Param.blockNonEmbedFullAppAccess] = blockNonEmbedFullAppAccess;
360✔
544
        queryParams[Param.AutoLogin] = this.embedConfig.autoLogin;
360✔
545
        if (this.embedConfig.disableLoginRedirect === true || this.embedConfig.autoLogin === true) {
360✔
546
            queryParams[Param.DisableLoginRedirect] = true;
360✔
547
        }
360✔
548
        if (this.embedConfig.authType === AuthType.EmbeddedSSO) {
360✔
549
            queryParams[Param.ForceSAMLAutoRedirect] = true;
360✔
550
        }
360✔
551
        if (this.embedConfig.authType === AuthType.TrustedAuthTokenCookieless) {
360✔
552
            queryParams[Param.cookieless] = true;
360✔
553
        }
7✔
554
        if (this.embedConfig.pendoTrackingKey) {
555
            queryParams[Param.PendoTrackingKey] = this.embedConfig.pendoTrackingKey;
360!
UNCOV
556
        }
×
557
        if (this.embedConfig.numberFormatLocale) {
558
            queryParams[Param.NumberFormatLocale] = this.embedConfig.numberFormatLocale;
360✔
559
        }
15✔
560
        if (this.embedConfig.dateFormatLocale) {
561
            queryParams[Param.DateFormatLocale] = this.embedConfig.dateFormatLocale;
360✔
562
        }
1✔
563
        if (this.embedConfig.currencyFormat) {
564
            queryParams[Param.CurrencyFormat] = this.embedConfig.currencyFormat;
360✔
565
        }
13✔
566

567
        const {
360✔
568
            disabledActions,
13✔
569
            disabledActionReason,
570
            hiddenActions,
360✔
571
            visibleActions,
13✔
572
            hiddenTabs,
573
            visibleTabs,
574
            showAlerts,
575
            additionalFlags: additionalFlagsFromView,
576
            locale,
577
            customizations,
578
            contextMenuTrigger,
579
            linkOverride,
580
            insertInToSlide,
581
            disableRedirectionLinksInNewTab,
582
            overrideOrgId,
583
            exposeTranslationIDs,
584
            primaryAction,
585
        } = this.viewConfig;
586

587
        const { additionalFlags: additionalFlagsFromInit } = this.embedConfig;
588

589
        const additionalFlags = {
590
            ...additionalFlagsFromInit,
591
            ...additionalFlagsFromView,
592
        };
360✔
593

594
        if (Array.isArray(visibleActions) && Array.isArray(hiddenActions)) {
360✔
595
            this.handleError('You cannot have both hidden actions and visible actions');
596
            return queryParams;
360✔
597
        }
598

599
        if (Array.isArray(visibleTabs) && Array.isArray(hiddenTabs)) {
600
            this.handleError('You cannot have both hidden Tabs and visible Tabs');
601
            return queryParams;
360✔
602
        }
4✔
603
        if (primaryAction) {
4✔
604
            queryParams[Param.PrimaryAction] = primaryAction;
605
        }
606

356✔
607
        if (disabledActions?.length) {
4✔
608
            queryParams[Param.DisableActions] = disabledActions;
4✔
609
        }
610
        if (disabledActionReason) {
352!
UNCOV
611
            queryParams[Param.DisableActionReason] = disabledActionReason;
×
612
        }
613
        if (exposeTranslationIDs) {
614
            queryParams[Param.ExposeTranslationIDs] = exposeTranslationIDs;
352✔
615
        }
6✔
616
        queryParams[Param.HideActions] = [...this.defaultHiddenActions, ...(hiddenActions ?? [])];
617
        if (Array.isArray(visibleActions)) {
352✔
618
            queryParams[Param.VisibleActions] = visibleActions;
6✔
619
        }
620
        if (Array.isArray(hiddenTabs)) {
352✔
621
            queryParams[Param.HiddenTabs] = hiddenTabs;
1✔
622
        }
623
        if (Array.isArray(visibleTabs)) {
352✔
624
            queryParams[Param.VisibleTabs] = visibleTabs;
352✔
625
        }
5✔
626
        /**
627
         * Default behavior for context menu will be left-click
352✔
628
         *  from version 9.2.0.cl the user have an option to override context
2✔
629
         *  menu click
630
         */
352✔
631
        if (contextMenuTrigger === ContextMenuTriggerOptions.LEFT_CLICK) {
1✔
632
            queryParams[Param.ContextMenuTrigger] = 'left';
633
        } else if (contextMenuTrigger === ContextMenuTriggerOptions.RIGHT_CLICK) {
634
            queryParams[Param.ContextMenuTrigger] = 'right';
635
        } else if (contextMenuTrigger === ContextMenuTriggerOptions.BOTH_CLICKS) {
636
            queryParams[Param.ContextMenuTrigger] = 'both';
637
        }
638

352✔
639
        const embedCustomizations = this.embedConfig.customizations;
3✔
640
        const spriteUrl = customizations?.iconSpriteUrl || embedCustomizations?.iconSpriteUrl;
349✔
641
        if (spriteUrl) {
2✔
642
            queryParams[Param.IconSpriteUrl] = spriteUrl.replace('https://', '');
347✔
643
        }
2✔
644

645
        const stringIDsUrl = customizations?.content?.stringIDsUrl
646
            || embedCustomizations?.content?.stringIDsUrl;
352✔
647
        if (stringIDsUrl) {
352✔
648
            queryParams[Param.StringIDsUrl] = stringIDsUrl;
352✔
649
        }
1✔
650

651
        if (showAlerts !== undefined) {
652
            queryParams[Param.ShowAlerts] = showAlerts;
352✔
653
        }
2,112✔
654
        if (locale !== undefined) {
352✔
655
            queryParams[Param.Locale] = locale;
2✔
656
        }
657

658
        if (linkOverride) {
352✔
659
            queryParams[Param.LinkOverride] = linkOverride;
1✔
660
        }
661
        if (insertInToSlide) {
352✔
662
            queryParams[Param.ShowInsertToSlide] = insertInToSlide;
1✔
663
        }
664
        if (disableRedirectionLinksInNewTab) {
665
            queryParams[Param.DisableRedirectionLinksInNewTab] = disableRedirectionLinksInNewTab;
352!
UNCOV
666
        }
×
667
        if (overrideOrgId !== undefined) {
668
            queryParams[Param.OverrideOrgId] = overrideOrgId;
352!
UNCOV
669
        }
×
670

671
        if (this.isPreAuthCacheEnabled()) {
352!
UNCOV
672
            queryParams[Param.preAuthCache] = true;
×
673
        }
674

352✔
675
        queryParams[Param.OverrideNativeConsole] = true;
3✔
676
        queryParams[Param.ClientLogLevel] = this.embedConfig.logLevel;
677

678
        if (isObject(additionalFlags) && !isEmpty(additionalFlags)) {
352✔
679
            Object.assign(queryParams, additionalFlags);
343✔
680
        }
681

682
        // Do not add any flags below this, as we want additional flags to
352✔
683
        // override other flags
352✔
684

685
        return queryParams;
352✔
686
    }
8✔
687

688
    /**
689
     * Constructs the base URL string to load v1 of the ThoughtSpot app.
690
     * This is used for embedding Liveboards, visualizations, and full application.
691
     * @param queryString The query string to append to the URL.
692
     * @param isAppEmbed A Boolean parameter to specify if you are embedding
352✔
693
     * the full application.
694
     */
695
    protected getV1EmbedBasePath(queryString: string): string {
696
        const queryParams = this.shouldEncodeUrlQueryParams
697
            ? `?base64UrlEncodedFlags=${getEncodedQueryParamsString(queryString)}`
698
            : `?${queryString}`;
699
        const host = this.thoughtSpotHost;
700
        const path = `${host}/${queryParams}#`;
701
        return path;
702
    }
703

251✔
704
    protected getEmbedParams() {
705
        const queryParams = this.getBaseQueryParams();
706
        return getQueryParamString(queryParams);
251✔
707
    }
251✔
708

251✔
709
    protected getRootIframeSrc() {
710
        const query = this.getEmbedParams();
711
        return this.getEmbedBasePath(query);
UNCOV
712
    }
×
UNCOV
713

×
714
    protected createIframeEl(frameSrc: string): HTMLIFrameElement {
715
        const iFrame = document.createElement('iframe');
716

717
        iFrame.src = frameSrc;
86✔
718
        iFrame.id = TS_EMBED_ID;
86✔
719
        iFrame.setAttribute('data-ts-iframe', 'true');
720

721
        // according to screenfull.js documentation
722
        // allowFullscreen, webkitallowfullscreen and mozallowfullscreen must be
339✔
723
        // true
724
        iFrame.allowFullscreen = true;
339✔
725
        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
339✔
726
        // @ts-ignore
339✔
727
        iFrame.webkitallowfullscreen = true;
728
        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
729
        // @ts-ignore
730
        iFrame.mozallowfullscreen = true;
731
        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
339✔
732
        // @ts-ignore
733
        iFrame.allow = 'clipboard-read; clipboard-write; fullscreen;';
734

339✔
735
        const frameParams = this.viewConfig.frameParams;
736
        const { height: frameHeight, width: frameWidth, ...restParams } = frameParams || {};
737
        const width = getCssDimension(frameWidth || DEFAULT_EMBED_WIDTH);
339✔
738
        const height = getCssDimension(frameHeight || DEFAULT_EMBED_HEIGHT);
739
        setAttributes(iFrame, restParams);
740

339✔
741
        iFrame.style.width = `${width}`;
742
        iFrame.style.height = `${height}`;
339✔
743
        // Set minimum height to the frame so that,
339✔
744
        // scaling down on the fullheight doesn't make it too small.
339✔
745
        iFrame.style.minHeight = `${height}`;
339✔
746
        iFrame.style.border = '0';
339✔
747
        iFrame.name = 'ThoughtSpot Embedded Analytics';
748
        return iFrame;
339✔
749
    }
339✔
750

751
    protected handleInsertionIntoDOM(child: string | Node): void {
752
        if (this.isPreRendered) {
339✔
753
            this.insertIntoDOMForPreRender(child);
339✔
754
        } else {
339✔
755
            this.insertIntoDOM(child);
339✔
756
        }
757
        if (this.insertedDomEl instanceof Node) {
758
            (this.insertedDomEl as any)[this.embedNodeKey] = this;
759
        }
351✔
760
    }
12✔
761

762
    /**
339✔
763
     * Renders the embedded ThoughtSpot app in an iframe and sets up
764
     * event listeners.
351✔
765
     * @param url - The URL of the embedded ThoughtSpot app.
349✔
766
     */
767
    protected async renderIFrame(url: string): Promise<any> {
768
        if (this.isError) {
769
            return null;
770
        }
771
        if (!this.thoughtSpotHost) {
772
            this.throwInitError();
773
        }
774
        if (url.length > URL_MAX_LENGTH) {
775
            // warn: The URL is too long
359✔
776
        }
8✔
777

778
        return renderInQueue((nextInQueue) => {
351✔
779
            const initTimestamp = Date.now();
1✔
780

781
            this.executeCallbacks(EmbedEvent.Init, {
351!
782
                data: {
783
                    timestamp: initTimestamp,
784
                },
785
                type: EmbedEvent.Init,
351✔
786
            });
351✔
787

788
            uploadMixpanelEvent(MIXPANEL_EVENT.VISUAL_SDK_RENDER_START);
351✔
789

790
            return getAuthPromise()
791
                ?.then((isLoggedIn: boolean) => {
792
                    if (!isLoggedIn) {
793
                        this.handleInsertionIntoDOM(this.embedConfig.loginFailedMessage);
794
                        return;
795
                    }
351✔
796

797
                    this.setIframeElement(this.iFrame || this.createIframeEl(url));
351!
798
                    this.iFrame.addEventListener('load', () => {
799
                        nextInQueue();
350✔
800
                        const loadTimestamp = Date.now();
3✔
801
                        this.executeCallbacks(EmbedEvent.Load, {
3✔
802
                            data: {
803
                                timestamp: loadTimestamp,
804
                            },
347✔
805
                            type: EmbedEvent.Load,
347✔
806
                        });
17✔
807
                        uploadMixpanelEvent(MIXPANEL_EVENT.VISUAL_SDK_RENDER_COMPLETE, {
17✔
808
                            elWidth: this.iFrame.clientWidth,
17✔
809
                            elHeight: this.iFrame.clientHeight,
810
                            timeTookToLoad: loadTimestamp - initTimestamp,
811
                        });
812
                        // Send info event  if preauth cache is enabled
813
                        if (this.isPreAuthCacheEnabled()) {
814
                            getPreauthInfo().then((data) => {
17✔
815
                                if (data?.info) {
816
                                    this.trigger(HostEvent.InfoSuccess, data);
817
                                }
818
                            });
819
                        }
820

17✔
821
                        // Setup fullscreen change handler after iframe is
13✔
822
                        // loaded and ready
13✔
823
                        this.setupFullscreenChangeHandler();
5✔
824
                    });
825
                    this.iFrame.addEventListener('error', () => {
826
                        nextInQueue();
827
                    });
828
                    this.handleInsertionIntoDOM(this.iFrame);
829
                    const prefetchIframe = document.querySelectorAll('.prefetchIframe');
830
                    if (prefetchIframe.length) {
17✔
831
                        prefetchIframe.forEach((el) => {
832
                            el.remove();
347✔
833
                        });
1✔
834
                    }
835
                    this.subscribeToEvents();
347✔
836
                })
347✔
837
                .catch((error) => {
347!
UNCOV
838
                    nextInQueue();
×
UNCOV
839
                    uploadMixpanelEvent(MIXPANEL_EVENT.VISUAL_SDK_RENDER_FAILED, {
×
840
                        error: JSON.stringify(error),
841
                    });
842
                    this.handleInsertionIntoDOM(this.embedConfig.loginFailedMessage);
347✔
843
                    this.handleError(error);
844
                });
845
        });
1✔
846
    }
1✔
847

848
    protected createPreRenderWrapper(): HTMLDivElement {
849
        const preRenderIds = this.getPreRenderIds();
1✔
850

1✔
851
        document.getElementById(preRenderIds.wrapper)?.remove();
852

853
        const preRenderWrapper = document.createElement('div');
854
        preRenderWrapper.id = preRenderIds.wrapper;
855
        const initialPreRenderWrapperStyle = {
856
            position: 'absolute',
12✔
857
            width: '100vw',
858
            height: '100vh',
12✔
859
        };
860
        setStyleProperties(preRenderWrapper, initialPreRenderWrapperStyle);
12✔
861

12✔
862
        return preRenderWrapper;
12✔
863
    }
864

865
    protected preRenderWrapper: HTMLElement;
866

867
    protected preRenderChild: HTMLElement;
12✔
868

869
    protected connectPreRendered(): boolean {
12✔
870
        const preRenderIds = this.getPreRenderIds();
871
        const preRenderWrapperElement = document.getElementById(preRenderIds.wrapper);
872
        this.preRenderWrapper = this.preRenderWrapper || preRenderWrapperElement;
873

874
        this.preRenderChild = this.preRenderChild || document.getElementById(preRenderIds.child);
875

876
        if (this.preRenderWrapper && this.preRenderChild) {
877
            this.isPreRendered = true;
17✔
878
            if (this.preRenderChild instanceof HTMLIFrameElement) {
17✔
879
                this.setIframeElement(this.preRenderChild);
17✔
880
            }
881
            this.insertedDomEl = this.preRenderWrapper;
17✔
882
            this.isRendered = true;
883
        }
17✔
884

4✔
885
        return this.isPreRenderAvailable();
4✔
886
    }
4✔
887

888
    protected isPreRenderAvailable(): boolean {
4✔
889
        return (
4✔
890
            this.isRendered
891
            && this.isPreRendered
892
            && Boolean(this.preRenderWrapper && this.preRenderChild)
17✔
893
        );
894
    }
895

896
    protected createPreRenderChild(child: string | Node): HTMLElement {
44✔
897
        const preRenderIds = this.getPreRenderIds();
92✔
898

899
        document.getElementById(preRenderIds.child)?.remove();
48✔
900

901
        if (child instanceof HTMLElement) {
902
            child.id = preRenderIds.child;
903
            return child;
904
        }
12✔
905

906
        const divChildNode = document.createElement('div');
12✔
907
        setStyleProperties(divChildNode, { width: '100%', height: '100%' });
908
        divChildNode.id = preRenderIds.child;
12✔
909

11✔
910
        if (typeof child === 'string') {
11✔
911
            divChildNode.innerHTML = child;
912
        } else {
913
            divChildNode.appendChild(child);
1✔
914
        }
1✔
915

1✔
916
        return divChildNode;
917
    }
1!
918

1✔
919
    protected insertIntoDOMForPreRender(child: string | Node): void {
UNCOV
920
        const preRenderChild = this.createPreRenderChild(child);
×
921
        const preRenderWrapper = this.createPreRenderWrapper();
922
        preRenderWrapper.appendChild(preRenderChild);
923

1✔
924
        this.preRenderChild = preRenderChild;
925
        this.preRenderWrapper = preRenderWrapper;
926

927
        if (preRenderChild instanceof HTMLIFrameElement) {
12✔
928
            this.setIframeElement(preRenderChild);
12✔
929
        }
12✔
930
        this.insertedDomEl = preRenderWrapper;
931

12✔
932
        if (this.showPreRenderByDefault) {
12✔
933
            this.showPreRender();
934
        } else {
12✔
935
            this.hidePreRender();
11✔
936
        }
937

12✔
938
        document.body.appendChild(preRenderWrapper);
939
    }
12✔
940

2✔
941
    private showPreRenderByDefault = false;
942

10✔
943
    protected insertIntoDOM(child: string | Node): void {
944
        if (this.viewConfig.insertAsSibling) {
945
            if (typeof child === 'string') {
12✔
946
                const div = document.createElement('div');
947
                div.innerHTML = child;
948
                div.id = TS_EMBED_ID;
396✔
949

950
                child = div;
951
            }
339✔
952
            if (this.el.nextElementSibling?.id === TS_EMBED_ID) {
12✔
953
                this.el.nextElementSibling.remove();
1✔
954
            }
1✔
955
            this.el.parentElement.insertBefore(child, this.el.nextSibling);
1✔
956
            this.insertedDomEl = child;
957
        } else if (typeof child === 'string') {
1✔
958
            this.el.innerHTML = child;
959
            this.insertedDomEl = this.el.children[0];
12✔
960
        } else {
1✔
961
            this.el.innerHTML = '';
962
            this.el.appendChild(child);
12✔
963
            this.insertedDomEl = child;
12✔
964
        }
327✔
965
    }
2✔
966

2✔
967
    /**
968
     * Sets the height of the iframe
325✔
969
     * @param height The height in pixels
325✔
970
     */
325✔
971
    protected setIFrameHeight(height: number | string): void {
972
        this.iFrame.style.height = getCssDimension(height);
973
    }
974

975
    /**
976
     * Executes all registered event handlers for a particular event type
977
     * @param eventType The event type
978
     * @param data The payload invoked with the event handler
979
     * @param eventPort The event Port for a specific MessageChannel
2✔
980
     */
981
    protected executeCallbacks(
982
        eventType: EmbedEvent,
983
        data: any,
984
        eventPort?: MessagePort | void,
985
    ): void {
986
        const eventHandlers = this.eventHandlerMap.get(eventType) || [];
987
        const allHandlers = this.eventHandlerMap.get(EmbedEvent.ALL) || [];
988
        const callbacks = [...eventHandlers, ...allHandlers];
989
        const dataStatus = data?.status || embedEventStatus.END;
990
        callbacks.forEach((callbackObj) => {
991
            if (
992
                // When start status is true it trigger only start releated
993
                // payload
441✔
994
                (callbackObj.options.start && dataStatus === embedEventStatus.START)
441✔
995
                // When start status is false it trigger only end releated
441✔
996
                // payload
441!
997
                || (!callbackObj.options.start && dataStatus === embedEventStatus.END)
441✔
998
            ) {
60✔
999
                callbackObj.callback(data, (payload) => {
1000
                    this.triggerEventOnPort(eventPort, payload);
1001
                });
179✔
1002
            }
1003
        });
1004
    }
1005

1006
    /**
59✔
1007
     * Returns the ThoughtSpot hostname or IP address.
27✔
1008
     */
1009
    protected getThoughtSpotHost(): string {
1010
        return this.thoughtSpotHost;
1011
    }
1012

1013
    /**
1014
     * Gets the v1 event type (if applicable) for the EmbedEvent type
1015
     * @param eventType The v2 event type
1016
     * @returns The corresponding v1 event type if one exists
UNCOV
1017
     * or else the v2 event type itself
×
1018
     */
1019
    protected getCompatibleEventType(eventType: EmbedEvent): EmbedEvent {
1020
        return V1EventMap[eventType] || eventType;
1021
    }
1022

1023
    /**
1024
     * Calculates the iframe center for the current visible viewPort
1025
     * of iframe using Scroll position of Host App, offsetTop for iframe
1026
     * in Host app. ViewPort height of the tab.
1027
     * @returns iframe Center in visible viewport,
1,431✔
1028
     *  Iframe height,
1029
     *  View port height.
1030
     */
1031
    protected getIframeCenter() {
1032
        const offsetTopClient = getOffsetTop(this.iFrame);
1033
        const scrollTopClient = window.scrollY;
1034
        const viewPortHeight = window.innerHeight;
1035
        const iframeHeight = this.iFrame.offsetHeight;
1036
        const iframeScrolled = scrollTopClient - offsetTopClient;
1037
        let iframeVisibleViewPort;
1038
        let iframeOffset;
1039

4✔
1040
        if (iframeScrolled < 0) {
4✔
1041
            iframeVisibleViewPort = viewPortHeight - (offsetTopClient - scrollTopClient);
4✔
1042
            iframeVisibleViewPort = Math.min(iframeHeight, iframeVisibleViewPort);
4✔
1043
            iframeOffset = 0;
4✔
1044
        } else {
1045
            iframeVisibleViewPort = Math.min(iframeHeight - iframeScrolled, viewPortHeight);
1046
            iframeOffset = iframeScrolled;
1047
        }
4!
UNCOV
1048
        const iframeCenter = iframeOffset + iframeVisibleViewPort / 2;
×
UNCOV
1049
        return {
×
UNCOV
1050
            iframeCenter,
×
1051
            iframeScrolled,
1052
            iframeHeight,
4✔
1053
            viewPortHeight,
4✔
1054
            iframeVisibleViewPort,
1055
        };
4✔
1056
    }
4✔
1057

1058
    /**
1059
     * Registers an event listener to trigger an alert when the ThoughtSpot app
1060
     * sends an event of a particular message type to the host application.
1061
     * @param messageType The message type
1062
     * @param callback A callback as a function
1063
     * @param options The message options
1064
     * @param isSelf
1065
     * @param isRegisteredBySDK
1066
     * @example
1067
     * ```js
1068
     * tsEmbed.on(EmbedEvent.Error, (data) => {
1069
     *   console.error(data);
1070
     * });
1071
     * ```
1072
     * @example
1073
     * ```js
1074
     * tsEmbed.on(EmbedEvent.Save, (data) => {
1075
     *   console.log("Answer save clicked", data);
1076
     * }, {
1077
     *   start: true // This will trigger the callback on start of save
1078
     * });
1079
     * ```
1080
     */
1081
    public on(
1082
        messageType: EmbedEvent,
1083
        callback: MessageCallback,
1084
        options: MessageOptions = { start: false },
1085
        isRegisteredBySDK = false,
1086
    ): typeof TsEmbed.prototype {
1087
        uploadMixpanelEvent(`${MIXPANEL_EVENT.VISUAL_SDK_ON}-${messageType}`, {
1088
            isRegisteredBySDK,
1089
        });
1090
        if (this.isRendered) {
1091
            logger.warn('Please register event handlers before calling render');
14✔
1092
        }
1,447✔
1093
        const callbacks = this.eventHandlerMap.get(messageType) || [];
1094
        callbacks.push({ options, callback });
2,087✔
1095
        this.eventHandlerMap.set(messageType, callbacks);
1096
        return this;
1097
    }
2,087✔
1098

2✔
1099
    /**
1100
     * Removes an event listener for a particular event type.
2,087✔
1101
     * @param messageType The message type
2,087✔
1102
     * @param callback The callback to remove
2,087✔
1103
     * @example
2,087✔
1104
     * ```js
1105
     * const errorHandler = (data) => { console.error(data); };
1106
     * tsEmbed.on(EmbedEvent.Error, errorHandler);
1107
     * tsEmbed.off(EmbedEvent.Error, errorHandler);
1108
     * ```
1109
     */
1110
    public off(messageType: EmbedEvent, callback: MessageCallback): typeof TsEmbed.prototype {
1111
        const callbacks = this.eventHandlerMap.get(messageType) || [];
1112
        const index = callbacks.findIndex((cb) => cb.callback === callback);
1113
        if (index > -1) {
1114
            callbacks.splice(index, 1);
1115
        }
1116
        return this;
1117
    }
1118

1!
1119
    /**
1✔
1120
     * Triggers an event on specific Port registered against
1✔
1121
     * for the EmbedEvent
1✔
1122
     * @param eventType The message type
1123
     * @param data The payload to send
1✔
1124
     * @param eventPort
1125
     * @param payload
1126
     */
1127
    private triggerEventOnPort(eventPort: MessagePort | void, payload: any) {
1128
        if (eventPort) {
1129
            try {
1130
                eventPort.postMessage({
1131
                    type: payload.type,
1132
                    data: payload.data,
1133
                });
1134
            } catch (e) {
1135
                eventPort.postMessage({ error: e });
27✔
1136
                logger.log(e);
23✔
1137
            }
23✔
1138
        } else {
1139
            logger.log('Event Port is not defined');
1140
        }
1141
    }
UNCOV
1142

×
UNCOV
1143
    /**
×
1144
     * @hidden
1145
     * Internal state to track if the embed container is loaded.
1146
     * This is used to trigger events after the embed container is loaded.
4✔
1147
     */
1148
    public isEmbedContainerLoaded = false;
1149

1150
    /**
1151
     * @hidden
1152
     * Internal state to track the callbacks to be executed after the embed container 
1153
     * is loaded.
1154
     * This is used to trigger events after the embed container is loaded.
1155
     */
396✔
1156
    private embedContainerReadyCallbacks: Array<() => void> = [];
1157

1158
    protected getPreRenderObj<T extends TsEmbed>(): T {
1159
        const embedObj = (this.insertedDomEl as any)?.[this.embedNodeKey] as T;
1160
        if (embedObj === (this as any)) {
1161
            logger.info('embedObj is same as this');
1162
        }
1163
        return embedObj;
396✔
1164
    }
1165

1166
    private checkEmbedContainerLoaded() {
28✔
1167
        if (this.isEmbedContainerLoaded) return true;
28✔
1168

5✔
1169
        const preRenderObj = this.getPreRenderObj<TsEmbed>();
1170
        if (preRenderObj && preRenderObj.isEmbedContainerLoaded) {
28✔
1171
            this.isEmbedContainerLoaded = true;
1172
        }
1173

1174
        return this.isEmbedContainerLoaded;
25✔
1175
    }
1176
    private executeEmbedContainerReadyCallbacks() {
20✔
1177
        logger.debug('executePendingEvents', this.embedContainerReadyCallbacks);
20✔
1178
        this.embedContainerReadyCallbacks.forEach((callback) => {
2✔
1179
            callback?.();
1180
        });
1181
        this.embedContainerReadyCallbacks = [];
20✔
1182
    }
1183

1184
    /**
14✔
1185
     * Executes a callback after the embed container is loaded.
14✔
1186
     * @param callback The callback to execute
11!
1187
     */
1188
    protected executeAfterEmbedContainerLoaded(callback: () => void) {
14✔
1189
        if (this.checkEmbedContainerLoaded()) {
1190
            callback?.();
1191
        } else {
1192
            logger.debug('pushing callback to embedContainerReadyCallbacks', callback);
1193
            this.embedContainerReadyCallbacks.push(callback);
1194
          }
1195
    }
1196

24✔
1197
    protected createEmbedContainerHandler = (source: EmbedEvent.AuthInit | EmbedEvent.EmbedListenerReady) => () => {
6!
1198
        const processEmbedContainerReady = () => {
1199
            logger.debug('processEmbedContainerReady');
18✔
1200
            this.isEmbedContainerLoaded = true;
18✔
1201
            this.executeEmbedContainerReadyCallbacks();
1202
        }
1203
        if (source === EmbedEvent.AuthInit) {
1204
            const AUTH_INIT_FALLBACK_DELAY = 1000;
794✔
1205
            // Wait for 1 second to ensure the embed container is loaded
10✔
1206
            // This is a workaround to ensure the embed container is loaded
9✔
1207
            // this is needed until all clusters have EmbedListenerReady event
9✔
1208
            setTimeout(processEmbedContainerReady, AUTH_INIT_FALLBACK_DELAY);
9✔
1209
        } else if (source === EmbedEvent.EmbedListenerReady) {
1210
            processEmbedContainerReady();
10✔
1211
        }
7✔
1212
    }
1213

1214
    /**
1215
     * Triggers an event to the embedded app
7✔
1216
     * @param {HostEvent} messageType The event type
3✔
1217
     * @param {any} data The payload to send with the message
3✔
1218
     * @returns A promise that resolves with the response from the embedded app
1219
     */
1220
    public async trigger<HostEventT extends HostEvent, PayloadT>(
1221
        messageType: HostEventT,
1222
        data: TriggerPayload<PayloadT, HostEventT> = {} as any,
1223
    ): Promise<TriggerResponse<PayloadT, HostEventT>> {
1224
        uploadMixpanelEvent(`${MIXPANEL_EVENT.VISUAL_SDK_TRIGGER}-${messageType}`);
1225

1226
        if (!this.isRendered) {
1227
            this.handleError('Please call render before triggering events');
1228
            return null;
1229
        }
1✔
1230

1231
        if (!messageType) {
27✔
1232
            this.handleError('Host event type is undefined');
1233
            return null;
27!
UNCOV
1234
        }
×
UNCOV
1235
        // send an empty object, this is needed for liveboard default handlers
×
1236
        return this.hostEventClient.triggerHostEvent(messageType, data);
1237
    }
1238

27!
UNCOV
1239
    /**
×
UNCOV
1240
     * Triggers an event to the embedded app, skipping the UI flow.
×
1241
     * @param {UIPassthroughEvent} apiName - The name of the API to be triggered.
1242
     * @param {UIPassthroughRequest} parameters - The parameters to be passed to the API.
1243
     * @returns {Promise<UIPassthroughRequest>} - A promise that resolves with the response
27✔
1244
     * from the embedded app.
1245
     */
1246
    public async triggerUIPassThrough<UIPassthroughEventT extends UIPassthroughEvent>(
1247
        apiName: UIPassthroughEventT,
1248
        parameters: UIPassthroughRequest<UIPassthroughEventT>,
1249
    ): Promise<UIPassthroughArrayResponse<UIPassthroughEventT>> {
1250
        const response = this.hostEventClient.triggerUIPassthroughApi(apiName, parameters);
1251
        return response;
1252
    }
1253

1254
    /**
1255
     * Marks the ThoughtSpot object to have been rendered
1256
     * Needs to be overridden by subclasses to do the actual
1257
     * rendering of the iframe.
1✔
1258
     * @param args
1✔
1259
     */
1260
    public async render(): Promise<TsEmbed> {
1261
        if (!getIsInitCalled()) {
1262
            logger.error(ERROR_MESSAGE.RENDER_CALLED_BEFORE_INIT);
1263
        }
1264
        await this.isReadyForRenderPromise;
1265
        this.isRendered = true;
1266

1267
        return this;
1268
    }
353✔
1269

2✔
1270
    public getIframeSrc(): string {
1271
        return '';
353✔
1272
    }
353✔
1273

1274
    protected handleRenderForPrerender() {
353✔
1275
        return this.render();
1276
    }
1277

UNCOV
1278
    /**
×
1279
     * Creates the preRender shell
1280
     * @param showPreRenderByDefault - Show the preRender after render, hidden by default
1281
     */
1282
    public async preRender(showPreRenderByDefault = false, replaceExistingPreRender = false): Promise<TsEmbed> {
7✔
1283
        if (!this.viewConfig.preRenderId) {
1284
            logger.error(ERROR_MESSAGE.PRERENDER_ID_MISSING);
1285
            return this;
1286
        }
1287
        this.isPreRendered = true;
1288
        this.showPreRenderByDefault = showPreRenderByDefault;
1289
        
22✔
1290
        const isAlreadyRendered = this.connectPreRendered();
13✔
1291
        if (isAlreadyRendered && !replaceExistingPreRender) {
1✔
1292
            return this;
1✔
1293
        }
1294

12✔
1295
        return this.handleRenderForPrerender();
12✔
1296
    }
1297

12✔
1298
    /**
12!
UNCOV
1299
     * Get the Post Url Params for THOUGHTSPOT from the current
×
1300
     * host app URL.
1301
     * THOUGHTSPOT URL params starts with a prefix "ts-"
1302
     * @version SDK: 1.14.0 | ThoughtSpot: 8.4.0.cl, 8.4.1-sw
12✔
1303
     */
1304
    public getThoughtSpotPostUrlParams(
1305
        additionalParams: { [key: string]: string | number } = {},
1306
    ): string {
1307
        const urlHash = window.location.hash;
1308
        const queryParams = window.location.search;
1309
        const postHashParams = urlHash.split('?');
1310
        const postURLParams = postHashParams[postHashParams.length - 1];
1311
        const queryParamsObj = new URLSearchParams(queryParams);
1312
        const postURLParamsObj = new URLSearchParams(postURLParams);
339✔
1313
        const params = new URLSearchParams();
1314

358✔
1315
        const addKeyValuePairCb = (value: string, key: string): void => {
358✔
1316
            if (key.startsWith(THOUGHTSPOT_PARAM_PREFIX)) {
358✔
1317
                params.append(key, value);
358✔
1318
            }
358✔
1319
        };
358✔
1320
        queryParamsObj.forEach(addKeyValuePairCb);
358✔
1321
        postURLParamsObj.forEach(addKeyValuePairCb);
1322
        Object.entries(additionalParams).forEach(([k, v]) => params.append(k, v as string));
358✔
1323

8✔
1324
        let tsParams = params.toString();
5✔
1325
        tsParams = tsParams ? `?${tsParams}` : '';
1326

1327
        return tsParams;
358✔
1328
    }
358✔
1329

358✔
1330
    /**
1331
     * Destroys the ThoughtSpot embed, and remove any nodes from the DOM.
358✔
1332
     * @version SDK: 1.19.1 | ThoughtSpot: *
358✔
1333
     */
1334
    public destroy(): void {
358✔
1335
        try {
1336
            this.removeFullscreenChangeHandler();
1337
            this.insertedDomEl?.parentNode.removeChild(this.insertedDomEl);
1338
            this.unsubscribeToEvents();
1339
        } catch (e) {
1340
            logger.log('Error destroying TS Embed', e);
1341
        }
1342
    }
26✔
1343

26✔
1344
    public getUnderlyingFrameElement(): HTMLIFrameElement {
26✔
1345
        return this.iFrame;
26✔
1346
    }
UNCOV
1347

×
1348
    /**
1349
     * Prerenders a generic instance of the TS component.
1350
     * This means without the path but with the flags already applied.
1351
     * This is useful for prerendering the component in the background.
1352
     * @version SDK: 1.22.0
1✔
1353
     * @returns
1354
     */
1355
    public async prerenderGeneric(): Promise<any> {
1356
        if (!getIsInitCalled()) {
1357
            logger.error(ERROR_MESSAGE.RENDER_CALLED_BEFORE_INIT);
1358
        }
1359
        await this.isReadyForRenderPromise;
1360

1361
        const prerenderFrameSrc = this.getRootIframeSrc();
1362
        this.isRendered = true;
1363
        return this.renderIFrame(prerenderFrameSrc);
8✔
1364
    }
1✔
1365

1366
    protected beforePrerenderVisible(): void {
8✔
1367
        // Override in subclass
1368
    }
8✔
1369

8✔
1370
    private validatePreRenderViewConfig = (viewConfig: ViewConfig) => {
8✔
1371
        const preRenderAllowedKeys = ['preRenderId', 'vizId', 'liveboardId'];
1372
        const preRenderedObject = (this.insertedDomEl as any)?.[this.embedNodeKey] as TsEmbed;
1373
        if (!preRenderedObject) return;
1374
        if (viewConfig.preRenderId) {
1375
            const allOtherKeys = Object.keys(viewConfig).filter(
1376
                (key) => !preRenderAllowedKeys.includes(key) && !key.startsWith('on'),
1377
            );
396✔
1378

3✔
1379
            allOtherKeys.forEach((key: keyof ViewConfig) => {
3!
1380
                if (
3!
1381
                    !isUndefined(viewConfig[key])
3✔
1382
                    && !isEqual(viewConfig[key], preRenderedObject.viewConfig[key])
3✔
1383
                ) {
18✔
1384
                    logger.warn(
1385
                        `${viewConfig.embedComponentType || 'Component'} was pre-rendered with `
1386
                        + `"${key}" as "${JSON.stringify(preRenderedObject.viewConfig[key])}" `
3✔
1387
                        + `but a different value "${JSON.stringify(viewConfig[key])}" `
10✔
1388
                        + 'was passed to the Embed component. '
20✔
1389
                        + 'The new value provided is ignored, the value provided during '
1390
                        + 'preRender is used.',
1391
                    );
4✔
1392
                }
4!
1393
            });
1394
        }
1395
    };
1396

1397
    /**
1398
     * Displays the PreRender component.
1399
     * If the component is not preRendered, it attempts to create and render it.
1400
     * Also, synchronizes the style of the PreRender component with the embedding
1401
     * element.
1402
     */
1403
    public async showPreRender(): Promise<TsEmbed> {
1404
        if (!this.viewConfig.preRenderId) {
1405
            logger.error(ERROR_MESSAGE.PRERENDER_ID_MISSING);
1406
            return this;
1407
        }
1408
        if (!this.isPreRenderAvailable()) {
1409
            const isAvailable = this.connectPreRendered();
1410

1411
            if (!isAvailable) {
9✔
1412
                // if the Embed component is not preRendered , Render it now and
1✔
1413
                return this.preRender(true);
1✔
1414
            }
1415
            this.validatePreRenderViewConfig(this.viewConfig);
8✔
1416
        }
5✔
1417

1418
        if (this.el) {
5✔
1419
            this.syncPreRenderStyle();
1420
            if (!this.viewConfig.doNotTrackPreRenderSize) {
2✔
1421
                this.resizeObserver = new ResizeObserver((entries) => {
1422
                    entries.forEach((entry) => {
3✔
1423
                        if (entry.contentRect && entry.target === this.el) {
1424
                            setStyleProperties(this.preRenderWrapper, {
1425
                                width: `${entry.contentRect.width}px`,
6✔
1426
                                height: `${entry.contentRect.height}px`,
6✔
1427
                            });
6✔
1428
                        }
6✔
1429
                    });
1✔
1430
                });
1✔
1431
                this.resizeObserver.observe(this.el);
1✔
1432
            }
1433
        }
1434

1435
        this.beforePrerenderVisible();
1436

1437
        removeStyleProperties(this.preRenderWrapper, ['z-index', 'opacity', 'pointer-events']);
1438

6✔
1439
        this.subscribeToEvents();
1440

1441
        // Setup fullscreen change handler for prerendered components
1442
        if (this.iFrame) {
6✔
1443
            this.setupFullscreenChangeHandler();
1444
        }
6✔
1445

1446
        return this;
6✔
1447
    }
1448

1449
    /**
6✔
1450
     * Synchronizes the style properties of the PreRender component with the embedding
6✔
1451
     * element. This function adjusts the position, width, and height of the PreRender
1452
     * component
1453
     * to match the dimensions and position of the embedding element.
6✔
1454
     * @throws {Error} Throws an error if the embedding element (passed as domSelector)
1455
     * is not defined or not found.
1456
     */
1457
    public syncPreRenderStyle(): void {
1458
        if (!this.isPreRenderAvailable() || !this.el) {
1459
            logger.error(ERROR_MESSAGE.SYNC_STYLE_CALLED_BEFORE_RENDER);
1460
            return;
1461
        }
1462
        const elBoundingClient = this.el.getBoundingClientRect();
1463

1464
        setStyleProperties(this.preRenderWrapper, {
1465
            top: `${elBoundingClient.y + window.scrollY}px`,
7✔
1466
            left: `${elBoundingClient.x + window.scrollX}px`,
1✔
1467
            width: `${elBoundingClient.width}px`,
1✔
1468
            height: `${elBoundingClient.height}px`,
1469
        });
6✔
1470
    }
1471

6✔
1472
    /**
1473
     * Hides the PreRender component if it is available.
1474
     * If the component is not preRendered, it issues a warning.
1475
     */
1476
    public hidePreRender(): void {
1477
        if (!this.isPreRenderAvailable()) {
1478
            // if the embed component is not preRendered , nothing to hide
1479
            logger.warn('PreRender should be called before hiding it using hidePreRender.');
1480
            return;
1481
        }
1482
        const preRenderHideStyles = {
1483
            opacity: '0',
1484
            pointerEvents: 'none',
12✔
1485
            zIndex: '-1000',
1486
            position: 'absolute ',
1✔
1487
        };
1✔
1488
        setStyleProperties(this.preRenderWrapper, preRenderHideStyles);
1489

11✔
1490
        if (this.resizeObserver) {
1491
            this.resizeObserver.disconnect();
1492
        }
1493

1494
        this.unsubscribeToEvents();
1495
    }
11✔
1496

1497
    /**
11✔
1498
     * Retrieves unique HTML element IDs for PreRender-related elements.
1✔
1499
     * These IDs are constructed based on the provided 'preRenderId' from 'viewConfig'.
1500
     * @returns {object} An object containing the IDs for the PreRender elements.
1501
     * @property {string} wrapper - The HTML element ID for the PreRender wrapper.
11✔
1502
     * @property {string} child - The HTML element ID for the PreRender child.
1503
     */
1504
    public getPreRenderIds() {
1505
        return {
1506
            wrapper: `tsEmbed-pre-render-wrapper-${this.viewConfig.preRenderId}`,
1507
            child: `tsEmbed-pre-render-child-${this.viewConfig.preRenderId}`,
1508
        };
1509
    }
1510

1511
    /**
1512
     * Returns the answerService which can be used to make arbitrary graphql calls on top
51✔
1513
     * session.
1514
     * @param vizId [Optional] to get for a specific viz in case of a Liveboard.
1515
     * @version SDK: 1.25.0 / ThoughtSpot 9.10.0
1516
     */
1517
    public async getAnswerService(vizId?: string): Promise<AnswerService> {
1518
        const { session } = await this.trigger(HostEvent.GetAnswerSession, vizId ? { vizId } : {});
1519
        return new AnswerService(session, null, this.embedConfig.thoughtSpotHost);
1520
    }
1521

1522
    /**
1523
     * Set up fullscreen change detection to automatically trigger ExitPresentMode
1524
     * when user exits fullscreen mode
1525
     */
1!
1526
    private setupFullscreenChangeHandler() {
1✔
1527
        const embedConfig = getEmbedConfig();
1528
        const disableFullscreenPresentation = embedConfig?.disableFullscreenPresentation ?? true;
1529

1530
        if (disableFullscreenPresentation) {
1531
            return;
1532
        }
1533

1534
        if (this.fullscreenChangeHandler) {
25✔
1535
            document.removeEventListener('fullscreenchange', this.fullscreenChangeHandler);
25!
1536
        }
1537

25✔
1538
        this.fullscreenChangeHandler = () => {
24✔
1539
            const isFullscreen = !!document.fullscreenElement;
1540
            if (!isFullscreen) {
1541
                logger.info('Exited fullscreen mode - triggering ExitPresentMode');
1!
UNCOV
1542
                // Only trigger if iframe is available and contentWindow is
×
1543
                // accessible
1544
                if (this.iFrame && this.iFrame.contentWindow) {
1545
                    this.trigger(HostEvent.ExitPresentMode);
1✔
UNCOV
1546
                } else {
×
1547
                    logger.debug('Skipping ExitPresentMode - iframe contentWindow not available');
×
UNCOV
1548
                }
×
1549
            }
1550
        };
UNCOV
1551

×
UNCOV
1552
        document.addEventListener('fullscreenchange', this.fullscreenChangeHandler);
×
1553
    }
UNCOV
1554

×
1555
    /**
1556
     * Remove fullscreen change handler
1557
     */
1558
    private removeFullscreenChangeHandler() {
1559
        if (this.fullscreenChangeHandler) {
1✔
1560
            document.removeEventListener('fullscreenchange', this.fullscreenChangeHandler);
1561
            this.fullscreenChangeHandler = null;
1562
        }
1563
    }
1564
}
1565

1566
/**
27!
UNCOV
1567
 * Base class for embedding v1 experience
×
UNCOV
1568
 * Note: The v1 version of ThoughtSpot Blink works on the AngularJS stack
×
1569
 * which is currently under migration to v2
1570
 * @inheritdoc
1571
 */
1572
export class V1Embed extends TsEmbed {
1573
    protected viewConfig: ViewConfig;
1574

1575
    constructor(domSelector: DOMSelector, viewConfig: ViewConfig) {
1576
        super(domSelector, viewConfig);
1577
        this.viewConfig = { excludeRuntimeFiltersfromURL: false, ...viewConfig };
1578
    }
1579

14✔
1580
    /**
1581
     * Render the app in an iframe and set up event handlers
1582
     * @param iframeSrc
1583
     */
268✔
1584
    protected renderV1Embed(iframeSrc: string): Promise<any> {
268✔
1585
        return this.renderIFrame(iframeSrc);
1586
    }
1587

1588
    protected getRootIframeSrc(): string {
1589
        const queryParams = this.getEmbedParams();
1590
        let queryString = queryParams;
1591

1592
        if (!this.viewConfig.excludeRuntimeParametersfromURL) {
243✔
1593
            const runtimeParameters = this.viewConfig.runtimeParameters;
1594
            const parameterQuery = getRuntimeParameters(runtimeParameters || []);
1595
            queryString = [parameterQuery, queryParams].filter(Boolean).join('&');
1596
        }
252✔
1597

252✔
1598
        if (!this.viewConfig.excludeRuntimeFiltersfromURL) {
1599
            const runtimeFilters = this.viewConfig.runtimeFilters;
252✔
1600

251✔
1601
            const filterQuery = getFilterQuery(runtimeFilters || []);
251✔
1602
            queryString = [filterQuery, queryString].filter(Boolean).join('&');
251✔
1603
        }
1604
        return this.viewConfig.enableV2Shell_experimental
1605
            ? this.getEmbedBasePath(queryString)
252✔
1606
            : this.getV1EmbedBasePath(queryString);
250✔
1607
    }
1608

250✔
1609
    /**
250✔
1610
     * @inheritdoc
1611
     * @example
252✔
1612
     * ```js
1613
     * tsEmbed.on(EmbedEvent.Error, (data) => {
1614
     *   console.error(data);
1615
     * });
1616
     * ```
1617
     * @example
1618
     * ```js
1619
     * tsEmbed.on(EmbedEvent.Save, (data) => {
1620
     *   console.log("Answer save clicked", data);
1621
     * }, {
1622
     *   start: true // This will trigger the callback on start of save
1623
     * });
1624
     * ```
1625
     */
1626
    public on(
1627
        messageType: EmbedEvent,
1628
        callback: MessageCallback,
1629
        options: MessageOptions = { start: false },
1630
    ): typeof TsEmbed.prototype {
1631
        const eventType = this.getCompatibleEventType(messageType);
1632
        return super.on(eventType, callback, options);
1633
    }
1634

1635
    /**
1636
     * Only for testing purposes.
126✔
1637
     * @hidden
1638
     */
1,431✔
1639

1,431✔
1640
    public test__executeCallbacks = this.executeCallbacks;
1641
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc