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

thoughtspot / visual-embed-sdk / #3033

15 Dec 2025 05:48AM UTC coverage: 94.347% (+0.05%) from 94.3%
#3033

Pull #383

shivam-kumar-ts
SCAL-287502 Add server-side rendering (SSR) guards to embed SDK
Pull Request #383: SCAL-287502 Add server-side rendering (SSR) guards to embed SDK

1370 of 1539 branches covered (89.02%)

Branch coverage included in aggregate %.

9 of 9 new or added lines in 2 files covered. (100.0%)

7 existing lines in 2 files now uncovered.

3236 of 3343 relevant lines covered (96.8%)

107.37 hits per line

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

95.76
/src/utils.ts
1
/**
2
 * Copyright (c) 2023
3
 *
4
 * Common utility functions for ThoughtSpot Visual Embed SDK
5
 * @summary Utils
6
 * @author Ayon Ghosh <ayon.ghosh@thoughtspot.com>
7
 */
8

9
import merge from 'ts-deepmerge';
35✔
10
import {
11
    EmbedConfig,
12
    QueryParams,
13
    RuntimeFilter,
14
    CustomisationsInterface,
15
    DOMSelector,
16
    RuntimeParameter,
17
    AllEmbedViewConfig,
18
} from './types';
19
import { logger } from './utils/logger';
35✔
20

35✔
21
/**
22
 * Construct a runtime filters query string from the given filters.
23
 * Refer to the following docs for more details on runtime filter syntax:
24
 * https://cloud-docs.thoughtspot.com/admin/ts-cloud/apply-runtime-filter.html
25
 * https://cloud-docs.thoughtspot.com/admin/ts-cloud/runtime-filter-operators.html
26
 * @param runtimeFilters
27
 */
28
export const getFilterQuery = (runtimeFilters: RuntimeFilter[]): string | null => {
29
    if (runtimeFilters && runtimeFilters.length) {
35✔
30
        const filters = runtimeFilters.map((filter, valueIndex) => {
422✔
31
            const index = valueIndex + 1;
20✔
32
            const filterExpr = [];
22✔
33
            filterExpr.push(`col${index}=${encodeURIComponent(filter.columnName)}`);
22✔
34
            filterExpr.push(`op${index}=${filter.operator}`);
22✔
35
            filterExpr.push(
22✔
36
                filter.values.map((value) => {
22✔
37
                    const encodedValue = typeof value === 'bigint' ? value.toString() : value;
38
                    return `val${index}=${encodeURIComponent(String(encodedValue))}`;
23!
39
                }).join('&'),
23✔
40
            );
41

42
            return filterExpr.join('&');
43
        });
22✔
44

45
        return `${filters.join('&')}`;
46
    }
20✔
47

48
    return null;
49
};
402✔
50

51
/**
52
 * Construct a runtime parameter override query string from the given option.
53
 * @param runtimeParameters
54
 */
55
export const getRuntimeParameters = (runtimeParameters: RuntimeParameter[]): string => {
56
    if (runtimeParameters && runtimeParameters.length) {
35✔
57
        const params = runtimeParameters.map((param, valueIndex) => {
421✔
58
            const index = valueIndex + 1;
11✔
59
            const filterExpr = [];
14✔
60
            filterExpr.push(`param${index}=${encodeURIComponent(param.name)}`);
14✔
61
            filterExpr.push(`paramVal${index}=${encodeURIComponent(param.value)}`);
14✔
62

14✔
63
            return filterExpr.join('&');
64
        });
14✔
65

66
        return `${params.join('&')}`;
67
    }
11✔
68

69
    return null;
70
};
410✔
71

72
/**
73
 * Convert a value to a string representation to be sent as a query
74
 * parameter to the ThoughtSpot app.
75
 * @param value Any parameter value
76
 */
77
const serializeParam = (value: any) => {
78
    // do not serialize primitive types
35✔
79
    if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
80
        return value;
15,623✔
81
    }
15,044✔
82

83
    return JSON.stringify(value);
84
};
579✔
85

86
/**
87
 * Convert a value to a string:
88
 * in case of an array, we convert it to CSV.
89
 * in case of any other type, we directly return the value.
90
 * @param value
91
 */
92
const paramToString = (value: any) => (Array.isArray(value) ? value.join(',') : value);
93

35!
94
/**
95
 * Return a query param string composed from the given params object
96
 * @param queryParams
97
 * @param shouldSerializeParamValues
98
 */
