@thoughtspot/visual-embed-sdk
Version:
ThoughtSpot Embed SDK
522 lines (457 loc) • 16.6 kB
text/typescript
/**
* Copyright (c) 2023
*
* Common utility functions for ThoughtSpot Visual Embed SDK
* @summary Utils
* @author Ayon Ghosh <ayon.ghosh@thoughtspot.com>
*/
import merge from 'ts-deepmerge';
import {
EmbedConfig,
QueryParams,
RuntimeFilter,
CustomisationsInterface,
DOMSelector,
RuntimeParameter,
AllEmbedViewConfig,
} from './types';
import { logger } from './utils/logger';
/**
* Construct a runtime filters query string from the given filters.
* Refer to the following docs for more details on runtime filter syntax:
* https://cloud-docs.thoughtspot.com/admin/ts-cloud/apply-runtime-filter.html
* https://cloud-docs.thoughtspot.com/admin/ts-cloud/runtime-filter-operators.html
* @param runtimeFilters
*/
export const getFilterQuery = (runtimeFilters: RuntimeFilter[]): string | null => {
if (runtimeFilters && runtimeFilters.length) {
const filters = runtimeFilters.map((filter, valueIndex) => {
const index = valueIndex + 1;
const filterExpr = [];
filterExpr.push(`col${index}=${encodeURIComponent(filter.columnName)}`);
filterExpr.push(`op${index}=${filter.operator}`);
filterExpr.push(
filter.values.map((value) => {
const encodedValue = typeof value === 'bigint' ? value.toString() : value;
return `val${index}=${encodeURIComponent(String(encodedValue))}`;
}).join('&'),
);
return filterExpr.join('&');
});
return `${filters.join('&')}`;
}
return null;
};
/**
* Construct a runtime parameter override query string from the given option.
* @param runtimeParameters
*/
export const getRuntimeParameters = (runtimeParameters: RuntimeParameter[]): string => {
if (runtimeParameters && runtimeParameters.length) {
const params = runtimeParameters.map((param, valueIndex) => {
const index = valueIndex + 1;
const filterExpr = [];
filterExpr.push(`param${index}=${encodeURIComponent(param.name)}`);
filterExpr.push(`paramVal${index}=${encodeURIComponent(param.value)}`);
return filterExpr.join('&');
});
return `${params.join('&')}`;
}
return null;
};
/**
* Convert a value to a string representation to be sent as a query
* parameter to the ThoughtSpot app.
* @param value Any parameter value
*/
const serializeParam = (value: any) => {
// do not serialize primitive types
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return value;
}
return JSON.stringify(value);
};
/**
* Convert a value to a string:
* in case of an array, we convert it to CSV.
* in case of any other type, we directly return the value.
* @param value
*/
const paramToString = (value: any) => (Array.isArray(value) ? value.join(',') : value);
/**
* Return a query param string composed from the given params object
* @param queryParams
* @param shouldSerializeParamValues
*/
export const getQueryParamString = (
queryParams: QueryParams,
shouldSerializeParamValues = false,
): string => {
const qp: string[] = [];
const params = Object.keys(queryParams);
params.forEach((key) => {
const val = queryParams[key];
if (val !== undefined) {
const serializedValue = shouldSerializeParamValues
? serializeParam(val)
: paramToString(val);
qp.push(`${key}=${serializedValue}`);
}
});
if (qp.length) {
return qp.join('&');
}
return null;
};
/**
* Get a string representation of a dimension value in CSS
* If numeric, it is considered in pixels.
* @param value
*/
export const getCssDimension = (value: number | string): string => {
if (typeof value === 'number') {
return `${value}px`;
}
return value;
};
export const getSSOMarker = (markerId: string) => {
const encStringToAppend = encodeURIComponent(markerId);
return `tsSSOMarker=${encStringToAppend}`;
};
/**
* Append a string to a URL's hash fragment
* @param url A URL
* @param stringToAppend The string to append to the URL hash
*/
export const appendToUrlHash = (url: string, stringToAppend: string) => {
let outputUrl = url;
const encStringToAppend = encodeURIComponent(stringToAppend);
const marker = `tsSSOMarker=${encStringToAppend}`;
let splitAdder = '';
if (url.indexOf('#') >= 0) {
// If second half of hash contains a '?' already add a '&' instead of
// '?' which appends to query params.
splitAdder = url.split('#')[1].indexOf('?') >= 0 ? '&' : '?';
} else {
splitAdder = '#?';
}
outputUrl = `${outputUrl}${splitAdder}${marker}`;
return outputUrl;
};
/**
*
* @param url
* @param stringToAppend
* @param path
*/
export function getRedirectUrl(url: string, stringToAppend: string, path = '') {
const targetUrl = path ? new URL(path, window.location.origin).href : url;
return appendToUrlHash(targetUrl, stringToAppend);
}
export const getEncodedQueryParamsString = (queryString: string) => {
if (!queryString) {
return queryString;
}
return btoa(queryString).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
};
export const getOffsetTop = (element: any) => {
const rect = element.getBoundingClientRect();
return rect.top + window.scrollY;
};
export const embedEventStatus = {
START: 'start',
END: 'end',
};
export const setAttributes = (
element: HTMLElement,
attributes: { [key: string]: string | number | boolean },
): void => {
Object.keys(attributes).forEach((key) => {
element.setAttribute(key, attributes[key].toString());
});
};
const isCloudRelease = (version: string) => version.endsWith('.cl');
/* For Search Embed: ReleaseVersionInBeta */
export const checkReleaseVersionInBeta = (
releaseVersion: string,
suppressBetaWarning: boolean,
): boolean => {
if (releaseVersion !== '' && !isCloudRelease(releaseVersion)) {
const splittedReleaseVersion = releaseVersion.split('.');
const majorVersion = Number(splittedReleaseVersion[0]);
const isBetaVersion = majorVersion < 8;
return !suppressBetaWarning && isBetaVersion;
}
return false;
};
export const getCustomisations = (
embedConfig: EmbedConfig,
viewConfig: AllEmbedViewConfig,
): CustomisationsInterface => {
const customizationsFromViewConfig = viewConfig.customizations;
const customizationsFromEmbedConfig = embedConfig.customizations
|| ((embedConfig as any).customisations as CustomisationsInterface);
const customizations: CustomisationsInterface = {
style: {
...customizationsFromEmbedConfig?.style,
...customizationsFromViewConfig?.style,
customCSS: {
...customizationsFromEmbedConfig?.style?.customCSS,
...customizationsFromViewConfig?.style?.customCSS,
},
customCSSUrl:
customizationsFromViewConfig?.style?.customCSSUrl
|| customizationsFromEmbedConfig?.style?.customCSSUrl,
},
content: {
...customizationsFromEmbedConfig?.content,
...customizationsFromViewConfig?.content,
},
};
return customizations;
};
export const getRuntimeFilters = (runtimefilters: any) => getFilterQuery(runtimefilters || []);
/**
* Gets a reference to the DOM node given
* a selector.
* @param domSelector
*/
export function getDOMNode(domSelector: DOMSelector): HTMLElement {
return typeof domSelector === 'string' ? document.querySelector(domSelector) : domSelector;
}
export const deepMerge = (target: any, source: any) => merge(target, source);
export const getOperationNameFromQuery = (query: string) => {
const regex = /(?:query|mutation)\s+(\w+)/;
const matches = query.match(regex);
return matches?.[1];
};
/**
*
* @param obj
*/
export function removeTypename(obj: any) {
if (!obj || typeof obj !== 'object') return obj;
for (const key in obj) {
if (key === '__typename') {
delete obj[key];
} else if (typeof obj[key] === 'object') {
removeTypename(obj[key]);
}
}
return obj;
}
/**
* Sets the specified style properties on an HTML element.
* @param {HTMLElement} element - The HTML element to which the styles should be applied.
* @param {Partial<CSSStyleDeclaration>} styleProperties - An object containing style
* property names and their values.
* @example
* // Apply styles to an element
* const element = document.getElementById('myElement');
* const styles = {
* backgroundColor: 'red',
* fontSize: '16px',
* };
* setStyleProperties(element, styles);
*/
export const setStyleProperties = (
element: HTMLElement,
styleProperties: Partial<CSSStyleDeclaration>,
): void => {
if (!element?.style) return;
Object.keys(styleProperties).forEach((styleProperty) => {
const styleKey = styleProperty as keyof CSSStyleDeclaration;
const value = styleProperties[styleKey];
if (value !== undefined) {
(element.style as any)[styleKey] = value.toString();
}
});
};
/**
* Removes specified style properties from an HTML element.
* @param {HTMLElement} element - The HTML element from which the styles should be removed.
* @param {string[]} styleProperties - An array of style property names to be removed.
* @example
* // Remove styles from an element
* const element = document.getElementById('myElement');
* element.style.backgroundColor = 'red';
* const propertiesToRemove = ['backgroundColor'];
* removeStyleProperties(element, propertiesToRemove);
*/
export const removeStyleProperties = (element: HTMLElement, styleProperties: string[]): void => {
if (!element?.style) return;
styleProperties.forEach((styleProperty) => {
element.style.removeProperty(styleProperty);
});
};
export const isUndefined = (value: any): boolean => value === undefined;
// Return if the value is a string, double or boolean.
export const getTypeFromValue = (value: any): [string, string] => {
if (typeof value === 'string') {
return ['char', 'string'];
}
if (typeof value === 'number') {
return ['double', 'double'];
}
if (typeof value === 'boolean') {
return ['boolean', 'boolean'];
}
return ['', ''];
};
const sdkWindowKey = '_tsEmbedSDK' as any;
/**
* Stores a value in the global `window` object under the `_tsEmbedSDK` namespace.
* @param key - The key under which the value will be stored.
* @param value - The value to store.
* @param options - Additional options.
* @param options.ignoreIfAlreadyExists - Does not set if value for key is set.
*
* @returns The stored value.
*
* @version SDK: 1.36.2 | ThoughtSpot: *
*/
export function storeValueInWindow<T>(
key: string,
value: T,
options: { ignoreIfAlreadyExists?: boolean } = {},
): T {
if (!window[sdkWindowKey]) {
(window as any)[sdkWindowKey] = {};
}
if (options.ignoreIfAlreadyExists && key in (window as any)[sdkWindowKey]) {
return (window as any)[sdkWindowKey][key];
}
(window as any)[sdkWindowKey][key] = value;
return value;
}
/**
* Retrieves a stored value from the global `window` object under the `_tsEmbedSDK` namespace.
* @param key - The key whose value needs to be retrieved.
* @returns The stored value or `undefined` if the key is not found.
*/
export const getValueFromWindow = <T = any>
(key: string): T => (window as any)?.[sdkWindowKey]?.[key];
/**
* Check if an array includes a string value
* @param arr - The array to check
* @param key - The string to search for
* @returns boolean indicating if the string is found in the array
*/
export const arrayIncludesString = (arr: readonly unknown[], key: string): boolean => {
return arr.some(item => typeof item === 'string' && item === key);
};
/**
* Resets the key if it exists in the `window` object under the `_tsEmbedSDK` key.
* Returns true if the key was reset, false otherwise.
* @param key - Key to reset
* @returns - boolean indicating if the key was reset
*/
export function resetValueFromWindow(key: string): boolean {
if (key in window[sdkWindowKey]) {
delete (window as any)[sdkWindowKey][key];
return true;
}
return false;
}
/**
* Check if the document is currently in fullscreen mode
*/
const isInFullscreen = (): boolean => {
return !!(
document.fullscreenElement ||
(document as any).webkitFullscreenElement ||
(document as any).mozFullScreenElement ||
(document as any).msFullscreenElement
);
};
/**
* Handle Present HostEvent by entering fullscreen mode
* @param iframe The iframe element to make fullscreen
*/
export const handlePresentEvent = async (iframe: HTMLIFrameElement): Promise<void> => {
if (isInFullscreen()) {
return; // Already in fullscreen
}
// Browser-specific methods to enter fullscreen mode
const fullscreenMethods = [
'requestFullscreen', // Standard API
'webkitRequestFullscreen', // WebKit browsers
'mozRequestFullScreen', // Firefox
'msRequestFullscreen' // IE/Edge
];
for (const method of fullscreenMethods) {
if (typeof (iframe as any)[method] === 'function') {
try {
const result = (iframe as any)[method]();
await Promise.resolve(result);
return;
} catch (error) {
logger.warn(`Failed to enter fullscreen using ${method}:`, error);
}
}
}
logger.error('Fullscreen API is not supported by this browser.');
};
/**
* Handle ExitPresentMode EmbedEvent by exiting fullscreen mode
*/
export const handleExitPresentMode = async (): Promise<void> => {
if (!isInFullscreen()) {
return; // Not in fullscreen
}
const exitFullscreenMethods = [
'exitFullscreen', // Standard API
'webkitExitFullscreen', // WebKit browsers
'mozCancelFullScreen', // Firefox
'msExitFullscreen' // IE/Edge
];
// Try each method until one works
for (const method of exitFullscreenMethods) {
if (typeof (document as any)[method] === 'function') {
try {
const result = (document as any)[method]();
await Promise.resolve(result);
return;
} catch (error) {
logger.warn(`Failed to exit fullscreen using ${method}:`, error);
}
}
}
logger.warn('Exit fullscreen API is not supported by this browser.');
};
export const calculateVisibleElementData = (element: HTMLElement) => {
const rect = element.getBoundingClientRect();
const windowHeight = window.innerHeight;
const windowWidth = window.innerWidth;
const frameRelativeTop = Math.max(rect.top, 0);
const frameRelativeLeft = Math.max(rect.left, 0);
const frameRelativeBottom = Math.min(windowHeight, rect.bottom);
const frameRelativeRight = Math.min(windowWidth, rect.right);
const data = {
top: Math.max(0, rect.top * -1),
height: Math.max(0, frameRelativeBottom - frameRelativeTop),
left: Math.max(0, rect.left * -1),
width: Math.max(0, frameRelativeRight - frameRelativeLeft),
};
return data;
}
/**
* Replaces placeholders in a template string with provided values.
* Placeholders should be in the format {key}.
* @param template - The template string with placeholders
* @param values - An object containing key-value pairs to replace placeholders
* @returns The template string with placeholders replaced
* @example
* formatTemplate('Hello {name}, you are {age} years old', { name: 'John', age: 30 })
* // Returns: 'Hello John, you are 30 years old'
*
* formatTemplate('Expected {type}, but received {actual}', { type: 'string', actual: 'number' })
* // Returns: 'Expected string, but received number'
*/
export const formatTemplate = (template: string, values: Record<string, any>): string => {
// This regex /\{(\w+)\}/g finds all placeholders in the format {word}
// and captures the word inside the braces for replacement.
return template.replace(/\{(\w+)\}/g, (match, key) => {
return values[key] !== undefined ? String(values[key]) : match;
});
};