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

thoughtspot / visual-embed-sdk / #3104

18 Dec 2025 07:02AM UTC coverage: 94.072% (-0.3%) from 94.364%
#3104

Pull #390

yinstardev
runtimeFilterParams are needed
Pull Request #390: SCAL-287664

1703 of 1915 branches covered (88.93%)

Branch coverage included in aggregate %.

25 of 30 new or added lines in 2 files covered. (83.33%)

5 existing lines in 1 file now uncovered.

3264 of 3365 relevant lines covered (97.0%)

113.67 hits per line

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

95.07
/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
import { ERROR_MESSAGE } from './errors';
35✔
21

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

43
            return filterExpr.join('&');
27✔
44
        });
45

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

49
    return null;
418✔
50
};
51

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

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

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

70
    return null;
428✔
71
};
72

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

84
    return JSON.stringify(value);
2✔
85
};
10✔
86

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

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

116
    if (qp.length) {
117
        return qp.join('&');
118
    }
119

120
    return null;
121
};
122

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

9✔
133
    return value;
134
};
608✔
135

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

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

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

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

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

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

25✔
184
    const marker = `tsSSOMarker=${encStringToAppend}`;
185

25!
186
    let splitAdder = '';
×
187

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

4✔
197
    return outputUrl;
198
};
21✔
199

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

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

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

8✔
223
export const embedEventStatus = {
224
    START: 'start',
4✔
225
    END: 'end',
226
};
12✔
227

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

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

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

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

261
    const customizations: CustomisationsInterface = {
262
        style: {
263
            ...customizationsFromEmbedConfig?.style,
423✔
264
            ...customizationsFromViewConfig?.style,
21✔
265
            customCSS: {
266
                ...customizationsFromEmbedConfig?.style?.customCSS,
267
                ...customizationsFromViewConfig?.style?.customCSS,
268
            },
37✔
269
            customCSSUrl:
270
                customizationsFromViewConfig?.style?.customCSSUrl
271
                || customizationsFromEmbedConfig?.style?.customCSSUrl,
35✔
272
        },
273
        content: {
274
            ...customizationsFromEmbedConfig?.content,
275
            ...customizationsFromViewConfig?.content,
121✔
276
        },
36✔
277
    };
36✔
278
    return customizations;
36✔
279
};
36✔
280

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

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

292
export const deepMerge = (target: any, source: any) => merge(target, source);
24✔
293

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

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

72✔
307

308
    for (const key in obj) {
309
        if (key === '__typename') {
24✔
310
            delete obj[key];
311
        } else if (typeof obj[key] === 'object') {
312
            removeTypename(obj[key]);
35!
313
        }
314
    }
315
    return obj;
316
}
317

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

363
export const isUndefined = (value: any): boolean => value === undefined;
35✔
364

365
// Return if the value is a string, double or boolean.
366
export const getTypeFromValue = (value: any): [string, string] => {
367
    if (typeof value === 'string') {
48✔
368
        return ['char', 'string'];
47✔
369
    }
164✔
370
    if (typeof value === 'number') {
164✔
371
        return ['double', 'double'];
164!
372
    }
164✔
373
    if (typeof value === 'boolean') {
374
        return ['boolean', 'boolean'];
375
    }
376
    return ['', ''];
377
};
378

379
const sdkWindowKey = '_tsEmbedSDK' as any;
380

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

8✔
402
    if (options.ignoreIfAlreadyExists && key in (window as any)[sdkWindowKey]) {
2✔
403
        return (window as any)[sdkWindowKey][key];
404
    }
6✔
405

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

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

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

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

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

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

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

485
    logger.error('Fullscreen API is not supported by this browser.');
486
};
487

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

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

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

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

519
export const calculateVisibleElementData = (element: HTMLElement) => {
520
    const rect = element.getBoundingClientRect();
521

522
    const windowHeight = window.innerHeight;
35✔
523
    const windowWidth = window.innerWidth;
3✔
524

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

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

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

1✔
538
    return data;
1✔
539
}
1✔
540

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

562
/**
19✔
563
 * Check if the window is undefined
564
 * If the window is undefined, it means the code is running in a SSR environment.
565
 * @returns true if the window is undefined, false otherwise
566
 * 
567
 */
568
export const isWindowUndefined = (): boolean => {
569
    if(typeof window === 'undefined') {
19✔
570
        logger.error(ERROR_MESSAGE.SSR_ENVIRONMENT_ERROR);
571
        return true;
572
    }
573
    return false;
574
}
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