99
export const getQueryParamString = (
100
    queryParams: QueryParams,
35✔
101
    shouldSerializeParamValues = false,
102
): string => {
9✔
103
    const qp: string[] = [];
104
    const params = Object.keys(queryParams);
576✔
105
    params.forEach((key) => {
576✔
106
        const val = queryParams[key];
576✔
107
        if (val !== undefined) {
16,198✔
108
            const serializedValue = shouldSerializeParamValues
16,198✔
109
                ? serializeParam(val)
15,640✔
110
                : paramToString(val);
111
            qp.push(`${key}=${serializedValue}`);
112
        }
15,640✔
113
    });
114

115
    if (qp.length) {
116
        return qp.join('&');
576✔
117
    }
575✔
118

119
    return null;
120
};
1✔
121

122
/**
123
 * Get a string representation of a dimension value in CSS
124
 * If numeric, it is considered in pixels.
125
 * @param value
126
 */
127
export const getCssDimension = (value: number | string): string => {
128
    if (typeof value === 'number') {
35✔
129
        return `${value}px`;
809✔
130
    }
574✔
131

132
    return value;
133
};
235✔
134

135
/**
136
 * Validates if a string is a valid CSS margin value.
137
 * @param value - The string to validate
138
 * @returns true if the value is a valid CSS margin value, false otherwise
139
 */
140
export const isValidCssMargin = (value: string): boolean => {
141
    if(isUndefined(value)) {
35✔
142
        return false;
25!
143
    }
×
144
    if (typeof value !== 'string') {
145
        logger.error('Please provide a valid lazyLoadingMargin value (e.g., "10px")');
25!
146
        return false;
×
147
    }
×
148

149
    // This pattern allows for an optional negative sign, and numbers that can be integers or decimals (including leading dot).
150
    const cssUnitPattern = /^-?(\d+(\.\d*)?|\.\d+)(px|em|rem|%|vh|vw)$/i;
151
    const parts = value.trim().split(/\s+/);
25✔
152

25✔
153
    if (parts.length > 4) {
154
        logger.error('Please provide a valid lazyLoadingMargin value (e.g., "10px")');
25!
155
        return false;
×
156
    }
×
157

158
    const isValid = parts.every(part => {
159
        const trimmedPart = part.trim();
25✔
160
        return trimmedPart.toLowerCase() === 'auto' || trimmedPart === '0' || cssUnitPattern.test(trimmedPart);
27✔
161
    });
27✔
162
    if (!isValid) {
163
        logger.error('Please provide a valid lazyLoadingMargin value (e.g., "10px")');
25✔
164
        return false;
4✔
165
    }
4✔
166
    return true;
167
};
21✔
168

169
export const getSSOMarker = (markerId: string) => {
170
    const encStringToAppend = encodeURIComponent(markerId);
35✔
171
    return `tsSSOMarker=${encStringToAppend}`;
18✔
172
};
18✔
173

174
/**
175
 * Append a string to a URL's hash fragment
176
 * @param url A URL
177
 * @param stringToAppend The string to append to the URL hash
178
 */
179
export const appendToUrlHash = (url: string, stringToAppend: string) => {
180
    let outputUrl = url;
35✔
181
    const encStringToAppend = encodeURIComponent(stringToAppend);
12✔
182

12✔
183
    const marker = `tsSSOMarker=${encStringToAppend}`;
184

12✔
185
    let splitAdder = '';
186

12✔
187
    if (url.indexOf('#') >= 0) {
188
        // If second half of hash contains a '?' already add a '&' instead of
12✔
189
        // '?' which appends to query params.
190
        splitAdder = url.split('#')[1].indexOf('?') >= 0 ? '&' : '?';
191
    } else {
8✔
192
        splitAdder = '#?';
193
    }
4✔
194
    outputUrl = `${outputUrl}${splitAdder}${marker}`;
195

12✔
196
    return outputUrl;
197
};
12✔
198

199
/**
200
 *
201
 * @param url
202
 * @param stringToAppend
203
 * @param path
204
 */
205
export function getRedirectUrl(url: string, stringToAppend: string, path = '') {
206
    const targetUrl = path ? new URL(path, window.location.origin).href : url;
35✔
207
    return appendToUrlHash(targetUrl, stringToAppend);
10✔
208
}
10✔
209

210
export const getEncodedQueryParamsString = (queryString: string) => {
211
    if (!queryString) {
35✔
212
        return queryString;
4✔
213
    }
1✔
214
    return btoa(queryString).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
215
};
3✔
216

217
export const getOffsetTop = (element: any) => {
218
    const rect = element.getBoundingClientRect();
35✔
219
    return rect.top + window.scrollY;
5✔
220
};
5✔
221

