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

thoughtspot / visual-embed-sdk / #2969

10 Dec 2025 06:12AM UTC coverage: 94.241% (-0.2%) from 94.432%
#2969

Pull #375

ruchI9897
fixed comments
Pull Request #375: added check on margin value

1368 of 1537 branches covered (89.0%)

Branch coverage included in aggregate %.

17 of 20 new or added lines in 3 files covered. (85.0%)

11 existing lines in 3 files now uncovered.

3214 of 3325 relevant lines covered (96.66%)

106.6 hits per line

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

96.77
/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

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 => {
35✔
29
    if (runtimeFilters && runtimeFilters.length) {
422✔
30
        const filters = runtimeFilters.map((filter, valueIndex) => {
20✔
31
            const index = valueIndex + 1;
22✔
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) => {
37
                    const encodedValue = typeof value === 'bigint' ? value.toString() : value;
23!
38
                    return `val${index}=${encodeURIComponent(String(encodedValue))}`;
23✔
39
                }).join('&'),
40
            );
41

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

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

48
    return null;
402✔
49
};
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 => {
35✔
56
    if (runtimeParameters && runtimeParameters.length) {
421✔
57
        const params = runtimeParameters.map((param, valueIndex) => {
11✔
58
            const index = valueIndex + 1;
14✔
59
            const filterExpr = [];
14✔
60
            filterExpr.push(`param${index}=${encodeURIComponent(param.name)}`);
14✔
61
            filterExpr.push(`paramVal${index}=${encodeURIComponent(param.value)}`);
14✔
62

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

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

69
    return null;
410✔
70
};
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) => {
35✔
78
    // do not serialize primitive types
79
    if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
15,623✔
80
        return value;
15,044✔
81
    }
82

83
    return JSON.stringify(value);
579✔
84
};
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);
35!
93

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

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

119
    return null;
1✔
120
};
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 => {
35✔
128
    if (typeof value === 'number') {
809✔
129
        return `${value}px`;
574✔
130
    }
131

132
    return value;
235✔
133
};
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 => {
35✔
141
    if ( value === undefined || typeof value !== 'string' || value.trim() === '') {
25✔
142
        return false;
2✔
143
    }
2✔
144

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

23✔
149
    if (parts.length > 4) {
150
        return false;
23!
NEW
151
    }
×
NEW
152

×
153
    return parts.every(part => {
154
        const trimmedPart = part.trim();
155
        // '0' and 'auto' are valid margin values that don't match the pattern.
23✔
156
        return trimmedPart.toLowerCase() === 'auto' || trimmedPart === '0' || cssUnitPattern.test(trimmedPart);
25✔
157
    });
25✔
158
};
159

23✔
160
export const getSSOMarker = (markerId: string) => {
2✔
161
    const encStringToAppend = encodeURIComponent(markerId);
2✔
162
    return `tsSSOMarker=${encStringToAppend}`;
163
};
21✔
164

165
/**
166
 * Append a string to a URL's hash fragment
35✔
167
 * @param url A URL
18✔
168
 * @param stringToAppend The string to append to the URL hash
18✔
169
 */
170
export const appendToUrlHash = (url: string, stringToAppend: string) => {
171
    let outputUrl = url;
172
    const encStringToAppend = encodeURIComponent(stringToAppend);
173

174
    const marker = `tsSSOMarker=${encStringToAppend}`;
175

176
    let splitAdder = '';
35✔
177

12✔
178
    if (url.indexOf('#') >= 0) {
12✔
179
        // If second half of hash contains a '?' already add a '&' instead of
180
        // '?' which appends to query params.
12✔
181
        splitAdder = url.split('#')[1].indexOf('?') >= 0 ? '&' : '?';
182
    } else {
12✔
183
        splitAdder = '#?';
184
    }
12✔
185
    outputUrl = `${outputUrl}${splitAdder}${marker}`;
186

187
    return outputUrl;
8✔
188
};
189

4✔
190
/**
191
 *
12✔
192
 * @param url
193
 * @param stringToAppend
12✔
194
 * @param path
195
 */
196
export function getRedirectUrl(url: string, stringToAppend: string, path = '') {
197
    const targetUrl = path ? new URL(path, window.location.origin).href : url;
198
    return appendToUrlHash(targetUrl, stringToAppend);
199
}
200

201
export const getEncodedQueryParamsString = (queryString: string) => {
202
    if (!queryString) {
35✔
203
        return queryString;
10✔
204
    }
10✔
205
    return btoa(queryString).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
206
};
207

35✔
208
export const getOffsetTop = (element: any) => {
4✔
209
    const rect = element.getBoundingClientRect();
1✔
210
    return rect.top + window.scrollY;
211
};
3✔
212

213
export const embedEventStatus = {
214
    START: 'start',
35✔
215
    END: 'end',
5✔
216
};
5✔
217

218
export const setAttributes = (
219
    element: HTMLElement,
35✔
220
    attributes: { [key: string]: string | number | boolean },
221
): void => {
222
    Object.keys(attributes).forEach((key) => {
223
        element.setAttribute(key, attributes[key].toString());
224
    });
35✔
225
};
226

