ashish-sdk
Version:
ThoughtSpot Embed SDK
708 lines (647 loc) • 22.2 kB
text/typescript
/**
* Copyright (c) 2022
*
* Base classes
*
* @summary Base classes
* @author Ayon Ghosh <ayon.ghosh@thoughtspot.com>
*/
import {
getEncodedQueryParamsString,
getCssDimension,
getOffsetTop,
} from '../utils';
import {
getThoughtSpotHost,
URL_MAX_LENGTH,
DEFAULT_EMBED_WIDTH,
DEFAULT_EMBED_HEIGHT,
getV2BasePath,
} from '../config';
import {
DOMSelector,
HostEvent,
EmbedEvent,
MessageCallback,
Action,
RuntimeFilter,
Param,
EmbedConfig,
Plugin,
} from '../types';
import { uploadMixpanelEvent, MIXPANEL_EVENT } from '../mixpanel-service';
import { getProcessData } from '../utils/processData';
import { processTrigger } from '../utils/processTrigger';
// eslint-disable-next-line import/no-cycle
import pkgInfo from '../../package.json';
import { getAuthPromise, getEmbedConfig, renderInQueue } from './base';
const { version } = pkgInfo;
/**
* The event id map from v2 event names to v1 event id
* v1 events are the classic embed events implemented in Blink v1
* We cannot rename v1 event types to maintain backward compatibility
* @internal
*/
const V1EventMap = {
[EmbedEvent.Data]: [EmbedEvent.V1Data],
};
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface LayoutConfig {}
/**
* Embedded iFrame configuration
*/
export interface FrameParams {
/**
* The width of the iFrame (unit is pixels if numeric).
*/
width?: number | string;
/**
* The height of the iFrame (unit is pixels if numeric).
*/
height?: number | string;
}
/**
* The configuration object for an embedded view.
*/
export interface ViewConfig {
/**
* @hidden
*/
layoutConfig?: LayoutConfig;
/**
* The <b>width</b> and <b>height</b> dimensions to render an embedded object inside your app. Specify the values in pixels or percentage.
*/
frameParams?: FrameParams;
/**
* @hidden
*/
theme?: string;
/**
* @hidden
*/
// eslint-disable-next-line camelcase
styleSheet__unstable?: string;
/**
* The list of actions to disable from the primary menu, more menu
* (...), and the contextual menu.
*/
disabledActions?: Action[];
/**
* The tooltip to display for disabled actions.
*/
disabledActionReason?: string;
/**
* The list of actions to hide from the primary menu, more menu
* (...), and the contextual menu.
*/
hiddenActions?: Action[];
/**
* The list of actions to display from the primary menu, more menu
* (...), and the contextual menu.
* @version 1.6.0 or later
*/
visibleActions?: Action[];
/**
* The list of runtime filters to apply to a search answer,
* visualization, or Liveboard.
*/
runtimeFilters?: RuntimeFilter[];
/**
* This is an object (key/val) of override flags which will be applied
* to the internal embedded object. This can be used to add any
* URL flag.
* @version 1.8.0
*/
additionalFlags?: { [key: string]: string | number | boolean };
/**
* Provide a list of plugins, the plugins are executed in the order
* provided below,
*
* @version alpha
*/
plugins?: Plugin[];
}
/**
* Base class for embedding v2 experience
* Note: the v2 version of ThoughtSpot Blink is built on the new stack:
* React+GraphQL
*/
export class TsEmbed {
/**
* The DOM node where the ThoughtSpot app is to be embedded.
*/
private el: Element;
/**
* A reference to the iframe within which the ThoughtSpot app
* will be rendered.
*/
protected iFrame: HTMLIFrameElement;
protected viewConfig: ViewConfig;
protected embedConfig: EmbedConfig;
/**
* The ThoughtSpot hostname or IP address
*/
protected thoughtSpotHost: string;
/*
* This is the base to access ThoughtSpot V2.
*/
protected thoughtSpotV2Base: string;
/**
* A map of event handlers for particular message types triggered
* by the embedded app; multiple event handlers can be registered
* against a particular message type.
*/
private eventHandlerMap: Map<string, MessageCallback[]>;
/**
* A flag that is set to true post render.
*/
private isRendered: boolean;
/**
* A flag to mark if an error has occurred.
*/
private isError: boolean;
/**
* Should we encode URL Query Params using base64 encoding which thoughtspot
* will generate for embedding. This provides additional security to
* thoughtspot clusters against Cross site scripting attacks.
* @default false
*/
private shouldEncodeUrlQueryParams = false;
constructor(domSelector: DOMSelector, viewConfig?: ViewConfig) {
this.el = this.getDOMNode(domSelector);
// TODO: handle error
this.embedConfig = getEmbedConfig();
this.thoughtSpotHost = getThoughtSpotHost(this.embedConfig);
this.thoughtSpotV2Base = getV2BasePath(this.embedConfig);
this.eventHandlerMap = new Map();
this.isError = false;
this.viewConfig = viewConfig;
this.shouldEncodeUrlQueryParams = this.embedConfig.shouldEncodeUrlQueryParams;
this.registerPlugins(viewConfig?.plugins);
if (!this.embedConfig.suppressNoCookieAccessAlert) {
this.on(EmbedEvent.NoCookieAccess, () => {
// eslint-disable-next-line no-alert
alert(
'Third party cookie access is blocked on this browser, please allow third party cookies for ThoughtSpot to work properly',
);
});
}
}
private registerPlugins(plugins: Plugin[]) {
if (!plugins) {
return;
}
plugins.forEach((plugin) => {
Object.keys(plugin.handlers).forEach((eventName: EmbedEvent) => {
const listener = plugin.handlers[eventName];
this.on(eventName, listener);
});
});
}
/**
* Gets a reference to the root DOM node where
* the embedded content will appear.
* @param domSelector
*/
private getDOMNode(domSelector: DOMSelector) {
return typeof domSelector === 'string'
? document.querySelector(domSelector)
: domSelector;
}
/**
* Throws error encountered during initialization.
*/
private throwInitError() {
this.handleError('You need to init the ThoughtSpot SDK module first');
}
/**
* Handles errors within the SDK
* @param error The error message or object
*/
protected handleError(error: string | Record<string, unknown>) {
this.isError = true;
this.executeCallbacks(EmbedEvent.Error, {
error,
});
// Log error
console.log(error);
}
/**
* Extracts the type field from the event payload
* @param event The window message event
*/
private getEventType(event: MessageEvent) {
// eslint-disable-next-line no-underscore-dangle
return event.data?.type || event.data?.__type;
}
/**
* Extracts the port field from the event payload
* @param event The window message event
* @returns
*/
private getEventPort(event: MessageEvent) {
if (event.ports.length && event.ports[0]) {
return event.ports[0];
}
return null;
}
/**
* fix for ts7.sep.cl
* will be removed for ts7.oct.cl
* @hidden
*/
private formatEventData(event: MessageEvent) {
const eventData = {
...event.data,
};
if (!eventData.data) {
eventData.data = event.data.payload;
}
return eventData;
}
/**
* Adds a global event listener to window for "message" events.
* ThoughtSpot detects if a particular event is targeted to this
* embed instance through an identifier contained in the payload,
* and executes the registered callbacks accordingly.
*/
private subscribeToEvents() {
window.addEventListener('message', (event) => {
const eventType = this.getEventType(event);
const eventPort = this.getEventPort(event);
const eventData = this.formatEventData(event);
if (event.source === this.iFrame.contentWindow) {
this.executeCallbacks(
eventType,
getProcessData(eventType, eventData, this.thoughtSpotHost),
eventPort,
);
}
});
}
/**
* Constructs the base URL string to load the ThoughtSpot app.
*/
protected getEmbedBasePath(query: string): string {
let queryString = query;
if (this.shouldEncodeUrlQueryParams) {
queryString = `?base64UrlEncodedFlags=${getEncodedQueryParamsString(
queryString.substr(1),
)}`;
}
const basePath = [
this.thoughtSpotHost,
this.thoughtSpotV2Base,
queryString,
]
.filter((x) => x.length > 0)
.join('/');
return `${basePath}#/embed`;
}
/**
* Common query params set for all the embed modes.
* @returns queryParams
*/
protected getBaseQueryParams() {
const queryParams = {};
let hostAppUrl = window?.location?.host || '';
// The below check is needed because TS Cloud firewall, blocks localhost/127.0.0.1
// in any url param.
if (
hostAppUrl.includes('localhost') ||
hostAppUrl.includes('127.0.0.1')
) {
hostAppUrl = 'local-host';
}
queryParams[Param.HostAppUrl] = encodeURIComponent(hostAppUrl);
queryParams[Param.ViewPortHeight] = window.innerHeight;
queryParams[Param.ViewPortWidth] = window.innerWidth;
queryParams[Param.Version] = version;
if (this.embedConfig.customCssUrl) {
queryParams[Param.CustomCSSUrl] = this.embedConfig.customCssUrl;
}
const {
disabledActions,
disabledActionReason,
hiddenActions,
visibleActions,
additionalFlags,
} = this.viewConfig;
if (Array.isArray(visibleActions) && Array.isArray(hiddenActions)) {
this.handleError(
'You cannot have both hidden actions and visible actions',
);
return queryParams;
}
if (disabledActions?.length) {
queryParams[Param.DisableActions] = disabledActions;
}
if (disabledActionReason) {
queryParams[Param.DisableActionReason] = disabledActionReason;
}
if (hiddenActions?.length) {
queryParams[Param.HideActions] = hiddenActions;
}
if (Array.isArray(visibleActions)) {
queryParams[Param.VisibleActions] = visibleActions;
}
if (additionalFlags && additionalFlags.constructor.name === 'Object') {
Object.assign(queryParams, additionalFlags);
}
return queryParams;
}
/**
* Constructs the base URL string to load v1 of the ThoughtSpot app.
* This is used for embedding Liveboards, visualizations, and full application.
* @param queryString The query string to append to the URL.
* @param isAppEmbed A Boolean parameter to specify if you are embedding
* the full application.
*/
protected getV1EmbedBasePath(
queryString: string,
showPrimaryNavbar = false,
disableProfileAndHelp = false,
isAppEmbed = false,
): string {
const queryStringFrag = queryString ? `&${queryString}` : '';
const primaryNavParam = `&primaryNavHidden=${!showPrimaryNavbar}`;
const disableProfileAndHelpParam = `&profileAndHelpInNavBarHidden=${disableProfileAndHelp}`;
let queryParams = `?embedApp=true${isAppEmbed ? primaryNavParam : ''}${
isAppEmbed ? disableProfileAndHelpParam : ''
}${queryStringFrag}`;
if (this.shouldEncodeUrlQueryParams) {
queryParams = `?base64UrlEncodedFlags=${getEncodedQueryParamsString(
queryParams.substr(1),
)}`;
}
let path = `${this.thoughtSpotHost}/${queryParams}#`;
if (!isAppEmbed) {
path = `${path}/embed`;
}
return path;
}
/**
* Renders the embedded ThoughtSpot app in an iframe and sets up
* event listeners.
* @param url
* @param frameOptions
*/
protected renderIFrame(url: string, frameOptions: FrameParams): void {
if (this.isError) {
return;
}
if (!this.thoughtSpotHost) {
this.throwInitError();
}
if (url.length > URL_MAX_LENGTH) {
// warn: The URL is too long
}
renderInQueue((nextInQueue) => {
const initTimestamp = Date.now();
this.executeCallbacks(EmbedEvent.Init, {
data: {
timestamp: initTimestamp,
},
});
uploadMixpanelEvent(MIXPANEL_EVENT.VISUAL_SDK_RENDER_START);
getAuthPromise()
?.then(() => {
uploadMixpanelEvent(
MIXPANEL_EVENT.VISUAL_SDK_RENDER_COMPLETE,
);
this.iFrame =
this.iFrame || document.createElement('iframe');
this.iFrame.src = url;
// according to screenfull.js documentation
// allowFullscreen, webkitallowfullscreen and mozallowfullscreen must be true
this.iFrame.allowFullscreen = true;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this.iFrame.webkitallowfullscreen = true;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this.iFrame.mozallowfullscreen = true;
const width = getCssDimension(
frameOptions?.width || DEFAULT_EMBED_WIDTH,
);
const height = getCssDimension(
frameOptions?.height || DEFAULT_EMBED_HEIGHT,
);
this.iFrame.style.width = `${width}`;
this.iFrame.style.height = `${height}`;
this.iFrame.style.border = '0';
this.iFrame.name = 'ThoughtSpot Embedded Analytics';
this.iFrame.addEventListener('load', () => {
nextInQueue();
const loadTimestamp = Date.now();
this.executeCallbacks(EmbedEvent.Load, {
data: {
timestamp: loadTimestamp,
},
});
uploadMixpanelEvent(
MIXPANEL_EVENT.VISUAL_SDK_IFRAME_LOAD_PERFORMANCE,
{
timeTookToLoad: loadTimestamp - initTimestamp,
},
);
});
this.iFrame.addEventListener('error', () => {
nextInQueue();
});
this.el.innerHTML = '';
this.el.appendChild(this.iFrame);
const prefetchIframe = document.querySelectorAll(
'.prefetchIframe',
);
if (prefetchIframe.length) {
prefetchIframe.forEach((el) => {
el.remove();
});
}
this.subscribeToEvents();
})
.catch((error) => {
nextInQueue();
uploadMixpanelEvent(
MIXPANEL_EVENT.VISUAL_SDK_RENDER_FAILED,
);
this.handleError(error);
});
});
}
/**
* Sets the height of the iframe
* @param height The height in pixels
*/
protected setIFrameHeight(height: number): void {
this.iFrame.style.height = `${height}px`;
}
/**
* Executes all registered event handlers for a particular event type
* @param eventType The event type
* @param data The payload invoked with the event handler
* @param eventPort The event Port for a specific MessageChannel
*/
protected executeCallbacks(
eventType: EmbedEvent,
data: any,
eventPort?: MessagePort | void,
): void {
const callbacks = this.eventHandlerMap.get(eventType) || [];
callbacks.forEach((callback) =>
callback(data, (payload) => {
this.triggerEventOnPort(eventPort, payload);
}),
);
}
/**
* Returns the ThoughtSpot hostname or IP address.
*/
protected getThoughtSpotHost(): string {
return this.thoughtSpotHost;
}
/**
* Gets the v1 event type (if applicable) for the EmbedEvent type
* @param eventType The v2 event type
* @returns The corresponding v1 event type if one exists
* or else the v2 event type itself
*/
protected getCompatibleEventType(eventType: EmbedEvent): EmbedEvent {
return V1EventMap[eventType] || eventType;
}
/**
* Calculates the iframe center for the current visible viewPort
* of iframe using Scroll position of Host App, offsetTop for iframe
* in Host app. ViewPort height of the tab.
* @returns iframe Center in visible viewport,
* Iframe height,
* View port height.
*/
protected getIframeCenter() {
const offsetTopClient = getOffsetTop(this.iFrame);
const scrollTopClient = window.scrollY;
const viewPortHeight = window.innerHeight;
const iframeHeight = this.iFrame.offsetHeight;
const iframeScrolled = scrollTopClient - offsetTopClient;
let iframeVisibleViewPort;
let iframeOffset;
if (iframeScrolled < 0) {
iframeVisibleViewPort =
viewPortHeight - (offsetTopClient - scrollTopClient);
iframeVisibleViewPort = Math.min(
iframeHeight,
iframeVisibleViewPort,
);
iframeOffset = 0;
} else {
iframeVisibleViewPort = Math.min(
iframeHeight - iframeScrolled,
viewPortHeight,
);
iframeOffset = iframeScrolled;
}
const iframeCenter = iframeOffset + iframeVisibleViewPort / 2;
return {
iframeCenter,
iframeScrolled,
iframeHeight,
viewPortHeight,
iframeVisibleViewPort,
};
}
/**
* Registers an event listener to trigger an alert when the ThoughtSpot app
* sends an event of a particular message type to the host application.
*
* @param messageType The message type
* @param callback A callback function
*/
public on(
messageType: EmbedEvent,
callback: MessageCallback,
): typeof TsEmbed.prototype {
if (this.isRendered) {
this.handleError(
'Please register event handlers before calling render',
);
}
const callbacks = this.eventHandlerMap.get(messageType) || [];
callbacks.push(callback);
this.eventHandlerMap.set(messageType, callbacks);
return this;
}
/**
* Triggers an event on specific Port registered against
* for the EmbedEvent
* @param eventType The message type
* @param data The payload to send
*/
private triggerEventOnPort(eventPort: MessagePort | void, payload: any) {
if (eventPort) {
try {
eventPort.postMessage({
type: payload.type,
data: payload.data,
});
} catch (e) {
eventPort.postMessage({ error: e });
console.log(e);
}
} else {
console.log('Event Port is not defined');
}
}
/**
* Triggers an event to the embedded app
* @param messageType The event type
* @param data The payload to send with the message
*/
public trigger(
messageType: HostEvent,
data: any,
): typeof TsEmbed.prototype {
processTrigger(this.iFrame, messageType, this.thoughtSpotHost, data);
uploadMixpanelEvent(
`${MIXPANEL_EVENT.VISUAL_SDK_TRIGGER}-${messageType}`,
);
return this;
}
/**
* Marks the ThoughtSpot object to have been rendered
* Needs to be overridden by subclasses to do the actual
* rendering of the iframe.
* @param args
*/
public render(): TsEmbed {
this.isRendered = true;
return this;
}
}
/**
* Base class for embedding v1 experience
* Note: The v1 version of ThoughtSpot Blink works on the AngularJS stack
* which is currently under migration to v2
*/
export class V1Embed extends TsEmbed {
protected viewConfig: ViewConfig;
constructor(domSelector: DOMSelector, viewConfig: ViewConfig) {
super(domSelector, viewConfig);
this.viewConfig = viewConfig;
}
/**
* Render the app in an iframe and set up event handlers
* @param iframeSrc
*/
protected renderV1Embed(iframeSrc: string): void {
this.renderIFrame(iframeSrc, this.viewConfig.frameParams);
}
// @override
public on(
messageType: EmbedEvent,
callback: MessageCallback,
): typeof TsEmbed.prototype {
const eventType = this.getCompatibleEventType(messageType);
return super.on(eventType, callback);
}
}