From f07c5fcf2accd5a25c400af56046324bffcb1f16 Mon Sep 17 00:00:00 2001 From: sastaachar Date: Fri, 3 Jan 2025 19:39:50 +0530 Subject: [PATCH] init --- src/embed/hostEventClient/contracts.ts | 116 +++++++++++++ .../hostEventClient/host-event-client.spec.ts | 164 ++++++++++++++++++ .../hostEventClient/host-event-client.ts | 72 ++++++++ src/embed/ts-embed.ts | 18 +- src/types.ts | 10 ++ 5 files changed, 376 insertions(+), 4 deletions(-) create mode 100644 src/embed/hostEventClient/contracts.ts create mode 100644 src/embed/hostEventClient/host-event-client.spec.ts create mode 100644 src/embed/hostEventClient/host-event-client.ts diff --git a/src/embed/hostEventClient/contracts.ts b/src/embed/hostEventClient/contracts.ts new file mode 100644 index 00000000..783bee7e --- /dev/null +++ b/src/embed/hostEventClient/contracts.ts @@ -0,0 +1,116 @@ +import { HostEvent } from '../../types'; + +export enum UiPassthroughEvent { + addVizToPinboard = 'addVizToPinboard', + saveAnswer = 'saveAnswer', + getA3AnalysisColumns = 'getA3AnalysisColumns', + getPinboardTabInfo = 'getPinboardTabInfo', + getDiscoverabilityStatus = 'getDiscoverabilityStatus', + getAvailableUiPassthroughs = 'getAvailableUiPassthroughs', + getAnswerPageConfig = 'getAnswerPageConfig', + getPinboardPageConfig = 'getPinboardPageConfig', + UiPassthroughEventNotFound = 'UiPassthroughEventNotFound', +} + +export type UiPassthroughContractBase = { + [UiPassthroughEvent.addVizToPinboard]: { + request: { + vizId?: string; + newVizName: string; + newVizDescription?: string; + pinboardId?: string; + tabId?: string; + newPinboardName?: string; + newTabName?: string; + pinFromStore?: boolean; + }; + response: { + pinboardId: string; + tabId: string; + vizId: string; + errors?: any; + }; + }; + [UiPassthroughEvent.saveAnswer]: { + request: { + name: string; + description: string; + vizId: string; + isDiscoverable?: boolean; + }; + response: { + answerId: string, + errors?: any; + }; + }; + [UiPassthroughEvent.getA3AnalysisColumns]: { + request: { + vizId?: string; + }; + response: { + data?: any; + errors?: any; + }; + }; + [UiPassthroughEvent.getPinboardTabInfo]: { + request: any; + response: any; + }; + [UiPassthroughEvent.getDiscoverabilityStatus]: { + request: any; + response: { + shouldShowDiscoverability: boolean; + isDiscoverabilityCheckboxUnselectedPerOrg: boolean; + }; + }; + [UiPassthroughEvent.getAvailableUiPassthroughs]: { + request: any; + response: { + keys: string[]; + }; + }; + [UiPassthroughEvent.getAnswerPageConfig]: { + request: { + vizId?: string; + }; + response: any; + }; + [UiPassthroughEvent.getPinboardPageConfig]: { + request: any; + response: any; + }; + [UiPassthroughEvent.UiPassthroughEventNotFound]: { + request: any; + response: any; + }; +}; + +export type UiPassthroughRequest = UiPassthroughContractBase[T]['request']; +export type UiPassthroughResponse = UiPassthroughContractBase[T]['response']; + +export type UiPassthroughArrayResponse = + Promise; + error?: any; + }>> + +export type EmbedApiHostEventMapping = { + [HostEvent.Pin]: UiPassthroughEvent.addVizToPinboard; + [HostEvent.SaveAnswer]: UiPassthroughEvent.saveAnswer; + 'hostEventNotMapped': UiPassthroughEvent.UiPassthroughEventNotFound; +} + +export type FlattenType = T extends infer R ? { [K in keyof R]: R[K] } : never; + +export type HostEventRequest = + HostEventT extends keyof EmbedApiHostEventMapping ? + FlattenType> : any; + +export type HostEventResponse = + HostEventT extends keyof EmbedApiHostEventMapping ? + { + value?: UiPassthroughRequest + error?: any; + } + : any; diff --git a/src/embed/hostEventClient/host-event-client.spec.ts b/src/embed/hostEventClient/host-event-client.spec.ts new file mode 100644 index 00000000..fdfc1b9a --- /dev/null +++ b/src/embed/hostEventClient/host-event-client.spec.ts @@ -0,0 +1,164 @@ +import { getIFrameEl } from '../../test/test-utils'; +import { HostEvent } from '../../types'; +import { processTrigger } from '../../utils/processTrigger'; +import { + UiPassthroughEvent, + UiPassthroughRequest, + UiPassthroughArrayResponse, + HostEventRequest, +} from './contracts'; +import { HostEventClient } from './host-event-client'; + +jest.mock('../../utils/processTrigger'); + +const mockProcessTrigger = processTrigger as jest.Mock; + +const createHostEventClient = () => { + const mockIframe = document.createElement('iframe'); + const mockThoughtSpotHost = 'http://localhost'; + const client = new HostEventClient(mockIframe, mockThoughtSpotHost); + return { client, mockIframe, mockThoughtSpotHost }; +}; + +describe('HostEventClient', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('executeUiPassthroughApi', () => { + it('should call processTrigger with correct parameters and return response', async () => { + const { client, mockIframe, mockThoughtSpotHost } = createHostEventClient(); + + const apiName = UiPassthroughEvent.addVizToPinboard; + const parameters: UiPassthroughRequest = { + newVizName: 'testViz', + }; + const triggerResponse = Promise.resolve([ + { value: { pinboardId: 'testPinboard', tabId: 'testTab', vizId: 'testVizId' } }, + ]); + + mockProcessTrigger.mockResolvedValue(triggerResponse); + + const result = await client.executeUiPassthroughApi(getIFrameEl(), apiName, parameters); + + expect(mockProcessTrigger).toHaveBeenCalledWith( + mockIframe, + HostEvent.UiPassthrough, + mockThoughtSpotHost, + { + type: apiName, + parameters, + }, + ); + expect(result).toEqual(await triggerResponse); + }); + }); + + describe('handleUiPassthroughForHostEvent', () => { + it('should return the value from the first valid response', async () => { + const { client } = createHostEventClient(); + const apiName = UiPassthroughEvent.addVizToPinboard; + const parameters: UiPassthroughRequest = { + newVizName: 'testViz', + }; + const triggerResponse = Promise.resolve([ + { value: { pinboardId: 'testPinboard', tabId: 'testTab', vizId: 'testVizId' } }, + ]); + mockProcessTrigger.mockResolvedValue(triggerResponse); + + const result = await client.handleUiPassthroughForHostEvent(getIFrameEl(), + apiName, parameters); + + expect(result).toEqual({ + pinboardId: 'testPinboard', + tabId: 'testTab', + vizId: 'testVizId', + }); + }); + + it('should throw an error if no valid response is found', async () => { + const { client } = createHostEventClient(); + const apiName = UiPassthroughEvent.addVizToPinboard; + const parameters: UiPassthroughRequest = { + newVizName: 'testViz', + }; + const triggerResponse: UiPassthroughArrayResponse = Promise.resolve([]); + mockProcessTrigger.mockResolvedValue(triggerResponse); + + await expect(client.handleUiPassthroughForHostEvent(getIFrameEl(), apiName, parameters)) + .rejects.toThrow('No answer found'); + }); + + it('should throw an error if the response contains errors', async () => { + const { client } = createHostEventClient(); + const apiName = UiPassthroughEvent.addVizToPinboard; + const parameters: UiPassthroughRequest = { + newVizName: 'testViz', + }; + const triggerResponse: UiPassthroughArrayResponse = Promise.resolve([ + { error: 'Some error' }, + ]); + mockProcessTrigger.mockResolvedValue(triggerResponse); + + await expect(client.handleUiPassthroughForHostEvent(getIFrameEl(), apiName, parameters)) + .rejects.toThrow(JSON.stringify({ errors: 'Some error' })); + }); + }); + + describe('executeHostEvent', () => { + it('should call handleUiPassthroughForHostEvent for Pin event', async () => { + const { client } = createHostEventClient(); + const hostEvent = HostEvent.Pin; + const payload: HostEventRequest = { + newVizName: 'testViz', + }; + const mockResponse = { + pinboardId: 'testPinboard', + tabId: 'testTab', + vizId: 'testVizId', + }; + jest.spyOn(client, 'handleUiPassthroughForHostEvent').mockResolvedValue(mockResponse); + + const result = await client.executeHostEvent(getIFrameEl(), hostEvent, payload); + + expect(client.handleUiPassthroughForHostEvent).toHaveBeenCalledWith( + UiPassthroughEvent.addVizToPinboard, + payload, + ); + expect(result).toEqual(mockResponse); + }); + + it('should call handleUiPassthroughForHostEvent for SaveAnswer event', async () => { + const { client } = createHostEventClient(); + const hostEvent = HostEvent.SaveAnswer; + const payload: HostEventRequest = { + name: 'Test Answer', + description: 'Test Description', + vizId: 'testVizId', + }; + const mockResponse = { answerId: 'testAnswerId' }; + jest.spyOn(client, 'handleUiPassthroughForHostEvent').mockResolvedValue(mockResponse); + + const result = await client.executeHostEvent(getIFrameEl(), hostEvent, payload); + + expect(client.handleUiPassthroughForHostEvent).toHaveBeenCalledWith( + UiPassthroughEvent.saveAnswer, + payload, + ); + expect(result).toEqual(mockResponse); + }); + + it('should call hostEventFallback for unmapped events', async () => { + const { client } = createHostEventClient(); + const hostEvent = 'testEvent' as HostEvent; + const payload = { data: 'testData' }; + const mockResponse = { fallbackResponse: 'data' }; + jest.spyOn(client, 'hostEventFallback').mockResolvedValue(mockResponse); + + const result = await client.executeHostEvent(getIFrameEl(), hostEvent, payload); + + expect(client.hostEventFallback).toHaveBeenCalledWith(hostEvent, payload); + expect(result).toEqual(mockResponse); + }); + }); +}); diff --git a/src/embed/hostEventClient/host-event-client.ts b/src/embed/hostEventClient/host-event-client.ts new file mode 100644 index 00000000..2f8fdb6d --- /dev/null +++ b/src/embed/hostEventClient/host-event-client.ts @@ -0,0 +1,72 @@ +import { HostEvent } from '../../types'; +import { processTrigger } from '../../utils/processTrigger'; +import { + UiPassthroughArrayResponse, + UiPassthroughEvent, HostEventRequest, HostEventResponse, + UiPassthroughRequest, +} from './contracts'; + +export class HostEventClient { + thoughtSpotHost: string; + + constructor(iFrame: HTMLIFrameElement, thoughtSpotHost: string) { + this.thoughtSpotHost = thoughtSpotHost; + } + + async executeUiPassthroughApi(iFrame: HTMLIFrameElement, apiName: UiPassthroughEvent, + parameters: UiPassthroughRequest): + UiPassthroughArrayResponse { + const res = await processTrigger(iFrame, HostEvent.UiPassthrough, this.thoughtSpotHost, { + type: apiName, + parameters, + }); + + return res; + } + + async handleUiPassthroughForHostEvent( + iFrame: HTMLIFrameElement, + apiName: UiPassthroughEvent, + parameters: UiPassthroughRequest, + ): + UiPassthroughArrayResponse { + const response = (await this.executeUiPassthroughApi(iFrame, apiName, parameters)) + ?.filter?.((r) => r.error || r.value)[0]; + + if (!response) { + throw new Error('No answer found'); + } + + const errors = response.error || (response.value as any)?.errors; + if (errors) { + throw new Error(JSON.stringify({ errors: response.error })); + } + + return { ...response.value }; + } + + async hostEventFallback( + iFrame: HTMLIFrameElement, hostEvent: HostEvent, data: any, + ): Promise { + return processTrigger(iFrame, hostEvent, this.thoughtSpotHost, data); + } + + async executeHostEvent( + iFrame:HTMLIFrameElement, hostEvent: HostEvent, payload?: HostEventRequest, + ): + Promise> { + if (hostEvent === HostEvent.Pin && typeof payload === 'object') { + return this.handleUiPassthroughForHostEvent( + iFrame, + UiPassthroughEvent.addVizToPinboard, payload, + ); + } + + if (hostEvent === HostEvent.SaveAnswer && typeof payload === 'object') { + return this.handleUiPassthroughForHostEvent(iFrame, + UiPassthroughEvent.saveAnswer, payload); + } + + return this.hostEventFallback(iFrame, hostEvent, payload); + } +} diff --git a/src/embed/ts-embed.ts b/src/embed/ts-embed.ts index f7398de1..70adbe1b 100644 --- a/src/embed/ts-embed.ts +++ b/src/embed/ts-embed.ts @@ -9,6 +9,7 @@ import isEqual from 'lodash/isEqual'; import isEmpty from 'lodash/isEmpty'; import isObject from 'lodash/isObject'; +import { HostEventRequest, HostEventResponse } from './hostEventClient/contracts'; import { logger } from '../utils/logger'; import { getAuthenticationToken } from '../authToken'; import { AnswerService } from '../utils/graphql/answerService/answerService'; @@ -62,6 +63,7 @@ import { import { AuthFailureType } from '../auth'; import { getEmbedConfig } from './embedConfig'; import { ERROR_MESSAGE } from '../errors'; +import { HostEventClient } from './hostEventClient/host-event-client'; const { version } = pkgInfo; @@ -121,8 +123,8 @@ export class TsEmbed { protected thoughtSpotHost: string; /* - * This is the base to access ThoughtSpot V2. - */ + * This is the base to access ThoughtSpot V2. + */ protected thoughtSpotV2Base: string; /** @@ -159,6 +161,8 @@ export class TsEmbed { private resizeObserver: ResizeObserver; + protected hostEventClient: HostEventClient; + constructor(domSelector: DOMSelector, viewConfig?: ViewConfig) { this.el = getDOMNode(domSelector); // TODO: handle error @@ -180,6 +184,7 @@ export class TsEmbed { uploadMixpanelEvent(MIXPANEL_EVENT.VISUAL_SDK_EMBED_CREATE, { ...viewConfig, }); + this.hostEventClient = new HostEventClient(this.iFrame, this.embedConfig.thoughtSpotHost); } /** @@ -986,7 +991,11 @@ export class TsEmbed { * @param messageType The event type * @param data The payload to send with the message */ - public trigger(messageType: HostEvent, data: any = {}): Promise { + public trigger( + messageType: HostEventT, + data?: HostEventRequest, + ): + Promise> { uploadMixpanelEvent(`${MIXPANEL_EVENT.VISUAL_SDK_TRIGGER}-${messageType}`); if (!this.isRendered) { @@ -998,7 +1007,8 @@ export class TsEmbed { this.handleError('Host event type is undefined'); return null; } - return processTrigger(this.iFrame, messageType, this.thoughtSpotHost, data); + + return this.hostEventClient.executeHostEvent(this.iFrame, messageType, data); } /** diff --git a/src/types.ts b/src/types.ts index 6322f9d1..be4b8e58 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3199,6 +3199,16 @@ export enum HostEvent { * @version SDK: 1.36.0 | Thoughtspot: 10.6.0.cl */ UpdatePersonalisedView = 'UpdatePersonalisedView', + /** + * Triggers the action to get the current view of the liveboard + * @version SDK: 1.34.0 | Thoughtspot: 10.6.0.cl + */ + SaveAnswer = 'saveAnswer', + /** + * EmbedApi + * @hidden + */ + UiPassthrough = 'UiPassthrough', } /**