227
const isCloudRelease = (version: string) => version.endsWith('.cl');
228

401✔
229
/* For Search Embed: ReleaseVersionInBeta */
21✔
230
export const checkReleaseVersionInBeta = (
231
    releaseVersion: string,
232
    suppressBetaWarning: boolean,
233
): boolean => {
35✔
234
    if (releaseVersion !== '' && !isCloudRelease(releaseVersion)) {
235
        const splittedReleaseVersion = releaseVersion.split('.');
236
        const majorVersion = Number(splittedReleaseVersion[0]);
35✔
237
        const isBetaVersion = majorVersion < 8;
238
        return !suppressBetaWarning && isBetaVersion;
239
    }
240
    return false;
110✔
241
};
34✔
242

34✔
243
export const getCustomisations = (
34✔
244
    embedConfig: EmbedConfig,
34✔
245
    viewConfig: AllEmbedViewConfig,
246
): CustomisationsInterface => {
76✔
247
    const customizationsFromViewConfig = viewConfig.customizations;
248
    const customizationsFromEmbedConfig = embedConfig.customizations
249
        || ((embedConfig as any).customisations as CustomisationsInterface);
35✔
250

251
    const customizations: CustomisationsInterface = {
252
        style: {
253
            ...customizationsFromEmbedConfig?.style,
20✔
254
            ...customizationsFromViewConfig?.style,
20✔
255
            customCSS: {
256
                ...customizationsFromEmbedConfig?.style?.customCSS,
257
                ...customizationsFromViewConfig?.style?.customCSS,
20✔
258
            },
259
            customCSSUrl:
60✔
260
                customizationsFromViewConfig?.style?.customCSSUrl
60✔
261
                || customizationsFromEmbedConfig?.style?.customCSSUrl,
262
        },
120✔
263
        content: {
120✔
264
            ...customizationsFromEmbedConfig?.content,
265
            ...customizationsFromViewConfig?.content,
266
        },
160✔
267
    };
120✔
268
    return customizations;
269
};
270

60✔
271
export const getRuntimeFilters = (runtimefilters: any) => getFilterQuery(runtimefilters || []);
60✔
272

273
/**
274
 * Gets a reference to the DOM node given
20✔
275
 * a selector.
276
 * @param domSelector
277
 */
35!
278
export function getDOMNode(domSelector: DOMSelector): HTMLElement {
279
    return typeof domSelector === 'string' ? document.querySelector(domSelector) : domSelector;
280
}
281

282
export const deepMerge = (target: any, source: any) => merge(target, source);
283

284
export const getOperationNameFromQuery = (query: string) => {
35✔
285
    const regex = /(?:query|mutation)\s+(\w+)/;
467✔
286
    const matches = query.match(regex);
287
    return matches?.[1];
288
};
35✔
289

290
/**
35✔
291
 *
88✔
292
 * @param obj
88✔
293
 */
88!
294
export function removeTypename(obj: any) {
295
    if (!obj || typeof obj !== 'object') return obj;
296

297

298
    for (const key in obj) {
299
        if (key === '__typename') {
300
            delete obj[key];
35✔
301
        } else if (typeof obj[key] === 'object') {
47✔
302
            removeTypename(obj[key]);
303
        }
304
    }
36✔
305
    return obj;
90✔
306
}
2✔
307

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

35✔
353
export const isUndefined = (value: any): boolean => value === undefined;
9✔
354

8✔
355
// Return if the value is a string, double or boolean.
22✔
356
export const getTypeFromValue = (value: any): [string, string] => {
357
    if (typeof value === 'string') {
358
        return ['char', 'string'];
359
    }
309✔
360
    if (typeof value === 'number') {
361
        return ['double', 'double'];
362
    }
35✔
363
    if (typeof value === 'boolean') {
9✔
364
        return ['boolean', 'boolean'];
1✔
365
    }
366
    return ['', ''];
8✔
367
};
2✔
368

369
const sdkWindowKey = '_tsEmbedSDK' as any;
6✔
370

2✔
371
/**
372
 * Stores a value in the global `window` object under the `_tsEmbedSDK` namespace.
4✔
373
 * @param key - The key under which the value will be stored.
374
 * @param value - The value to store.
375
 * @param options - Additional options.
35✔
376
 * @param options.ignoreIfAlreadyExists - Does not set if value for key is set.
377
 *
378
 * @returns The stored value.
379
 *
380
 * @version SDK: 1.36.2 | ThoughtSpot: *
381
 */
382
export function storeValueInWindow<T>(
383
    key: string,
384
    value: T,
385
    options: { ignoreIfAlreadyExists?: boolean } = {},
386
): T {
387
    if (!window[sdkWindowKey]) {
388
        (window as any)[sdkWindowKey] = {};
35✔
389
    }
390

391
    if (options.ignoreIfAlreadyExists && key in (window as any)[sdkWindowKey]) {
459✔
392
        return (window as any)[sdkWindowKey][key];
393
    }
479✔
394

22✔
395
    (window as any)[sdkWindowKey][key] = value;
396
    return value;
397
}
479✔
398

