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

thoughtspot / visual-embed-sdk / #2967

10 Dec 2025 05:59AM UTC coverage: 94.303% (-0.1%) from 94.432%
#2967

Pull #375

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

1372 of 1540 branches covered (89.09%)

Branch coverage included in aggregate %.

19 of 20 new or added lines in 3 files covered. (95.0%)

5 existing lines in 1 file now uncovered.

3213 of 3322 relevant lines covered (96.72%)

107.6 hits per line

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

97.0
/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) {
426✔
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;
406✔
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) {
425✔
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;
414✔
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,823✔
80
        return value;
15,238✔
81
    }
82

83
    return JSON.stringify(value);
585✔
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[] = [];
582✔
104
    const params = Object.keys(queryParams);
582✔
105
    params.forEach((key) => {
582✔
106
        const val = queryParams[key];
16,404✔
107
        if (val !== undefined) {
16,404✔
108
            const serializedValue = shouldSerializeParamValues
15,840✔
109
                ? serializeParam(val)
110
                : paramToString(val);
111
            qp.push(`${key}=${serializedValue}`);
15,840✔
112
        }
113
    });
114

115
    if (qp.length) {
582✔
116
        return qp.join('&');
581✔
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') {
817✔
129
        return `${value}px`;
582✔
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 (typeof value !== 'string' || value.trim() === '') {
27✔
142
        return false;
2✔
143
    }
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;
25✔
147
    const parts = value.trim().split(/\s+/);
25✔
148

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

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

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

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

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

176
    let splitAdder = '';
12✔
177

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.
181
        splitAdder = url.split('#')[1].indexOf('?') >= 0 ? '&' : '?';
8✔
182
    } else {
183
        splitAdder = '#?';
4✔
184
    }
185
    outputUrl = `${outputUrl}${splitAdder}${marker}`;
12✔
186

187
    return outputUrl;
12✔
188
};
189

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

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

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

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

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

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

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

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

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

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

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

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

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

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

297

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

308
/**
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
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 = (
35✔
323
    element: HTMLElement,
324
    styleProperties: Partial<CSSStyleDeclaration>,
325
): void => {
326
    if (!element?.style) return;
37✔
327
    Object.keys(styleProperties).forEach((styleProperty) => {
36✔
328
        const styleKey = styleProperty as keyof CSSStyleDeclaration;
124✔
329
        const value = styleProperties[styleKey];
124✔
330
        if (value !== undefined) {
124✔
331
            (element.style as any)[styleKey] = value.toString();
124✔
332
        }
333
    });
334
};
335
/**
336
 * Removes specified style properties from an HTML element.
337
 * @param {HTMLElement} element - The HTML element from which the styles should be removed.
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 => {
35✔
347
    if (!element?.style) return;
9✔
348
    styleProperties.forEach((styleProperty) => {
8✔
349
        element.style.removeProperty(styleProperty);
22✔
350
    });
351
};
352

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

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

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

371
/**
372
 * Stores a value in the global `window` object under the `_tsEmbedSDK` namespace.
373
 * @param key - The key under which the value will be stored.
374
 * @param value - The value to store.
375
 * @param options - Additional options.
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>(
35✔
383
    key: string,
384
    value: T,
385
    options: { ignoreIfAlreadyExists?: boolean } = {},
459✔
386
): T {
387
    if (!window[sdkWindowKey]) {
479✔
388
        (window as any)[sdkWindowKey] = {};
22✔
389
    }
390

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

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

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.
402
 * @returns The stored value or `undefined` if the key is not found.
403
 */
404
export const getValueFromWindow = <T = any>
35✔
405
    (key: string): T => (window as any)?.[sdkWindowKey]?.[key];
3,273!
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
411
 * @returns boolean indicating if the string is found in the array
412
 */
413
export const arrayIncludesString = (arr: readonly unknown[], key: string): boolean => {
35✔
414
    return arr.some(item => typeof item === 'string' && item === key);
397✔
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.
420
 * @param key - Key to reset
421
 * @returns - boolean indicating if the key was reset
422
 */
423
export function resetValueFromWindow(key: string): boolean {
35✔
424
    if (key in window[sdkWindowKey]) {
3✔
425
        delete (window as any)[sdkWindowKey][key];
3✔
426
        return true;
3✔
427
    }
428
    return false;
×
429
}
430

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

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> => {
35✔
448
    if (isInFullscreen()) {
3✔
449
        return; // Already in fullscreen
1✔
450
    }
451

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

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

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

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

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

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

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

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

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

512
    const frameRelativeTop = Math.max(rect.top, 0);
19✔
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),
520
        height: Math.max(0, frameRelativeBottom - frameRelativeTop),
521
        left: Math.max(0, rect.left * -1),
522
        width: Math.max(0, frameRelativeRight - frameRelativeLeft),
523
    };
524

525
    return data;
19✔
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
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 => {
35✔
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) => {
7✔
545
        return values[key] !== undefined ? String(values[key]) : match;
10✔
546
    });
547
};
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