222
export const embedEventStatus = {
223
    START: 'start',
35✔
224
    END: 'end',
225
};
226

227
export const setAttributes = (
228
    element: HTMLElement,
35✔
229
    attributes: { [key: string]: string | number | boolean },
230
): void => {
231
    Object.keys(attributes).forEach((key) => {
232
        element.setAttribute(key, attributes[key].toString());
401✔
233
    });
21✔
234
};
235

236
const isCloudRelease = (version: string) => version.endsWith('.cl');
237

35✔
238
/* For Search Embed: ReleaseVersionInBeta */
239
export const checkReleaseVersionInBeta = (
240
    releaseVersion: string,
35✔
241
    suppressBetaWarning: boolean,
242
): boolean => {
243
    if (releaseVersion !== '' && !isCloudRelease(releaseVersion)) {
244
        const splittedReleaseVersion = releaseVersion.split('.');
110✔
245
        const majorVersion = Number(splittedReleaseVersion[0]);
34✔
246
        const isBetaVersion = majorVersion < 8;
34✔
247
        return !suppressBetaWarning && isBetaVersion;
34✔
248
    }
34✔
249
    return false;
250
};
76✔
251

252
export const getCustomisations = (
253
    embedConfig: EmbedConfig,
35✔
254
    viewConfig: AllEmbedViewConfig,
255
): CustomisationsInterface => {
256
    const customizationsFromViewConfig = viewConfig.customizations;
257
    const customizationsFromEmbedConfig = embedConfig.customizations
20✔
258
        || ((embedConfig as any).customisations as CustomisationsInterface);
20✔
259

260
    const customizations: CustomisationsInterface = {
261
        style: {
20✔
262
            ...customizationsFromEmbedConfig?.style,
263
            ...customizationsFromViewConfig?.style,
60✔
264
            customCSS: {
60✔
265
                ...customizationsFromEmbedConfig?.style?.customCSS,
266
                ...customizationsFromViewConfig?.style?.customCSS,
120✔
267
            },
120✔
268
            customCSSUrl:
269
                customizationsFromViewConfig?.style?.customCSSUrl
270
                || customizationsFromEmbedConfig?.style?.customCSSUrl,
160✔
271
        },
120✔
272
        content: {
273
            ...customizationsFromEmbedConfig?.content,
274
            ...customizationsFromViewConfig?.content,
60✔
275
        },
60✔
276
    };
277
    return customizations;
278
};
20✔
279

280
export const getRuntimeFilters = (runtimefilters: any) => getFilterQuery(runtimefilters || []);
281

35!
282
/**
283
 * Gets a reference to the DOM node given
284
 * a selector.
285
 * @param domSelector
286
 */
287
export function getDOMNode(domSelector: DOMSelector): HTMLElement {
288
    return typeof domSelector === 'string' ? document.querySelector(domSelector) : domSelector;
35✔
289
}
467✔
290

291
export const deepMerge = (target: any, source: any) => merge(target, source);
292

35✔
293
export const getOperationNameFromQuery = (query: string) => {
294
    const regex = /(?:query|mutation)\s+(\w+)/;
35✔
295
    const matches = query.match(regex);
88✔
296
    return matches?.[1];
88✔
297
};
88!
298

299
/**
300
 *
301
 * @param obj
302
 */
303
export function removeTypename(obj: any) {
304
    if (!obj || typeof obj !== 'object') return obj;
35✔
305

47✔
306

307
    for (const key in obj) {
308
        if (key === '__typename') {
36✔
309
            delete obj[key];
90✔
310
        } else if (typeof obj[key] === 'object') {
2✔
311
            removeTypename(obj[key]);
88✔
312
        }
18✔
313
    }
314
    return obj;
315
}
36✔
316

317
/**
318
 * Sets the specified style properties on an HTML element.
319
 * @param {HTMLElement} element - The HTML element to which the styles should be applied.
320
 * @param {Partial<CSSStyleDeclaration>} styleProperties - An object containing style
321
 * property names and their values.
322
 * @example
323
 * // Apply styles to an element
324
 * const element = document.getElementById('myElement');
325
 * const styles = {
326
 *   backgroundColor: 'red',
327
 *   fontSize: '16px',
328
 * };
329
 * setStyleProperties(element, styles);
330
 */