1✔
399
/**
400
 * Retrieves a stored value from the global `window` object under the `_tsEmbedSDK` namespace.
401
 * @param key - The key whose value needs to be retrieved.
478✔
402
 * @returns The stored value or `undefined` if the key is not found.
478✔
403
 */
404
export const getValueFromWindow = <T = any>
405
    (key: string): T => (window as any)?.[sdkWindowKey]?.[key];
406

407
/**
408
 * Check if an array includes a string value
409
 * @param arr - The array to check
410
 * @param key - The string to search for
35✔
411
 * @returns boolean indicating if the string is found in the array
3,257!
412
 */
413
export const arrayIncludesString = (arr: readonly unknown[], key: string): boolean => {
414
    return arr.some(item => typeof item === 'string' && item === key);
415
};
416

417
/**
418
 * Resets the key if it exists in the `window` object under the `_tsEmbedSDK` key.
419
 * Returns true if the key was reset, false otherwise.
35✔
420
 * @param key - Key to reset
397✔
421
 * @returns - boolean indicating if the key was reset
422
 */
423
export function resetValueFromWindow(key: string): boolean {
424
    if (key in window[sdkWindowKey]) {
425
        delete (window as any)[sdkWindowKey][key];
426
        return true;
427
    }
428
    return false;
429
}
35✔
430

3✔
431
/**
3✔
432
 * Check if the document is currently in fullscreen mode
3✔
433
 */
UNCOV
434
const isInFullscreen = (): boolean => {
×
435
    return !!(
436
        document.fullscreenElement ||
437
        (document as any).webkitFullscreenElement ||
438
        (document as any).mozFullScreenElement ||
439
        (document as any).msFullscreenElement
440
    );
35✔
441
};
6✔
442

15✔
443
/**
444
 * Handle Present HostEvent by entering fullscreen mode
445
 * @param iframe The iframe element to make fullscreen
446
 */
447
export const handlePresentEvent = async (iframe: HTMLIFrameElement): Promise<void> => {
448
    if (isInFullscreen()) {
449
        return; // Already in fullscreen
450
    }
451

452
    // Browser-specific methods to enter fullscreen mode
453
    const fullscreenMethods = [
35✔
454
        'requestFullscreen',      // Standard API
3✔
455
        'webkitRequestFullscreen', // WebKit browsers
1✔
456
        'mozRequestFullScreen',   // Firefox
457
        'msRequestFullscreen'     // IE/Edge
458
    ];
459

2✔
460
    for (const method of fullscreenMethods) {
461
        if (typeof (iframe as any)[method] === 'function') {
462
            try {
463
                const result = (iframe as any)[method]();
464
                await Promise.resolve(result);
465
                return;
466
            } catch (error) {
2✔
467
                logger.warn(`Failed to enter fullscreen using ${method}:`, error);
5✔
468
            }
1✔
469
        }
1✔
470
    }
1✔
471

1✔
472
    logger.error('Fullscreen API is not supported by this browser.');
UNCOV
473
};
×
474

475
/**
476
 * Handle ExitPresentMode EmbedEvent by exiting fullscreen mode
477
 */
478
export const handleExitPresentMode = async (): Promise<void> => {
1✔
479
    if (!isInFullscreen()) {
480
        return; // Not in fullscreen
481
    }
482

483
    const exitFullscreenMethods = [
484
        'exitFullscreen',        // Standard API
35✔
485
        'webkitExitFullscreen',  // WebKit browsers
3✔
486
        'mozCancelFullScreen',   // Firefox
1✔
487
        'msExitFullscreen'       // IE/Edge
488
    ];
489

2✔
490
    // Try each method until one works
491
    for (const method of exitFullscreenMethods) {
492
        if (typeof (document as any)[method] === 'function') {
493
            try {
494
                const result = (document as any)[method]();
495
                await Promise.resolve(result);
496
                return;
497
            } catch (error) {
2✔
498
                logger.warn(`Failed to exit fullscreen using ${method}:`, error);
5✔
499
            }
1✔
500
        }
1✔
501
    }
1✔
502

1✔
503
    logger.warn('Exit fullscreen API is not supported by this browser.');
UNCOV
504
};
×
505

506
export const calculateVisibleElementData = (element: HTMLElement) => {
507
    const rect = element.getBoundingClientRect();
508

509
    const windowHeight = window.innerHeight;
1✔
510
    const windowWidth = window.innerWidth;
511

512
    const frameRelativeTop = Math.max(rect.top, 0);
35✔
513
    const frameRelativeLeft = Math.max(rect.left, 0);
19✔
514

515
    const frameRelativeBottom = Math.min(windowHeight, rect.bottom);
19✔
516
    const frameRelativeRight = Math.min(windowWidth, rect.right);
19✔
517

518
    const data = {
19✔
519
        top: Math.max(0, rect.top * -1),
19✔
520
        height: Math.max(0, frameRelativeBottom - frameRelativeTop),
521
        left: Math.max(0, rect.left * -1),
19✔
522
        width: Math.max(0, frameRelativeRight - frameRelativeLeft),
19✔
523
    };
524

19✔
525
    return data;
526
}
527

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