@thoughtspot/visual-embed-sdk
Version:
ThoughtSpot Embed SDK
293 lines (259 loc) • 10.6 kB
text/typescript
import { ContextType, HostEvent } from '../../types';
import { processTrigger as processTriggerService } from '../../utils/processTrigger';
import { getEmbedConfig } from '../embedConfig';
import {
isValidUpdateFiltersPayload,
isValidDrillDownPayload,
throwUpdateFiltersValidationError,
throwDrillDownValidationError,
} from './utils';
import {
UIPassthroughArrayResponse,
UIPassthroughEvent,
HostEventRequest,
HostEventResponse,
UIPassthroughRequest,
UIPassthroughResponse,
TriggerPayload,
TriggerResponse,
} from './contracts';
/**
* Maps HostEvent to its corresponding UIPassthroughEvent.
* Includes both custom-handler events (Pin, SaveAnswer, UpdateFilters, DrillDown)
* and getter events (GetAnswerSession, GetFilters, etc.) that use getDataWithPassthroughFallback.
*/
const PASSTHROUGH_MAP: Partial<Record<HostEvent, UIPassthroughEvent>> = {
// Custom handlers (setters with special logic)
[HostEvent.Pin]: UIPassthroughEvent.PinAnswerToLiveboard,
[HostEvent.SaveAnswer]: UIPassthroughEvent.SaveAnswer,
[HostEvent.UpdateFilters]: UIPassthroughEvent.UpdateFilters,
[HostEvent.DrillDown]: UIPassthroughEvent.Drilldown,
// Getters (use getDataWithPassthroughFallback)
[HostEvent.GetAnswerSession]: UIPassthroughEvent.GetAnswerSession,
[HostEvent.GetFilters]: UIPassthroughEvent.GetFilters,
[HostEvent.GetIframeUrl]: UIPassthroughEvent.GetIframeUrl,
[HostEvent.GetParameters]: UIPassthroughEvent.GetParameters,
[HostEvent.GetTML]: UIPassthroughEvent.GetTML,
[HostEvent.GetTabs]: UIPassthroughEvent.GetTabs,
[HostEvent.getExportRequestForCurrentPinboard]: UIPassthroughEvent.GetExportRequestForCurrentPinboard,
};
export class HostEventClient {
iFrame: HTMLIFrameElement;
/** Cached list of available UI passthrough keys from the embedded app */
private availablePassthroughKeysCache: string[] | null = null;
/** Host events with custom handlers
* (setters or special logic) -
* bound to instance for protected method access */
private readonly customHandlers: Partial<
Record<HostEvent, (payload: any, context?: ContextType) => Promise<any>>
>;
constructor(iFrame?: HTMLIFrameElement) {
this.iFrame = iFrame;
this.customHandlers = {
[HostEvent.Pin]: (p, c) => this.handlePinEvent(p, c),
[HostEvent.SaveAnswer]: (p, c) => this.handleSaveAnswerEvent(p, c),
[HostEvent.UpdateFilters]: (p, c) => this.handleUpdateFiltersEvent(p, c),
[HostEvent.DrillDown]: (p, c) => this.handleDrillDownEvent(p, c),
};
}
/**
* A wrapper over process trigger to
* @param {HostEvent} message Host event to send
* @param {any} data Data to send with the host event
* @returns {Promise<any>} - the response from the process trigger
*/
protected async processTrigger(message: HostEvent, data: any, context?: ContextType): Promise<any> {
if (!this.iFrame) {
throw new Error('Iframe element is not set');
}
const thoughtspotHost = getEmbedConfig().thoughtSpotHost;
return processTriggerService(
this.iFrame,
message,
thoughtspotHost,
data,
context,
);
}
public async handleHostEventWithParam<UIPassthroughEventT extends UIPassthroughEvent>(
apiName: UIPassthroughEventT,
parameters: UIPassthroughRequest<UIPassthroughEventT>,
context?: ContextType,
): Promise<UIPassthroughResponse<UIPassthroughEventT>> {
const response = (await this.triggerUIPassthroughApi(apiName, parameters, context))
?.find?.((r) => r.error || r.value);
if (!response) {
const error = `No answer found${parameters.vizId ? ` for vizId: ${parameters.vizId}` : ''}.`;
throw { error };
}
const errors = response.error
|| (response.value as any)?.errors
|| (response.value as any)?.error;
if (errors) {
const message = typeof errors === 'string' ? errors : JSON.stringify(errors);
throw { error: message };
}
return { ...response.value };
}
public async hostEventFallback(
hostEvent: HostEvent,
data: any,
context?: ContextType,
): Promise<any> {
return this.processTrigger(hostEvent, data, context);
}
/**
* For getter events that return data. Tries UI passthrough first;
* if the app doesn't support it (no response data), falls back to
* the legacy host event channel. Real errors are thrown as-is.
*/
private async getDataWithPassthroughFallback<UIPassthroughEventT extends UIPassthroughEvent>(
passthroughEvent: UIPassthroughEventT,
hostEvent: HostEvent,
payload: any,
context?: ContextType,
): Promise<UIPassthroughResponse<UIPassthroughEventT>> {
const response = await this.triggerUIPassthroughApi(
passthroughEvent, payload || {}, context,
);
const matched = response?.find?.((r) => r.error || r.value);
if (!matched) {
return this.hostEventFallback(hostEvent, payload, context);
}
const errors = matched.error
|| (matched.value as any)?.errors
|| (matched.value as any)?.error;
if (errors) {
const message = typeof errors === 'string' ? errors : JSON.stringify(errors);
throw new Error(message);
}
return { ...matched.value };
}
/**
* Setter for the iframe element used for host events
* @param {HTMLIFrameElement} iFrame - the iframe element to set
*/
public setIframeElement(iFrame: HTMLIFrameElement): void {
this.iFrame = iFrame;
}
/**
* Fetches the list of available UI passthrough keys from the embedded app.
* Result is cached for the session. Returns empty array on failure.
*/
private async getAvailableUIPassthroughKeys(context?: ContextType): Promise<string[]> {
if (this.availablePassthroughKeysCache !== null) {
return this.availablePassthroughKeysCache;
}
try {
const response = await this.triggerUIPassthroughApi(
UIPassthroughEvent.GetAvailableUIPassthroughs, {}, context,
);
const matched = response?.find?.((r) => r.value && !r.error);
const keys = matched?.value?.keys;
this.availablePassthroughKeysCache = Array.isArray(keys) ? keys : [];
return this.availablePassthroughKeysCache;
} catch {
return [];
}
}
public async triggerUIPassthroughApi<UIPassthroughEventT extends UIPassthroughEvent>(
apiName: UIPassthroughEventT,
parameters: UIPassthroughRequest<UIPassthroughEventT>,
context?: ContextType,
): Promise<UIPassthroughArrayResponse<UIPassthroughEventT>> {
const res = await this.processTrigger(HostEvent.UIPassthrough, {
type: apiName,
parameters,
}, context);
return res;
}
protected async handlePinEvent(
payload: HostEventRequest<HostEvent.Pin>,
context?: ContextType,
): Promise<HostEventResponse<HostEvent.Pin, ContextType>> {
if (!payload || !('newVizName' in payload)) {
return this.hostEventFallback(HostEvent.Pin, payload, context);
}
const formattedPayload = {
...payload,
pinboardId: payload.liveboardId ?? (payload as any).pinboardId,
newPinboardName: payload.newLiveboardName ?? (payload as any).newPinboardName,
};
const data = await this.handleHostEventWithParam(
UIPassthroughEvent.PinAnswerToLiveboard, formattedPayload,
context as ContextType,
);
return {
...data,
liveboardId: (data as any).pinboardId,
};
}
protected async handleSaveAnswerEvent(
payload: HostEventRequest<HostEvent.SaveAnswer>,
context?: ContextType,
): Promise<any> {
if (!payload || !('name' in payload) || !('description' in payload)) {
// Save is the fallback for SaveAnswer
return this.hostEventFallback(HostEvent.Save, payload, context);
}
const data = await this.handleHostEventWithParam(
UIPassthroughEvent.SaveAnswer, payload,
context as ContextType,
);
return {
...data,
answerId: data?.saveResponse?.data?.Answer__save?.answer?.id,
};
}
protected handleUpdateFiltersEvent(
payload: HostEventRequest<HostEvent.UpdateFilters>,
context?: ContextType,
): Promise<any> {
if (!isValidUpdateFiltersPayload(payload)) {
throwUpdateFiltersValidationError();
}
return this.handleHostEventWithParam(UIPassthroughEvent.UpdateFilters, payload, context as ContextType);
}
protected handleDrillDownEvent(
payload: HostEventRequest<HostEvent.DrillDown>,
context?: ContextType,
): Promise<any> {
if (!isValidDrillDownPayload(payload)) {
throwDrillDownValidationError();
}
return this.handleHostEventWithParam(UIPassthroughEvent.Drilldown, payload, context as ContextType);
}
/**
* Dispatches a host event using the appropriate channel:
* 1. If the embedded app supports UI passthrough for this event, use it (custom handler or getter).
* 2. Otherwise fall back to the legacy host event channel.
*
* @param hostEvent - The host event to trigger
* @param payload - Optional payload for the event
* @param context - Optional context (e.g. vizId) for scoped operations
*/
public async triggerHostEvent<
HostEventT extends HostEvent,
PayloadT,
ContextT extends ContextType,
>(
hostEvent: HostEventT,
payload?: TriggerPayload<PayloadT, HostEventT>,
context?: ContextT,
): Promise<TriggerResponse<PayloadT, HostEventT, ContextType>> {
const customHandler = this.customHandlers[hostEvent];
const passthroughEvent = PASSTHROUGH_MAP[hostEvent];
// If embedded app supports passthrough but not this event, use legacy channel
const keys = passthroughEvent ? await this.getAvailableUIPassthroughKeys(context as ContextType) : [];
if (passthroughEvent && keys.length > 0 && !keys.includes(passthroughEvent)) {
return this.hostEventFallback(hostEvent, payload, context) as any;
}
// Custom handler (setters) > getter passthrough > legacy fallback
return (customHandler
? customHandler(payload, context as ContextType)
: passthroughEvent
? this.getDataWithPassthroughFallback(passthroughEvent, hostEvent, payload, context as ContextType)
: this.hostEventFallback(hostEvent, payload, context)
) as any;
}
}