331
export const setStyleProperties = (
332
    element: HTMLElement,
35✔
333
    styleProperties: Partial<CSSStyleDeclaration>,
334
): void => {
335
    if (!element?.style) return;
336
    Object.keys(styleProperties).forEach((styleProperty) => {
37✔
337
        const styleKey = styleProperty as keyof CSSStyleDeclaration;
36✔
338
        const value = styleProperties[styleKey];
124✔
339
        if (value !== undefined) {
124✔
340
            (element.style as any)[styleKey] = value.toString();
124✔
341
        }
124✔
342
    });
343
};
344
/**
345
 * Removes specified style properties from an HTML element.
346
 * @param {HTMLElement} element - The HTML element from which the styles should be removed.
347
 * @param {string[]} styleProperties - An array of style property names to be removed.
348
 * @example
349
 * // Remove styles from an element
350
 * const element = document.getElementById('myElement');
351
 * element.style.backgroundColor = 'red';
352
 * const propertiesToRemove = ['backgroundColor'];
353
 * removeStyleProperties(element, propertiesToRemove);
354
 */
355
export const removeStyleProperties = (element: HTMLElement, styleProperties: string[]): void => {
356
    if (!element?.style) return;
35✔
357
    styleProperties.forEach((styleProperty) => {
9✔
358
        element.style.removeProperty(styleProperty);
8✔
359
    });
22✔
360
};
361

362
export const isUndefined = (value: any): boolean => value === undefined;
363

334✔
364
// Return if the value is a string, double or boolean.
365
export const getTypeFromValue = (value: any): [string, string] => {
366
    if (typeof value === 'string') {
35✔
367
        return ['char', 'string'];
9✔
368
    }
1✔
369
    if (typeof value === 'number') {
370
        return ['double', 'double'];
8✔
371
    }
2✔
372
    if (typeof value === 'boolean') {
373
        return ['boolean', 'boolean'];
6✔
374
    }
2✔
375
    return ['', ''];
376
};
4✔
377

378
const sdkWindowKey = '_tsEmbedSDK' as any;
379

35✔
380
/**
381
 * Stores a value in the global `window` object under the `_tsEmbedSDK` namespace.
382
 * @param key - The key under which the value will be stored.
383
 * @param value - The value to store.
384
 * @param options - Additional options.
385
 * @param options.ignoreIfAlreadyExists - Does not set if value for key is set.
386
 *
387
 * @returns The stored value.
388
 *
389
 * @version SDK: 1.36.2 | ThoughtSpot: *
390
 */
391
export function storeValueInWindow<T>(
392
    key: string,
35✔
393
    value: T,
394
    options: { ignoreIfAlreadyExists?: boolean } = {},
395
): T {
474✔
396
    if (typeof window === 'undefined') return value;
397
    if (!window[sdkWindowKey]) {
502✔
398
        (window as any)[sdkWindowKey] = {};
1✔
399
    }
1✔
400

401
    if (options.ignoreIfAlreadyExists && key in (window as any)[sdkWindowKey]) {
501✔
402
        return (window as any)[sdkWindowKey][key];
22✔
403
    }
404

405
    (window as any)[sdkWindowKey][key] = value;
501✔
406
    return value;
2✔
407
}
408

409
/**
499✔
410
 * Retrieves a stored value from the global 
499✔
411
 * `window` object under the `_tsEmbedSDK` namespace.
412
 * Returns undefined in SSR environment.
413
 */
414
export const getValueFromWindow = <T = any>(key: string): T | undefined => {
415
    if (typeof window === 'undefined') return undefined;
416
    return (window as any)?.[sdkWindowKey]?.[key];
417
};
418
/**
35✔
419
 * Check if an array includes a string value
3,287✔
420
 * @param arr - The array to check
1✔
421
 * @param key - The string to search for
1✔
422
 * @returns boolean indicating if the string is found in the array
423
 */
3,286!
424
export const arrayIncludesString = (arr: readonly unknown[], key: string): boolean => {
425
    return arr.some(item => typeof item === 'string' && item === key);
426
};
427

428
/**
429
 * Resets the key if it exists in the `window` object under the `_tsEmbedSDK` key.
430
 * Returns true if the key was reset, false otherwise.
431
 * @param key - Key to reset
35✔
432
 * @returns - boolean indicating if the key was reset
397✔
433
 */
434
export function resetValueFromWindow(key: string): boolean {
435
    if (typeof window === 'undefined') return false;
436
    if (key in window[sdkWindowKey]) {
437
        delete (window as any)[sdkWindowKey][key];
438
        return true;
439
    }
440
    return false;
441
}
35✔
442

7✔
443
/**
1✔
444
 * Check if the document is currently in fullscreen mode
1✔
445
 */
446
const isInFullscreen = (): boolean => {
6✔
447
    return !!(
5✔
448
        document.fullscreenElement ||
5✔
449
        (document as any).webkitFullscreenElement ||
450
        (document as any).mozFullScreenElement ||
1✔
451
        (document as any).msFullscreenElement
452
    );
453
};
454

