@thoughtspot/visual-embed-sdk
Version:
ThoughtSpot Embed SDK
480 lines (442 loc) • 15.9 kB
text/typescript
/* eslint-disable camelcase */
/* eslint-disable import/no-mutable-exports */
/**
* Copyright (c) 2022
*
* Base classes
* @summary Base classes
* @author Ayon Ghosh <ayon.ghosh@thoughtspot.com>
*/
import EventEmitter from 'eventemitter3';
import { registerReportingObserver } from '../utils/reporting';
import { logger, setGlobalLogLevelOverride } from '../utils/logger';
import { tokenizedFetch } from '../tokenizedFetch';
import { EndPoints } from '../utils/authService/authService';
import { getThoughtSpotHost } from '../config';
import {
AuthType, EmbedConfig, LogLevel, Param, PrefetchFeatures,
} from '../types';
import {
authenticate,
logout as _logout,
AuthFailureType,
AuthStatus,
AuthEvent,
notifyAuthFailure,
notifyAuthSDKSuccess,
notifyAuthSuccess,
notifyLogout,
setAuthEE,
AuthEventEmitter,
postLoginService,
} from '../auth';
import '../utils/with-resolvers-polyfill';
import { uploadMixpanelEvent, MIXPANEL_EVENT } from '../mixpanel-service';
import { getEmbedConfig, setEmbedConfig } from './embedConfig';
import { getQueryParamString, getValueFromWindow, isWindowUndefined, storeValueInWindow } from '../utils';
import { resetAllCachedServices } from '../utils/resetServices';
import { reload } from '../utils/processTrigger';
import { ERROR_MESSAGE } from '../errors';
const CONFIG_DEFAULTS: Partial<EmbedConfig> = {
loginFailedMessage: 'Not logged in',
authTriggerText: 'Authorize',
authType: AuthType.None,
logLevel: LogLevel.ERROR,
waitForCleanupOnDestroy: false,
cleanupTimeout: 5000,
};
export interface executeTMLInput {
metadata_tmls: string[];
import_policy?: 'PARTIAL' | 'ALL_OR_NONE' | 'VALIDATE_ONLY';
create_new?: boolean;
}
export interface exportTMLInput {
metadata: {
identifier: string;
type?: 'LIVEBOARD' | 'ANSWER' | 'LOGICAL_TABLE' | 'CONNECTION';
}[];
export_associated?: boolean;
export_fqn?: boolean;
edoc_format?: 'YAML' | 'JSON';
}
export let authPromise: Promise<boolean>;
export const getAuthPromise = (): Promise<boolean> => authPromise;
export {
notifyAuthFailure, notifyAuthSDKSuccess, notifyAuthSuccess, notifyLogout,
};
/**
* Perform authentication on the ThoughtSpot app as applicable.
*/
export const handleAuth = (): Promise<boolean> => {
authPromise = authenticate(getEmbedConfig());
authPromise.then(
(isLoggedIn) => {
if (!isLoggedIn) {
notifyAuthFailure(AuthFailureType.SDK);
} else {
// Post login service is called after successful login.
postLoginService();
notifyAuthSDKSuccess();
}
},
() => {
notifyAuthFailure(AuthFailureType.SDK);
},
);
return authPromise;
};
const hostUrlToFeatureUrl = {
[PrefetchFeatures.SearchEmbed]: (url: string, flags: string) => `${url}v2/?${flags}#/embed/answer`,
[PrefetchFeatures.LiveboardEmbed]: (url: string, flags: string) => `${url}?${flags}`,
[PrefetchFeatures.FullApp]: (url: string, flags: string) => `${url}?${flags}`,
[PrefetchFeatures.VizEmbed]: (url: string, flags: string) => `${url}?${flags}`,
};
/**
* Prefetches static resources from the specified URL. Web browsers can then cache the
* prefetched resources and serve them from the user's local disk to provide faster access
* to your app.
* @param url The URL provided for prefetch
* @param prefetchFeatures Specify features which needs to be prefetched.
* @param additionalFlags This can be used to add any URL flag.
* @version SDK: 1.4.0 | ThoughtSpot: ts7.sep.cl, 7.2.1
* @group Global methods
*/
export const prefetch = (
url?: string,
prefetchFeatures?: PrefetchFeatures[],
additionalFlags?: { [key: string]: string | number | boolean },
): void => {
if (url === '') {
// eslint-disable-next-line no-console
logger.warn('The prefetch method does not have a valid URL');
} else {
const features = prefetchFeatures || [PrefetchFeatures.FullApp];
let hostUrl = url || getEmbedConfig().thoughtSpotHost;
const prefetchFlags = {
[Param.EmbedApp]: true,
...getEmbedConfig()?.additionalFlags,
...additionalFlags,
};
hostUrl = hostUrl[hostUrl.length - 1] === '/' ? hostUrl : `${hostUrl}/`;
Array.from(
new Set(features
.map((feature) => hostUrlToFeatureUrl[feature](
hostUrl,
getQueryParamString(prefetchFlags),
))),
)
.forEach(
(prefetchUrl, index) => {
const iFrame = document.createElement('iframe');
iFrame.src = prefetchUrl;
iFrame.style.width = '0';
iFrame.style.height = '0';
iFrame.style.border = '0';
// Make it 'fixed' to keep it in a different stacking context.
// This should solve the focus behaviours inside the iframe from
// interfering with main body.
iFrame.style.position = 'fixed';
// Push it out of viewport.
iFrame.style.top = '100vh';
iFrame.style.left = '100vw';
iFrame.classList.add('prefetchIframe');
iFrame.classList.add(`prefetchIframeNum-${index}`);
document.body.appendChild(iFrame);
},
);
}
};
/**
*
* @param embedConfig
*/
function sanity(embedConfig: EmbedConfig) {
if (embedConfig.thoughtSpotHost === undefined) {
throw new Error('ThoughtSpot host not provided');
}
if (embedConfig.authType === AuthType.TrustedAuthToken) {
if (!embedConfig.authEndpoint && typeof embedConfig.getAuthToken !== 'function') {
throw new Error('Trusted auth should provide either authEndpoint or getAuthToken');
}
}
}
/**
*
* @param embedConfig
*/
function backwardCompat(embedConfig: EmbedConfig): EmbedConfig {
const newConfig = { ...embedConfig };
if (embedConfig.noRedirect !== undefined && embedConfig.inPopup === undefined) {
newConfig.inPopup = embedConfig.noRedirect;
}
return newConfig;
}
type InitFlagStore = {
initPromise: Promise<ReturnType<typeof init>>;
isInitCalled: boolean;
initPromiseResolve: (value: ReturnType<typeof init>) => void;
}
const initFlagKey = 'initFlagKey';
export const createAndSetInitPromise = (): void => {
if (isWindowUndefined()) return;
const {
promise: initPromise,
resolve: initPromiseResolve,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
} = Promise.withResolvers<AuthEventEmitter>();
const initFlagStore: InitFlagStore = {
initPromise,
isInitCalled: false,
initPromiseResolve,
};
storeValueInWindow(initFlagKey, initFlagStore, {
// In case of diff imports the promise might be already set
ignoreIfAlreadyExists: true,
});
};
createAndSetInitPromise();
export const getInitPromise = ():
Promise<
ReturnType<typeof init>
> => getValueFromWindow<InitFlagStore>(initFlagKey)?.initPromise;
export const getIsInitCalled = (): boolean => !!getValueFromWindow(initFlagKey)?.isInitCalled;
/**
* Initializes the Visual Embed SDK globally and perform
* authentication if applicable. This function needs to be called before any ThoughtSpot
* component like Liveboard etc can be embedded. But need not wait for AuthEvent.SUCCESS
* to actually embed. That is handled internally.
* @param embedConfig The configuration object containing ThoughtSpot host,
* authentication mechanism and so on.
* @example
* ```js
* const authStatus = init({
* thoughtSpotHost: 'https://my.thoughtspot.cloud',
* authType: AuthType.None,
* });
* authStatus.on(AuthStatus.FAILURE, (reason) => { // do something here });
* ```
* @returns {@link AuthEventEmitter} event emitter which emits events on authentication success,
* failure and logout. See {@link AuthStatus}
* @version SDK: 1.0.0 | ThoughtSpot ts7.april.cl, 7.2.1
* @group Authentication / Init
*/
export const init = (embedConfig: EmbedConfig): AuthEventEmitter | null => {
if (isWindowUndefined()) return null;
sanity(embedConfig);
resetAllCachedServices();
embedConfig = setEmbedConfig(
backwardCompat({
...CONFIG_DEFAULTS,
...embedConfig,
thoughtSpotHost: getThoughtSpotHost(embedConfig),
}),
);
setGlobalLogLevelOverride(embedConfig.logLevel);
registerReportingObserver();
const authEE = new EventEmitter<AuthStatus | AuthEvent>();
setAuthEE(authEE);
handleAuth();
const { password, ...configToTrack } = getEmbedConfig();
uploadMixpanelEvent(MIXPANEL_EVENT.VISUAL_SDK_CALLED_INIT, {
...configToTrack,
usedCustomizationSheet: embedConfig.customizations?.style?.customCSSUrl != null,
usedCustomizationVariables: embedConfig.customizations?.style?.customCSS?.variables != null,
usedCustomizationRules:
embedConfig.customizations?.style?.customCSS?.rules_UNSTABLE != null,
usedCustomizationStrings: !!embedConfig.customizations?.content?.strings,
usedCustomizationIconSprite: !!embedConfig.customizations?.iconSpriteUrl,
});
if (getEmbedConfig().callPrefetch) {
prefetch(getEmbedConfig().thoughtSpotHost);
}
// Resolves the promise created in the initPromiseKey
getValueFromWindow<InitFlagStore>(initFlagKey).initPromiseResolve(authEE);
getValueFromWindow<InitFlagStore>(initFlagKey).isInitCalled = true;
return authEE as AuthEventEmitter;
};
/**
*
*/
export function disableAutoLogin(): void {
getEmbedConfig().autoLogin = false;
}
/**
* Logs out from ThoughtSpot. This also sets the autoLogin flag to false, to
* prevent the SDK from automatically logging in again.
*
* You can call the `init` method again to re login, if autoLogin is set to
* true in this second call it will be honored.
* @param doNotDisableAutoLogin This flag when passed will not disable autoLogin
* @returns Promise which resolves when logout completes.
* @version SDK: 1.10.1 | ThoughtSpot: 8.2.0.cl, 8.4.1-sw
* @group Global methods
*/
export const logout = (doNotDisableAutoLogin = false): Promise<boolean> => {
if (!doNotDisableAutoLogin) {
disableAutoLogin();
}
return _logout(getEmbedConfig()).then((isLoggedIn) => {
notifyLogout();
return isLoggedIn;
});
};
let renderQueue: Promise<any> = Promise.resolve();
/**
* Renders functions in a queue, resolves to next function only after the callback next
* is called
* @param fn The function being registered
*/
export const renderInQueue = (fn: (next?: (val?: any) => void) => Promise<any>): Promise<any> => {
const { queueMultiRenders = false } = getEmbedConfig();
if (queueMultiRenders) {
renderQueue = renderQueue.then(() => new Promise((res) => fn(res)));
return renderQueue;
}
// Sending an empty function to keep it consistent with the above usage.
return fn(() => {}); // eslint-disable-line @typescript-eslint/no-empty-function
};
/**
* Imports TML representation of the metadata objects into ThoughtSpot.
* @param data
* @returns imports TML data into ThoughtSpot
* @example
* ```js
* executeTML({
* //Array of metadata Tmls in string format
* metadata_tmls: [
* "'\''{\"guid\":\"9bd202f5-d431-44bf-9a07-b4f7be372125\",
* \"liveboard\":{\"name\":\"Parameters Liveboard\"}}'\''"
* ],
* import_policy: 'PARTIAL', // Specifies the import policy for the TML import.
* create_new: false, // If selected, creates TML objects with new GUIDs.
* }).then(result => {
* console.log(result);
* }).catch(error => {
* console.error(error);
* });
*```
* @version SDK: 1.23.0 | ThoughtSpot: 9.4.0.cl
* @group Global methods
*/
export const executeTML = async (data: executeTMLInput): Promise<any> => {
try {
sanity(getEmbedConfig());
} catch (err) {
return Promise.reject(err);
}
const { thoughtSpotHost, authType } = getEmbedConfig();
const headers: Record<string, string | undefined> = {
'Content-Type': 'application/json',
'x-requested-by': 'ThoughtSpot',
};
const payload = {
metadata_tmls: data.metadata_tmls,
import_policy: data.import_policy || 'PARTIAL',
create_new: data.create_new || false,
};
return tokenizedFetch(`${thoughtSpotHost}${EndPoints.EXECUTE_TML}`, {
method: 'POST',
headers,
body: JSON.stringify(payload),
credentials: 'include',
})
.then((response) => {
if (!response.ok) {
throw new Error(
`Failed to import TML data: ${response.status} - ${response.statusText}`,
);
}
return response.json();
})
.catch((error) => {
throw error;
});
};
/**
* Exports TML representation of the metadata objects from ThoughtSpot in JSON or YAML
* format.
* @param data
* @returns exports TML data
* @example
* ```js
* exportTML({
* metadata: [
* {
* type: "LIVEBOARD", //Metadata Type
* identifier: "9bd202f5-d431-44bf-9a07-b4f7be372125" //Metadata Id
* }
* ],
* export_associated: false,//indicates whether to export associated metadata objects
* export_fqn: false, //Adds FQNs of the referenced objects.For example, if you are
* //exporting a Liveboard and its associated objects, the API
* //returns the Liveboard TML data with the FQNs of the referenced
* //worksheet. If the exported TML data includes FQNs, you don't need
* //to manually add FQNs of the referenced objects during TML import.
* edoc_format: "JSON" //It takes JSON or YAML value
* }).then(result => {
* console.log(result);
* }).catch(error => {
* console.error(error);
* });
* ```
* @version SDK: 1.23.0 | ThoughtSpot: 9.4.0.cl
* @group Global methods
*/
export const exportTML = async (data: exportTMLInput): Promise<any> => {
const { thoughtSpotHost, authType } = getEmbedConfig();
try {
sanity(getEmbedConfig());
} catch (err) {
return Promise.reject(err);
}
const payload = {
metadata: data.metadata,
export_associated: data.export_associated || false,
export_fqn: data.export_fqn || false,
edoc_format: data.edoc_format || 'YAML',
};
const headers: Record<string, string | undefined> = {
'Content-Type': 'application/json',
'x-requested-by': 'ThoughtSpot',
};
return tokenizedFetch(`${thoughtSpotHost}${EndPoints.EXPORT_TML}`, {
method: 'POST',
headers,
body: JSON.stringify(payload),
credentials: 'include',
})
.then((response) => {
if (!response.ok) {
throw new Error(
`Failed to export TML: ${response.status} - ${response.statusText}`,
);
}
return response.json();
})
.catch((error) => {
throw error;
});
};
// For testing purposes only
/**
*
*/
export function reset(): void {
setEmbedConfig({} as any);
setAuthEE(null);
authPromise = null;
}
/**
* Reloads the ThoughtSpot iframe.
* @param iFrame
* @group Global methods
* @version SDK: 1.43.1
*/
export const reloadIframe = (iFrame: HTMLIFrameElement) => {
if (!iFrame) {
logger.warn('reloadIframe called with no iFrame element.');
return;
}
reload(iFrame);
};