455
/**
456
 * Handle Present HostEvent by entering fullscreen mode
35✔
457
 * @param iframe The iframe element to make fullscreen
6✔
458
 */
15✔
459
export const handlePresentEvent = async (iframe: HTMLIFrameElement): Promise<void> => {
460
    if (isInFullscreen()) {
461
        return; // Already in fullscreen
462
    }
463

464
    // Browser-specific methods to enter fullscreen mode
465
    const fullscreenMethods = [
466
        'requestFullscreen',      // Standard API
467
        'webkitRequestFullscreen', // WebKit browsers
468
        'mozRequestFullScreen',   // Firefox
469
        'msRequestFullscreen'     // IE/Edge
35✔
470
    ];
3✔
471

1✔
472
    for (const method of fullscreenMethods) {
473
        if (typeof (iframe as any)[method] === 'function') {
474
            try {
475
                const result = (iframe as any)[method]();
2✔
476
                await Promise.resolve(result);
477
                return;
478
            } catch (error) {
479
                logger.warn(`Failed to enter fullscreen using ${method}:`, error);
480
            }
481
        }
482
    }
2✔
483

5✔
484
    logger.error('Fullscreen API is not supported by this browser.');
1✔
485
};
1✔
486

1✔
487
/**
1✔
488
 * Handle ExitPresentMode EmbedEvent by exiting fullscreen mode
UNCOV
489
 */
×
490
export const handleExitPresentMode = async (): Promise<void> => {
491
    if (!isInFullscreen()) {
492
        return; // Not in fullscreen
493
    }
494

1✔
495
    const exitFullscreenMethods = [
496
        'exitFullscreen',        // Standard API
497
        'webkitExitFullscreen',  // WebKit browsers
498
        'mozCancelFullScreen',   // Firefox
499
        'msExitFullscreen'       // IE/Edge
500
    ];
35✔
501

3✔
502
    // Try each method until one works
1✔
503
    for (const method of exitFullscreenMethods) {
504
        if (typeof (document as any)[method] === 'function') {
505
            try {
2✔
506
                const result = (document as any)[method]();
507
                await Promise.resolve(result);
508
                return;
509
            } catch (error) {
510
                logger.warn(`Failed to exit fullscreen using ${method}:`, error);
511
            }
512
        }
513
    }
2✔
514

5✔
515
    logger.warn('Exit fullscreen API is not supported by this browser.');
1✔
516
};
1✔
517

1✔
518
export const calculateVisibleElementData = (element: HTMLElement) => {
1✔
519
    const rect = element.getBoundingClientRect();
UNCOV
520

×
521
    const windowHeight = window.innerHeight;
522
    const windowWidth = window.innerWidth;
523

524
    const frameRelativeTop = Math.max(rect.top, 0);
525
    const frameRelativeLeft = Math.max(rect.left, 0);
1✔
526

527
    const frameRelativeBottom = Math.min(windowHeight, rect.bottom);
528
    const frameRelativeRight = Math.min(windowWidth, rect.right);
35✔
529

19✔
530
    const data = {
531
        top: Math.max(0, rect.top * -1),
19✔
532
        height: Math.max(0, frameRelativeBottom - frameRelativeTop),
19✔
533
        left: Math.max(0, rect.left * -1),
534
        width: Math.max(0, frameRelativeRight - frameRelativeLeft),
19✔
535
    };
19✔
536

537
    return data;
19✔
538
}
19✔
539

540
/**
19✔
541
 * Replaces placeholders in a template string with provided values.
542
 * Placeholders should be in the format {key}.
543
 * @param template - The template string with placeholders
544
 * @param values - An object containing key-value pairs to replace placeholders
545
 * @returns The template string with placeholders replaced
546
 * @example
547
 * formatTemplate('Hello {name}, you are {age} years old', { name: 'John', age: 30 })
19✔
548
 * // Returns: 'Hello John, you are 30 years old'
549
 *
550
 * formatTemplate('Expected {type}, but received {actual}', { type: 'string', actual: 'number' })
551
 * // Returns: 'Expected string, but received number'
552
 */
553
export const formatTemplate = (template: string, values: Record<string, any>): string => {
554
    // This regex /\{(\w+)\}/g finds all placeholders in the format {word} 
555
    // and captures the word inside the braces for replacement.
556
    return template.replace(/\{(\w+)\}/g, (match, key) => {
557
        return values[key] !== undefined ? String(values[key]) : match;
558
    });
559
};
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