From 63ac06c680bdce1d4de5a8732c5608d7b0f91e2d Mon Sep 17 00:00:00 2001 From: Prashant Patil Date: Thu, 13 Aug 2026 10:42:39 +0530 Subject: [PATCH 1/3] SCAL-325540 Events typings support TS --- package.json | 8 + .../__snapshots__/contracts.spec.ts.snap | 96 +++++ src/contracts/contracts.spec.ts | 171 ++++++++ src/contracts/embed-event-payloads.ts | 51 +++ src/contracts/host-event-contracts.ts | 380 ++++++++++++++++++ src/contracts/host-event-emitters.ts | 59 +++ src/contracts/index.ts | 46 +++ .../hostEventClient/host-event-client.ts | 23 +- src/embed/liveboard.ts | 2 +- src/embed/ts-embed.ts | 20 +- src/utils/processTrigger.ts | 86 ++-- src/utils/transport/iframe-transport.ts | 122 ++++++ 12 files changed, 993 insertions(+), 71 deletions(-) create mode 100644 src/contracts/__snapshots__/contracts.spec.ts.snap create mode 100644 src/contracts/contracts.spec.ts create mode 100644 src/contracts/embed-event-payloads.ts create mode 100644 src/contracts/host-event-contracts.ts create mode 100644 src/contracts/host-event-emitters.ts create mode 100644 src/contracts/index.ts create mode 100644 src/utils/transport/iframe-transport.ts diff --git a/package.json b/package.json index cb04968ac..9d39ab8e3 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,11 @@ "require": "./cjs/src/react/all-types-export.js", "types": "./lib/src/react/all-types-export.d.ts" }, + "./contracts": { + "import": "./lib/src/contracts/index.js", + "require": "./cjs/src/contracts/index.js", + "types": "./lib/src/contracts/index.d.ts" + }, "./lib/src/react": { "import": "./lib/src/react/all-types-export.js", "require": "./cjs/src/react/all-types-export.js", @@ -32,6 +37,9 @@ "*": { "react": [ "./lib/src/react/all-types-export.d.ts" + ], + "contracts": [ + "./lib/src/contracts/index.d.ts" ] } }, diff --git a/src/contracts/__snapshots__/contracts.spec.ts.snap b/src/contracts/__snapshots__/contracts.spec.ts.snap new file mode 100644 index 000000000..8b7ddb404 --- /dev/null +++ b/src/contracts/__snapshots__/contracts.spec.ts.snap @@ -0,0 +1,96 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`event contracts (drift guardrails) UI passthrough wire values are stable (additive-only) 1`] = ` +[ + "addVizToPinboard", + "drillDown", + "getAnswerPageConfig", + "getAnswerSession", + "getAvailableUiPassthroughs", + "getDiscoverabilityStatus", + "getExportRequestForCurrentPinboard", + "getFilters", + "getGroups", + "getIframeUrl", + "getParameters", + "getPinboardPageConfig", + "getTML", + "getTabs", + "getUnsavedAnswerTML", + "saveAnswer", + "updateFilters", +] +`; + +exports[`event contracts (drift guardrails) embed event enum wire values referenced by typed payloads are stable 1`] = ` +[ + "EmbedListenerReady", + "authInit", +] +`; + +exports[`event contracts (drift guardrails) typed host event wire values are stable (additive-only) 1`] = ` +[ + "AIHighlights", + "AskSage", + "AskSpotter", + "CloseSpotterShareConversation", + "CloseSpotterVizPanel", + "DeleteLastPrompt", + "EditLastPrompt", + "ExitSpotterSharedConversation", + "InitSpotterVizConversation", + "Navigate", + "OpenSpotterVizPanel", + "PinSpotterConversation", + "PreviewSpotterData", + "ResetLiveboardPersonalisedView", + "ResetSpotterConversation", + "SelectPersonalisedView", + "SetActiveTab", + "SetPinboardHiddenTabs", + "SetPinboardVisibleTabs", + "SetPinboardVisibleVizs", + "ShareSpotterConversation", + "SpotterSearch", + "SpotterVizSendUserMessage", + "UnpinSpotterConversation", + "UpdateCrossFilter", + "UpdateParameters", + "UpdatePersonalisedView", + "UpdateRuntimeFilters", + "addColumns", + "answerChartSwitcher", + "createMonitor", + "downloadAsCSV", + "downloadAsPdf", + "downloadAsPng", + "downloadAsXLSX", + "edit", + "editTSL", + "embedDocument", + "explore", + "exportTSL", + "manage-pipeline", + "manageMonitor", + "onDeleteAnswer", + "openFilter", + "openParameter", + "present", + "refreshLiveboardBrowserCache", + "removeColumn", + "resetSearch", + "save", + "schedule-list", + "search", + "sendTestScheduleEmail", + "share", + "showUnderlyingData", + "spotIQAnalyze", + "subscription", + "sync-to-other-apps", + "sync-to-sheets", + "updateFilters", + "updateTSL", +] +`; diff --git a/src/contracts/contracts.spec.ts b/src/contracts/contracts.spec.ts new file mode 100644 index 000000000..d4d603c10 --- /dev/null +++ b/src/contracts/contracts.spec.ts @@ -0,0 +1,171 @@ +/** + * Contract drift guardrails. + * + * These tests are the CI gate for the additive-only contract policy: + * - The snapshots below record which events have TYPED contracts. Removing + * an event from the typed maps (or renaming its wire value) fails the + * snapshot and must be treated as a breaking change, not a refactor. + * - Type-level assertions verify the request/response resolution helpers + * keep resolving typed events to their contracts and unknown events to + * `any` (backward compatibility). + * + * When a snapshot fails: if you ADDED events, update the snapshot. If an + * existing entry disappeared or changed value, stop — that breaks published + * SDK consumers and the host runtime validation derived from these + * contracts. + */ +import { HostEvent, EmbedEvent } from '../types'; +import { UIPassthroughEvent } from '../embed/hostEventClient/contracts'; +import type { + HostEventRequest, + HostEventResponse, + NavigateRequest, + SetActiveTabRequest, +} from './host-event-contracts'; +import type { RuntimeFilter } from '../types'; +import { createHostEventEmitters } from './host-event-emitters'; + +// Events with explicitly typed contracts in HostEventContractExtension. +// Keep in sync with the interface — this list is what the snapshot locks. +const TYPED_HOST_EVENTS: HostEvent[] = [ + // Filters and parameters + HostEvent.UpdateRuntimeFilters, + HostEvent.UpdateParameters, + HostEvent.UpdateFilters, + HostEvent.UpdateCrossFilter, + HostEvent.OpenFilter, + HostEvent.OpenParameter, + // Tabs and vizs + HostEvent.SetVisibleVizs, + HostEvent.SetVisibleTabs, + HostEvent.SetHiddenTabs, + HostEvent.SetActiveTab, + // Navigation + HostEvent.Navigate, + // Search and columns + HostEvent.Search, + HostEvent.ResetSearch, + HostEvent.AddColumns, + HostEvent.RemoveColumn, + // Viz-scoped actions + HostEvent.Edit, + HostEvent.Save, + HostEvent.Delete, + HostEvent.Share, + HostEvent.Present, + HostEvent.CopyLink, + HostEvent.ExportTML, + HostEvent.EditTML, + HostEvent.UpdateTML, + HostEvent.SchedulesList, + HostEvent.Schedule, + HostEvent.SpotIQAnalyze, + HostEvent.ShowUnderlyingData, + HostEvent.CreateMonitor, + HostEvent.ManageMonitor, + HostEvent.SyncToSheets, + HostEvent.SyncToOtherApps, + HostEvent.ManagePipelines, + HostEvent.DownloadAsPng, + HostEvent.DownloadAsCsv, + HostEvent.DownloadAsXlsx, + HostEvent.DownloadAsPdf, + HostEvent.Explore, + HostEvent.AskSage, + HostEvent.AskSpotter, + HostEvent.AnswerChartSwitcher, + // Liveboard + HostEvent.UpdatePersonalisedView, + HostEvent.SelectPersonalizedView, + HostEvent.ResetLiveboardPersonalisedView, + HostEvent.AIHighlights, + HostEvent.SendTestScheduleEmail, + HostEvent.RefreshLiveboardBrowserCache, + // Spotter + HostEvent.SpotterSearch, + HostEvent.ResetSpotterConversation, + HostEvent.ShareSpotterConversation, + HostEvent.CloseSpotterShareConversation, + HostEvent.ExitSpotterSharedConversation, + HostEvent.PinSpotterConversation, + HostEvent.UnpinSpotterConversation, + HostEvent.EditLastPrompt, + HostEvent.DeleteLastPrompt, + HostEvent.PreviewSpotterData, + HostEvent.SpotterVizSendUserMessage, + HostEvent.InitSpotterVizConversation, + HostEvent.OpenSpotterVizPanel, + HostEvent.CloseSpotterVizPanel, +]; + +// Type-level assertions: compile failures here mean contract resolution +// regressed. `expectType` is erased at runtime. +const expectType = (value: T): T => value; + +describe('event contracts (drift guardrails)', () => { + test('typed host event wire values are stable (additive-only)', () => { + expect( + TYPED_HOST_EVENTS.map((event) => `${event}`).sort(), + ).toMatchSnapshot(); + }); + + test('UI passthrough wire values are stable (additive-only)', () => { + expect(Object.values(UIPassthroughEvent).sort()).toMatchSnapshot(); + }); + + test('every typed host event is a real HostEvent member', () => { + const allHostEventValues = new Set(Object.values(HostEvent)); + TYPED_HOST_EVENTS.forEach((event) => { + expect(allHostEventValues.has(event)).toBe(true); + }); + }); + + test('contract resolution helpers compile against the typed map', () => { + // Typed event resolves to its contract type. + expectType( + (undefined as unknown) as HostEventRequest, + ); + expectType( + (undefined as unknown) as HostEventRequest, + ); + expectType( + (undefined as unknown) as HostEventRequest, + ); + // Untyped event stays `any` (backward compatible). + const untyped: HostEventRequest = { anything: 'goes' }; + expect(untyped).toBeDefined(); + // Response helper resolves for UI-passthrough-backed events. + expectType>( + (undefined as unknown) as HostEventResponse, + ); + }); + + test('createHostEventEmitters exposes one emitter per HostEvent member', async () => { + const triggered: Array<{ type: HostEvent; data: any }> = []; + const fakeEmbed = { + trigger: (type: HostEvent, data?: any) => { + triggered.push({ type, data }); + return Promise.resolve({ ok: true }); + }, + }; + const emitters = createHostEventEmitters(fakeEmbed); + + expect(Object.keys(emitters).sort()).toEqual( + Object.keys(HostEvent).sort(), + ); + + const filters: RuntimeFilter[] = [ + { columnName: 'state', operator: 'EQ' as any, values: ['CA'] }, + ]; + await emitters.UpdateRuntimeFilters(filters); + expect(triggered).toEqual([ + { type: HostEvent.UpdateRuntimeFilters, data: filters }, + ]); + }); + + test('embed event enum wire values referenced by typed payloads are stable', () => { + expect( + [EmbedEvent.AuthInit, EmbedEvent.EmbedListenerReady].map((e) => `${e}`).sort(), + ).toMatchSnapshot(); + }); +}); diff --git a/src/contracts/embed-event-payloads.ts b/src/contracts/embed-event-payloads.ts new file mode 100644 index 000000000..3a16c997e --- /dev/null +++ b/src/contracts/embed-event-payloads.ts @@ -0,0 +1,51 @@ +/** + * Copyright (c) 2026 + * + * Typed payload contracts for {@link EmbedEvent}s (ThoughtSpot app -> SDK). + * + * The SDK delivers embed events to `embed.on()` callbacks wrapped in the + * {@link MessagePayload} envelope `{ type, data, status? }`. The map below + * types the `data` field per event. Events absent from the map resolve to + * `any` until their payload is audited against what the host actually sends + * (codify observed behavior, not documented behavior). + * + * Same additive-only evolution rules as host-event-contracts.ts. + * @module contracts + */ +import type { EmbedEvent, MessagePayload } from '../types'; + +/** + * Typed `data` field per embed event. Seed with audited events only. + */ +export interface EmbedEventDataExtension { + [EmbedEvent.AuthInit]: { + /** + * Whether authentication was successful. + */ + isLoggedIn?: boolean; + [key: string]: any; + }; + [EmbedEvent.EmbedListenerReady]: Record; +} + +/** + * Resolves the typed `data` payload for an embed event; `any` when the + * event has not been audited/typed yet. + */ +export type EmbedEventData = + EmbedEventT extends keyof EmbedEventDataExtension + ? EmbedEventDataExtension[EmbedEventT] + : any; + +/** + * Full envelope delivered to `embed.on()` callbacks for a given event. + */ +export type EmbedEventPayload = + Omit & { + data: EmbedEventData; + }; + +/** + * String-name keyed view for the host side (addresses events by wire value). + */ +export type EmbedEventName = `${EmbedEvent}`; diff --git a/src/contracts/host-event-contracts.ts b/src/contracts/host-event-contracts.ts new file mode 100644 index 000000000..d28d2f2f5 --- /dev/null +++ b/src/contracts/host-event-contracts.ts @@ -0,0 +1,380 @@ +/** + * Copyright (c) 2026 + * + * Typed contracts for {@link HostEvent} requests and responses. + * + * This module is the single source of truth for host event payload shapes. + * It is published under the `@thoughtspot/visual-embed-sdk/contracts` subpath + * so the ThoughtSpot app (host) can consume the exact same contract types the + * SDK compiles against, preventing SDK <-> host contract drift. + * + * Contract evolution rules (enforced by contracts.spec.ts snapshot): + * - Additive only: new events and new OPTIONAL fields may be added. + * - Never remove or rename an event key, or change an existing field's type. + * - An event absent from the maps below intentionally resolves to `any` + * (untyped, backward compatible) until it is audited and added. + * @module contracts + */ +import type { + ContextType, + HostEvent, + RuntimeFilter, + RuntimeParameter, +} from '../types'; +import type { + Applicability, + EmbedApiHostEventMapping, + UIPassthroughContractBase, + UIPassthroughRequest, + UIPassthroughResponse, +} from '../embed/hostEventClient/contracts'; + +/** + * Shorthand for a contract entry whose response shape is not formally + * specified yet. Tightening a response later is additive for readers; + * never loosen an already-typed response. + */ +type ContractEntryOf = { request: RequestT; response: any }; + +/** + * Request for host events that MAY target a specific visualization. + * Omitting vizId targets the current answer/liveboard as a whole. + * (In some contexts, e.g. Spotter, the app requires vizId at runtime.) + */ +export interface VizScopedRequest { + vizId?: string; +} + +/** + * Request for host events that MUST target a specific visualization. + */ +export interface RequiredVizRequest { + vizId: string; +} + +/** + * Request for Spotter conversation-scoped host events. + */ +export interface ConversationScopedRequest { + conversationId: string; +} + +/** + * Request payload for {@link HostEvent.OpenFilter}. Field requirements + * vary by context (Search requires columnId/type/dataType/name); the + * contract is the cross-context superset — the app validates per context + * at runtime. + */ +export interface OpenFilterRequest { + column: { + columnId?: string; + columnName?: string; + type?: string; + dataType?: string; + name?: string; + isStrictDateColumn?: boolean; + }; + applicability?: Applicability; + visualizationId?: string; + liveboardId?: string; +} + +/** + * Request payload for {@link HostEvent.OpenParameter}. One of parameterId + * or parameterName must be provided (validated at runtime). + */ +export interface OpenParameterRequest { + parameter: { + parameterId?: string; + parameterName?: string; + }; + applicability?: Applicability; +} + +/** + * Request payload for {@link HostEvent.Search}. + */ +export interface SearchRequest { + searchQuery: string; + dataSources: string[]; + execute?: boolean; +} + +/** + * Request payload for {@link HostEvent.SpotterSearch}. + */ +export interface SpotterSearchRequest { + query: string; + executeSearch: boolean; +} + +/** + * A single cross-filter condition for {@link HostEvent.UpdateCrossFilter}. + */ +export interface CrossFilterCondition { + columnName?: string; + operator?: string; + values: Array; +} + +/** + * Request payload for {@link HostEvent.UpdateCrossFilter}. + */ +export interface UpdateCrossFilterRequest { + vizId: string; + conditions: CrossFilterCondition[]; +} + +/** + * A single filter entry for {@link HostEvent.UpdateFilters}. Supports both + * the current (columnName/operator) and legacy (column/oper) field names. + */ +export interface HostFilterUpdate { + columnName?: string; + columnId?: string; + operator?: string; + values: Array; + type?: string; + datePeriod?: string; + negate?: boolean; + /** Legacy field name for columnName. */ + column?: string; + /** Legacy field name for operator. */ + oper?: string; + applicability?: Applicability; +} + +/** + * Request payload for {@link HostEvent.UpdateFilters} (singular or plural + * form). + */ +export interface UpdateFiltersRequest { + filter?: HostFilterUpdate; + filters?: HostFilterUpdate[]; +} + +/** + * Request payload for personalised-view host events. + */ +export interface PersonalisedViewRequest { + viewId?: string; + viewName?: string; +} + +/** + * Request payload for schedule-email related host events. + */ +export interface ScheduleEmailRequest { + sendToSelf?: boolean; +} + +/** + * Object form of the {@link HostEvent.Navigate} payload. + */ +export interface NavigateRequest { + /** + * Route to navigate to, or a history delta such as `1` or `-1`. + */ + path: string | number; + /** + * When `true`, replaces the current history entry instead of pushing. + */ + replace?: boolean; +} + +/** + * Request payload for {@link HostEvent.SetActiveTab}. + */ +export interface SetActiveTabRequest { + /** + * Id of the liveboard tab to make active. + */ + tabId: string; +} + +/** + * Typed request/response contracts for host events that do not go through + * the UI passthrough pipeline. + * + * Request shapes are transcribed from the host's runtime validation + * schemas (embed-util HostEventContract) — the shapes the ThoughtSpot app + * actually enforces — flattened across contexts to the permissive + * superset (context-specific requirements are validated at runtime). + * + * NOTE: `response` is typed `any` for events whose host-side response + * shape is not formally specified yet. Tightening a response type later + * is additive for consumers reading properties off it, but do not LOOSEN + * an already-typed response. + * + * Deliberately absent (do not add without an audit): + * - DrillDown: the runtime schema (object-shaped points) and the UI + * passthrough contract (string-shaped points) disagree — resolve the + * drift first. + * - GetAnswerSession/GetParameters/GetTML: typed via the UI passthrough + * mapping; their Spotter-context vizId requirement is runtime-only. + */ +export interface HostEventContractExtension { + // ==================== FILTERS AND PARAMETERS ==================== + [HostEvent.UpdateRuntimeFilters]: ContractEntryOf; + [HostEvent.UpdateParameters]: ContractEntryOf; + [HostEvent.UpdateFilters]: ContractEntryOf; + [HostEvent.UpdateCrossFilter]: ContractEntryOf; + [HostEvent.OpenFilter]: ContractEntryOf; + [HostEvent.OpenParameter]: ContractEntryOf; + + // ==================== TABS AND VIZS ==================== + [HostEvent.SetVisibleVizs]: ContractEntryOf; + [HostEvent.SetVisibleTabs]: ContractEntryOf; + [HostEvent.SetHiddenTabs]: ContractEntryOf; + [HostEvent.SetActiveTab]: ContractEntryOf; + + // ==================== NAVIGATION ==================== + [HostEvent.Navigate]: ContractEntryOf; + + // ==================== SEARCH AND COLUMNS ==================== + [HostEvent.Search]: ContractEntryOf; + [HostEvent.ResetSearch]: ContractEntryOf; + [HostEvent.AddColumns]: ContractEntryOf<{ columnIds: string[] }>; + [HostEvent.RemoveColumn]: ContractEntryOf<{ columnId: string }>; + + // ==================== VIZ-SCOPED ACTIONS ==================== + // vizId optional in most contexts; some contexts require it at runtime. + [HostEvent.Edit]: ContractEntryOf; + [HostEvent.Save]: ContractEntryOf; + [HostEvent.Delete]: ContractEntryOf; + [HostEvent.Share]: ContractEntryOf; + [HostEvent.Present]: ContractEntryOf; + [HostEvent.CopyLink]: ContractEntryOf; + [HostEvent.ExportTML]: ContractEntryOf; + [HostEvent.EditTML]: ContractEntryOf; + [HostEvent.UpdateTML]: ContractEntryOf; + [HostEvent.SchedulesList]: ContractEntryOf; + [HostEvent.Schedule]: ContractEntryOf; + [HostEvent.SpotIQAnalyze]: ContractEntryOf; + [HostEvent.ShowUnderlyingData]: ContractEntryOf; + [HostEvent.CreateMonitor]: ContractEntryOf; + [HostEvent.ManageMonitor]: ContractEntryOf; + [HostEvent.SyncToSheets]: ContractEntryOf; + [HostEvent.SyncToOtherApps]: ContractEntryOf; + [HostEvent.ManagePipelines]: ContractEntryOf; + // Download shares the downloadAsPng wire value with DownloadAsPng. + [HostEvent.DownloadAsPng]: ContractEntryOf; + [HostEvent.DownloadAsCsv]: ContractEntryOf; + [HostEvent.DownloadAsXlsx]: ContractEntryOf; + [HostEvent.DownloadAsPdf]: ContractEntryOf; + + // Viz id required in every context. + [HostEvent.Explore]: ContractEntryOf; + [HostEvent.AskSage]: ContractEntryOf; + [HostEvent.AskSpotter]: ContractEntryOf; + [HostEvent.AnswerChartSwitcher]: ContractEntryOf; + + // ==================== LIVEBOARD ==================== + [HostEvent.UpdatePersonalisedView]: ContractEntryOf>; + [HostEvent.SelectPersonalizedView]: ContractEntryOf; + [HostEvent.ResetLiveboardPersonalisedView]: ContractEntryOf; + [HostEvent.AIHighlights]: ContractEntryOf; + [HostEvent.SendTestScheduleEmail]: ContractEntryOf; + [HostEvent.RefreshLiveboardBrowserCache]: ContractEntryOf; + + // ==================== SPOTTER ==================== + [HostEvent.SpotterSearch]: ContractEntryOf; + [HostEvent.ResetSpotterConversation]: ContractEntryOf; + [HostEvent.ShareSpotterConversation]: ContractEntryOf; + [HostEvent.CloseSpotterShareConversation]: ContractEntryOf; + [HostEvent.ExitSpotterSharedConversation]: ContractEntryOf; + [HostEvent.PinSpotterConversation]: ContractEntryOf; + [HostEvent.UnpinSpotterConversation]: ContractEntryOf; + [HostEvent.EditLastPrompt]: ContractEntryOf; + [HostEvent.DeleteLastPrompt]: ContractEntryOf; + [HostEvent.PreviewSpotterData]: ContractEntryOf; + [HostEvent.SpotterVizSendUserMessage]: ContractEntryOf<{ query: string }>; + [HostEvent.InitSpotterVizConversation]: ContractEntryOf; + [HostEvent.OpenSpotterVizPanel]: ContractEntryOf; + [HostEvent.CloseSpotterVizPanel]: ContractEntryOf; +} + +/** + * Resolves the typed request payload for a host event. + * Resolution order: + * 1. Explicitly typed contract in {@link HostEventContractExtension} + * 2. UI passthrough backed contract ({@link EmbedApiHostEventMapping}) + * 3. `any` (event not audited/typed yet — backward compatible) + */ +export type HostEventRequest = + HostEventT extends keyof HostEventContractExtension + ? HostEventContractExtension[HostEventT]['request'] + : HostEventT extends keyof EmbedApiHostEventMapping + ? UIPassthroughRequest + : any; + +/** + * Resolves the typed response payload for a host event. + * Same resolution order as {@link HostEventRequest}. + */ +export type HostEventResponse< + HostEventT extends HostEvent, + // Reserved for context-dependent response shapes (additive change later). + ContextT extends ContextType = ContextType, +> = HostEventT extends keyof HostEventContractExtension + ? HostEventContractExtension[HostEventT]['response'] + : HostEventT extends keyof EmbedApiHostEventMapping + ? UIPassthroughResponse + : any; + +/** + * Payload type accepted by `embed.trigger()`. Keeps the historical + * `PayloadT` escape hatch so untyped existing call sites keep compiling. + */ +export type TriggerPayload = + PayloadT | HostEventRequest; + +/** + * Response type returned by `embed.trigger()`. + */ +export type TriggerResponse< + PayloadT, + HostEventT extends HostEvent, + ContextT extends ContextType = ContextType, +> = PayloadT extends HostEventRequest + ? HostEventResponse + : any; + +/** + * String-name keyed views of the contracts, for the host (ThoughtSpot app) + * side, which addresses events by their wire value (e.g. + * 'UpdateRuntimeFilters') rather than the {@link HostEvent} enum member. + */ +export type HostEventName = `${HostEvent}`; + +/** + * Resolves a host event's request type from its wire name. + */ +export type HostEventRequestByName = { + [EventT in HostEvent]: `${EventT}` extends NameT + ? HostEventRequest + : never; +}[HostEvent] extends never + ? any + : { + [EventT in HostEvent]: `${EventT}` extends NameT + ? HostEventRequest + : never; + }[HostEvent]; + +/** + * Resolves a host event's response type from its wire name. + */ +export type HostEventResponseByName = { + [EventT in HostEvent]: `${EventT}` extends NameT + ? HostEventResponse + : never; +}[HostEvent] extends never + ? any + : { + [EventT in HostEvent]: `${EventT}` extends NameT + ? HostEventResponse + : never; + }[HostEvent]; + +export type { UIPassthroughContractBase, EmbedApiHostEventMapping }; diff --git a/src/contracts/host-event-emitters.ts b/src/contracts/host-event-emitters.ts new file mode 100644 index 000000000..81601ce15 --- /dev/null +++ b/src/contracts/host-event-emitters.ts @@ -0,0 +1,59 @@ +/** + * Copyright (c) 2026 + * + * Type-derived host event emitter helpers. + * + * Instead of hand-writing one helper per event (which would drift from the + * contracts), the emitter surface is DERIVED from the {@link HostEvent} enum + * and the contract maps at the type level, and implemented generically at + * runtime. Adding an event to the enum/contracts automatically adds a fully + * typed emitter — there is no per-event code to keep in sync. + * @module contracts + * @example + * ```js + * import { createHostEventEmitters } from '@thoughtspot/visual-embed-sdk/contracts'; + * + * const emit = createHostEventEmitters(liveboardEmbed); + * await emit.UpdateRuntimeFilters([{ columnName: 'state', operator: 'EQ', values: ['CA'] }]); + * await emit.Pin({ vizId: '123', newVizName: 'My viz' }); + * ``` + */ +import { ContextType, HostEvent } from '../types'; +import type { HostEventRequest, HostEventResponse } from './host-event-contracts'; + +/** + * Minimal surface of an embed instance needed to emit host events. + */ +export interface HostEventTrigger { + trigger( + messageType: HostEvent, + data?: any, + context?: ContextType, + ): Promise; +} + +/** + * One emitter method per {@link HostEvent} member, request/response typed + * from the event contracts. + */ +export type HostEventEmitters = { + [MemberK in keyof typeof HostEvent]: ( + data?: HostEventRequest<(typeof HostEvent)[MemberK]>, + context?: ContextType, + ) => Promise>; +}; + +/** + * Creates typed emitter helpers bound to an embed instance. + * @param embed Any embed instance exposing `trigger()`. + */ +export const createHostEventEmitters = ( + embed: HostEventTrigger, +): HostEventEmitters => { + const emitters = {} as Record Promise>; + (Object.keys(HostEvent) as Array).forEach((memberName) => { + emitters[memberName] = (data?: any, context?: ContextType) => + embed.trigger(HostEvent[memberName], data, context); + }); + return emitters as HostEventEmitters; +}; diff --git a/src/contracts/index.ts b/src/contracts/index.ts new file mode 100644 index 000000000..a8722d741 --- /dev/null +++ b/src/contracts/index.ts @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2026 + * + * `@thoughtspot/visual-embed-sdk/contracts` + * + * Single source of truth for the Visual Embed SDK <-> ThoughtSpot app event + * contracts. Consumed by: + * - the SDK itself (compile-time types for `trigger()` / `on()`), + * - the ThoughtSpot app (host-side handler typing + runtime validation), + * - documentation generation. + * + * Evolution policy: ADDITIVE ONLY. See host-event-contracts.ts header. + * @module contracts + */ +export { + HostEvent, + EmbedEvent, + ContextType, +} from '../types'; +export type { + RuntimeFilter, + RuntimeParameter, + MessagePayload, +} from '../types'; + +export * from './host-event-contracts'; +export * from './embed-event-payloads'; +export * from './host-event-emitters'; + +// UI passthrough contracts remain in their historical home; re-exported so +// the contracts subpath is self-sufficient for host-side consumers. +export { + UIPassthroughEvent, + ApplicabilityLevel, +} from '../embed/hostEventClient/contracts'; +export type { + Applicability, + FilterUpdate, + LiveboardFilter, + LiveboardParameter, + LiveboardTab, + LiveboardGroup, + UIPassthroughRequest, + UIPassthroughResponse, + UIPassthroughArrayResponse, +} from '../embed/hostEventClient/contracts'; diff --git a/src/embed/hostEventClient/host-event-client.ts b/src/embed/hostEventClient/host-event-client.ts index 46e0f8cb0..79f417da0 100644 --- a/src/embed/hostEventClient/host-event-client.ts +++ b/src/embed/hostEventClient/host-event-client.ts @@ -12,13 +12,17 @@ import { import { UIPassthroughArrayResponse, UIPassthroughEvent, - HostEventRequest, - HostEventResponse, UIPassthroughRequest, UIPassthroughResponse, +} from './contracts'; +// Contract resolution from the shared contracts module — see +// src/contracts/host-event-contracts.ts (single source of truth). +import { + HostEventRequest, + HostEventResponse, TriggerPayload, TriggerResponse, -} from './contracts'; +} from '../../contracts/host-event-contracts'; /** * Maps HostEvent to its corresponding UIPassthroughEvent. @@ -244,7 +248,16 @@ export class HostEventClient { throwUpdateFiltersValidationError(); } - return this.handleHostEventWithParam(UIPassthroughEvent.UpdateFilters, payload, context as ContextType); + // The shared contract accepts both current (columnName/operator) and + // legacy (column/oper) filter field names — as does the validation + // above and the app at runtime. The UIPassthrough FilterUpdate type + // still requires the legacy names; bridge until that contract is + // audited. + return this.handleHostEventWithParam( + UIPassthroughEvent.UpdateFilters, + payload as UIPassthroughRequest, + context as ContextType, + ); } protected handleUpdateParametersEvent( @@ -287,7 +300,7 @@ export class HostEventClient { hostEvent: HostEventT, payload?: TriggerPayload, context?: ContextT, - ): Promise> { + ): Promise> { const customHandler = this.customHandlers[hostEvent]; const passthroughEvent = PASSTHROUGH_MAP[hostEvent]; diff --git a/src/embed/liveboard.ts b/src/embed/liveboard.ts index 2c2797d6c..d54e23fc2 100644 --- a/src/embed/liveboard.ts +++ b/src/embed/liveboard.ts @@ -29,7 +29,7 @@ import { calculateVisibleElementData, getEffectiveClippingAncestors, getQueryPar import { getAuthPromise } from './base'; import { TsEmbed, V1Embed } from './ts-embed'; import { addPreviewStylesIfNotPresent } from '../utils/global-styles'; -import { HostEventRequest, TriggerPayload, TriggerResponse } from './hostEventClient/contracts'; +import { HostEventRequest, TriggerPayload, TriggerResponse } from '../contracts/host-event-contracts'; import { logger } from '../utils/logger'; import { SpotterChatViewConfig, StarterPromptsConfig } from './conversation'; import { buildStarterPromptsAppInitData } from './spotter-utils'; diff --git a/src/embed/ts-embed.ts b/src/embed/ts-embed.ts index 10ed5d535..bf76a0415 100644 --- a/src/embed/ts-embed.ts +++ b/src/embed/ts-embed.ts @@ -10,13 +10,16 @@ import isEqual from 'lodash/isEqual'; import isEmpty from 'lodash/isEmpty'; import isObject from 'lodash/isObject'; import { - HostEventRequest, - TriggerPayload, - TriggerResponse, UIPassthroughArrayResponse, UIPassthroughEvent, UIPassthroughRequest, } from './hostEventClient/contracts'; +// Contract resolution comes from the shared contracts module (the single +// source of truth for event payload shapes) rather than the legacy +// UI-passthrough-only mapping. +import { HostEventRequest, TriggerPayload, TriggerResponse } from '../contracts/host-event-contracts'; +import { EmbedEventPayload } from '../contracts/embed-event-payloads'; +import { isMessageFromIframe } from '../utils/transport/iframe-transport'; import { logger } from '../utils/logger'; import { getAuthenticationToken } from '../authToken'; import { AnswerService } from '../utils/graphql/answerService/answerService'; @@ -432,7 +435,7 @@ export class TsEmbed { const eventType = this.getEventType(event); const eventPort = this.getEventPort(event); const eventData = this.formatEventData(event, eventType); - if (event.source === this.iFrame.contentWindow) { + if (isMessageFromIframe(event, this.iFrame, this.thoughtSpotHost)) { const processedEventData = processEventData( eventType, eventData, @@ -1516,9 +1519,12 @@ export class TsEmbed { * }); * ``` */ - public on( - messageType: EmbedEvent, - callback: MessageCallback, + public on( + messageType: EmbedEventT, + callback: ( + payload: EmbedEventPayload, + responder?: (data: any) => void, + ) => void, options: MessageOptions = { start: false }, isRegisteredBySDK = false, ): typeof TsEmbed.prototype { diff --git a/src/utils/processTrigger.ts b/src/utils/processTrigger.ts index 761eb6463..c44ef2fa0 100644 --- a/src/utils/processTrigger.ts +++ b/src/utils/processTrigger.ts @@ -1,8 +1,11 @@ -import { ERROR_MESSAGE } from '../errors'; -import { ContextType, HostEvent, MessagePayload } from '../types'; +import { ContextType, HostEvent } from '../types'; import { logger } from '../utils/logger'; import { handlePresentEvent } from '../utils'; import { getEmbedConfig } from '../embed/embedConfig'; +import { + MESSAGE_RESPONSE_TIMEOUT, + sendMessageWithResponse, +} from './transport/iframe-transport'; /** * Reloads the ThoughtSpot iframe. @@ -16,29 +19,12 @@ export const reload = (iFrame: HTMLIFrameElement) => { }, 100); }; -/** - * Post iframe message. - * @param iFrame - * @param message - * @param message.type - * @param message.data - * @param message.context - * @param thoughtSpotHost - * @param channel - */ -function postIframeMessage( - iFrame: HTMLIFrameElement, - message: { type: HostEvent; data: any, context?: any }, - thoughtSpotHost: string, - channel?: MessageChannel, -) { - return iFrame.contentWindow?.postMessage(message, thoughtSpotHost, [channel?.port2]); -} - -export const TRIGGER_TIMEOUT = 30000; +export const TRIGGER_TIMEOUT = MESSAGE_RESPONSE_TIMEOUT; /** - * + * Processes a host event trigger: handles SDK-local events (Reload, + * Present) and forwards everything else to the embedded app over the + * iframe transport. * @param iFrame * @param messageType * @param thoughtSpotHost @@ -52,42 +38,26 @@ export function processTrigger( data: any, context?: ContextType, ): Promise { - return new Promise((res, rej) => { - if (messageType === HostEvent.Reload) { - reload(iFrame); - return res(null); - } - - if (messageType === HostEvent.Present) { - const embedConfig = getEmbedConfig(); - const disableFullscreenPresentation = embedConfig?.disableFullscreenPresentation ?? true; - - if (!disableFullscreenPresentation) { - handlePresentEvent(iFrame); - } else { - logger.warn('Fullscreen presentation mode is disabled. Set disableFullscreenPresentation: false to enable this feature.'); - } - } - - const channel = new MessageChannel(); + if (messageType === HostEvent.Reload) { + reload(iFrame); + return Promise.resolve(null); + } - // Close the messageChannel and resolve the promise if timeout. - const timeoutId = setTimeout(() => { - channel.port1.close(); - res(new Error(ERROR_MESSAGE.TRIGGER_TIMED_OUT)); - }, TRIGGER_TIMEOUT); + if (messageType === HostEvent.Present) { + const embedConfig = getEmbedConfig(); + const disableFullscreenPresentation = embedConfig?.disableFullscreenPresentation ?? true; - channel.port1.onmessage = ({ data: responseData }) => { - clearTimeout(timeoutId); - channel.port1.close(); - const error = responseData?.error || responseData?.data?.error; - if (error) { - rej(error); - } else { - res(responseData); - } - }; + if (!disableFullscreenPresentation) { + handlePresentEvent(iFrame); + } else { + logger.warn('Fullscreen presentation mode is disabled. Set disableFullscreenPresentation: false to enable this feature.'); + } + } - return postIframeMessage(iFrame, { type: messageType, data, context }, thoughtSpotHost, channel); - }); + return sendMessageWithResponse( + iFrame, + { type: messageType, data, context }, + thoughtSpotHost, + TRIGGER_TIMEOUT, + ); } diff --git a/src/utils/transport/iframe-transport.ts b/src/utils/transport/iframe-transport.ts new file mode 100644 index 000000000..dfba70824 --- /dev/null +++ b/src/utils/transport/iframe-transport.ts @@ -0,0 +1,122 @@ +/** + * Copyright (c) 2026 + * + * Dedicated transport layer for SDK <-> ThoughtSpot iframe messaging. + * + * Owns the postMessage/MessageChannel mechanics and origin resolution so + * that embed/business logic (ts-embed.ts, hostEventClient) never touches + * the wire directly. The wire format is unchanged from the historical + * implementation — this layer is a pure structural extraction. + */ +import { ERROR_MESSAGE } from '../../errors'; +import { logger } from '../logger'; + +/** + * Default time to wait for the app to respond to a request-response + * message before giving up and reclaiming the channel. + */ +export const MESSAGE_RESPONSE_TIMEOUT = 30000; + +/** + * Single implementation of origin resolution for the embedded app. + * Returns the origin (scheme://host[:port]) for a ThoughtSpot host URL, + * or null when it cannot be parsed. + * @param thoughtSpotHost + */ +export const resolveMessageOrigin = (thoughtSpotHost: string): string | null => { + try { + return new URL(thoughtSpotHost).origin; + } catch (e) { + return null; + } +}; + +/** + * Validates that an inbound window message came from the embedded + * ThoughtSpot app: the source window must be the embed iframe's window and, + * when the expected origin is resolvable, the message origin must match it. + * + * Fails open (returns true) on the origin check when the expected origin + * cannot be resolved, to avoid breaking unconventional-but-working setups; + * a warning is logged on mismatch either way. + * @param event The message event received on window. + * @param iFrame The embed iframe the message should originate from. + * @param thoughtSpotHost The configured ThoughtSpot host. + */ +export const isMessageFromIframe = ( + event: MessageEvent, + iFrame: HTMLIFrameElement, + thoughtSpotHost: string, +): boolean => { + if (event.source !== iFrame?.contentWindow) { + return false; + } + const expectedOrigin = resolveMessageOrigin(thoughtSpotHost); + if (expectedOrigin && event.origin && event.origin !== expectedOrigin) { + logger.warn( + `Dropped message from unexpected origin ${event.origin}; expected ${expectedOrigin}`, + ); + return false; + } + return true; +}; + +/** + * Posts a one-way message to the embedded app's iframe, optionally + * transferring a MessageChannel port for the response. + * @param iFrame + * @param message + * @param thoughtSpotHost Used as the postMessage targetOrigin. + * @param channel + */ +export const postMessageToIframe = ( + iFrame: HTMLIFrameElement, + message: { type: string; data: any; context?: any }, + thoughtSpotHost: string, + channel?: MessageChannel, +): void => iFrame.contentWindow?.postMessage( + message, + thoughtSpotHost, + channel ? [channel.port2] : [], +); + +/** + * Sends a request-response message to the embedded app over a dedicated + * MessageChannel. Resolves with the response payload; rejects when the app + * responds with an error; resolves with a timeout Error object when no + * response arrives in time (historical behavior, preserved for backward + * compatibility). + * @param iFrame + * @param message + * @param thoughtSpotHost + * @param timeoutMs + */ +export function sendMessageWithResponse( + iFrame: HTMLIFrameElement, + message: { type: string; data: any; context?: any }, + thoughtSpotHost: string, + timeoutMs: number = MESSAGE_RESPONSE_TIMEOUT, +): Promise { + return new Promise((res, rej) => { + const channel = new MessageChannel(); + + // Close the messageChannel and resolve the promise if timeout. + const timeoutId = setTimeout(() => { + channel.port1.close(); + res(new Error(ERROR_MESSAGE.TRIGGER_TIMED_OUT)); + }, timeoutMs); + + channel.port1.onmessage = ({ data: responseData }) => { + clearTimeout(timeoutId); + channel.port1.close(); + const error = responseData?.error || responseData?.data?.error; + if (error) { + rej(error); + } else { + res(responseData); + } + }; + + postMessageToIframe(iFrame, message, thoughtSpotHost, channel); + }); +} From 346086fec6e4bb1c4113732ffc8f52348852bfa3 Mon Sep 17 00:00:00 2001 From: Prashant Patil Date: Tue, 25 Aug 2026 11:32:15 +0530 Subject: [PATCH 2/3] updated for custom actions typings --- .../__snapshots__/contracts.spec.ts.snap | 1 + src/contracts/contracts.spec.ts | 33 +++++++++++++++-- src/contracts/embed-event-payloads.ts | 36 +++++++++++++++++-- src/contracts/host-event-contracts.ts | 2 +- src/contracts/index.ts | 12 +++++-- .../ui-passthrough-contracts.ts} | 24 ++++--------- .../hostEventClient/host-event-client.spec.ts | 4 +-- .../hostEventClient/host-event-client.ts | 2 +- src/embed/hostEventClient/utils.ts | 3 +- src/embed/ts-embed.spec.ts | 2 +- src/embed/ts-embed.ts | 16 ++++++--- src/index.ts | 12 ++++--- src/types.ts | 9 +++++ 13 files changed, 115 insertions(+), 41 deletions(-) rename src/{embed/hostEventClient/contracts.ts => contracts/ui-passthrough-contracts.ts} (86%) diff --git a/src/contracts/__snapshots__/contracts.spec.ts.snap b/src/contracts/__snapshots__/contracts.spec.ts.snap index 8b7ddb404..466ca1be5 100644 --- a/src/contracts/__snapshots__/contracts.spec.ts.snap +++ b/src/contracts/__snapshots__/contracts.spec.ts.snap @@ -26,6 +26,7 @@ exports[`event contracts (drift guardrails) embed event enum wire values referen [ "EmbedListenerReady", "authInit", + "customAction", ] `; diff --git a/src/contracts/contracts.spec.ts b/src/contracts/contracts.spec.ts index d4d603c10..516107475 100644 --- a/src/contracts/contracts.spec.ts +++ b/src/contracts/contracts.spec.ts @@ -15,14 +15,19 @@ * contracts. */ import { HostEvent, EmbedEvent } from '../types'; -import { UIPassthroughEvent } from '../embed/hostEventClient/contracts'; +import { UIPassthroughEvent } from './ui-passthrough-contracts'; import type { HostEventRequest, HostEventResponse, NavigateRequest, SetActiveTabRequest, } from './host-event-contracts'; -import type { RuntimeFilter } from '../types'; +import type { CustomActionPayload, RuntimeFilter } from '../types'; +import type { + CustomActionEventPayload, + EmbedEventData, + EmbedEventPayload, +} from './embed-event-payloads'; import { createHostEventEmitters } from './host-event-emitters'; // Events with explicitly typed contracts in HostEventContractExtension. @@ -165,7 +170,29 @@ describe('event contracts (drift guardrails)', () => { test('embed event enum wire values referenced by typed payloads are stable', () => { expect( - [EmbedEvent.AuthInit, EmbedEvent.EmbedListenerReady].map((e) => `${e}`).sort(), + [ + EmbedEvent.AuthInit, + EmbedEvent.EmbedListenerReady, + EmbedEvent.CustomAction, + ].map((e) => `${e}`).sort(), ).toMatchSnapshot(); }); + + test('CustomAction resolves to its typed payload; answerService on the dedicated type', () => { + // Wire data resolves to CustomActionPayload. + expectType( + (undefined as unknown) as EmbedEventData, + ); + // payload.data.id is a typed string (type-level indexed access). + expectType( + (undefined as unknown) as EmbedEventPayload['data']['id'], + ); + // The dedicated type carries the SDK-added answerService (optional). + type AnswerServiceField = CustomActionEventPayload['answerService']; + const svc: AnswerServiceField = undefined; + expect(svc).toBeUndefined(); + // Untyped embed event stays `any` (backward compatible). + const untyped: EmbedEventData = { anything: 'goes' }; + expect(untyped).toBeDefined(); + }); }); diff --git a/src/contracts/embed-event-payloads.ts b/src/contracts/embed-event-payloads.ts index 3a16c997e..a0162e040 100644 --- a/src/contracts/embed-event-payloads.ts +++ b/src/contracts/embed-event-payloads.ts @@ -12,7 +12,11 @@ * Same additive-only evolution rules as host-event-contracts.ts. * @module contracts */ -import type { EmbedEvent, MessagePayload } from '../types'; +import type { CustomActionPayload, EmbedEvent, MessagePayload } from '../types'; +// Type-only: AnswerService is an SDK-side enrichment (see +// EmbedEventEnvelopeExtension). Type-only import keeps the contracts subpath +// free of any runtime dependency on the answer-service implementation. +import type { AnswerService } from '../utils/graphql/answerService/answerService'; /** * Typed `data` field per embed event. Seed with audited events only. @@ -26,6 +30,11 @@ export interface EmbedEventDataExtension { [key: string]: any; }; [EmbedEvent.EmbedListenerReady]: Record; + /** + * Fired when a callback-type custom action is triggered. Use + * `payload.data.id` to identify which action fired. + */ + [EmbedEvent.CustomAction]: CustomActionPayload; } /** @@ -38,13 +47,36 @@ export type EmbedEventData = : any; /** - * Full envelope delivered to `embed.on()` callbacks for a given event. + * Full envelope delivered to `embed.on()` callbacks for a given event: + * the wire fields (`type`, `status`) plus the typed `data`. */ export type EmbedEventPayload = Omit & { data: EmbedEventData; }; +/** + * Payload delivered to `on(EmbedEvent.CustomAction)`. Beyond the wire fields, + * the SDK attaches an {@link AnswerService} built from the event's session and + * answer data (see utils/processData.ts `processCustomAction`) — annotate your + * callback with this type to access it: + * + * ```ts + * embed.on(EmbedEvent.CustomAction, (payload: CustomActionEventPayload) => { + * if (payload.data.id === 'my-action') { + * payload.answerService?.getUnderlyingDataForPoint([]); + * } + * }); + * ``` + * + * It is a dedicated type rather than folded into `on()`'s generic callback + * because the SDK's event registry types callbacks as the plain + * {@link MessagePayload}, which the enrichment cannot be intersected into + * without restructuring that registry. + */ +export type CustomActionEventPayload = + EmbedEventPayload & { answerService?: AnswerService }; + /** * String-name keyed view for the host side (addresses events by wire value). */ diff --git a/src/contracts/host-event-contracts.ts b/src/contracts/host-event-contracts.ts index d28d2f2f5..c67bb73e6 100644 --- a/src/contracts/host-event-contracts.ts +++ b/src/contracts/host-event-contracts.ts @@ -27,7 +27,7 @@ import type { UIPassthroughContractBase, UIPassthroughRequest, UIPassthroughResponse, -} from '../embed/hostEventClient/contracts'; +} from './ui-passthrough-contracts'; /** * Shorthand for a contract entry whose response shape is not formally diff --git a/src/contracts/index.ts b/src/contracts/index.ts index a8722d741..aa8825391 100644 --- a/src/contracts/index.ts +++ b/src/contracts/index.ts @@ -21,6 +21,14 @@ export type { RuntimeFilter, RuntimeParameter, MessagePayload, + // Code-based custom action config (sent to the host at init as + // DefaultAppInitData.customActions) + the click event payload. + CustomAction, + CustomActionPayload, +} from '../types'; +export { + CustomActionsPosition, + CustomActionTarget, } from '../types'; export * from './host-event-contracts'; @@ -32,7 +40,7 @@ export * from './host-event-emitters'; export { UIPassthroughEvent, ApplicabilityLevel, -} from '../embed/hostEventClient/contracts'; +} from './ui-passthrough-contracts'; export type { Applicability, FilterUpdate, @@ -43,4 +51,4 @@ export type { UIPassthroughRequest, UIPassthroughResponse, UIPassthroughArrayResponse, -} from '../embed/hostEventClient/contracts'; +} from './ui-passthrough-contracts'; diff --git a/src/embed/hostEventClient/contracts.ts b/src/contracts/ui-passthrough-contracts.ts similarity index 86% rename from src/embed/hostEventClient/contracts.ts rename to src/contracts/ui-passthrough-contracts.ts index 290ef2e60..31c79ab12 100644 --- a/src/embed/hostEventClient/contracts.ts +++ b/src/contracts/ui-passthrough-contracts.ts @@ -1,5 +1,5 @@ -import { ContextType, HostEvent, RuntimeFilter } from '../../types'; -import { SessionInterface } from '../../utils/graphql/answerService/answerService'; +import { HostEvent, RuntimeFilter } from '../types'; +import { SessionInterface } from '../utils/graphql/answerService/answerService'; export interface LiveboardTab { id: string; @@ -244,19 +244,7 @@ export type EmbedApiHostEventMapping = { [HostEvent.getExportRequestForCurrentPinboard]: UIPassthroughEvent.GetExportRequestForCurrentPinboard; } -// Host Event Request and Response -export type HostEventRequest = - HostEventT extends keyof EmbedApiHostEventMapping - ? UIPassthroughRequest - : any; - -export type HostEventResponse = - HostEventT extends keyof EmbedApiHostEventMapping - ? UIPassthroughResponse - : any; - -// trigger response and request -export type TriggerPayload = - PayloadT | HostEventRequest; -export type TriggerResponse = - PayloadT extends HostEventRequest ? HostEventResponse : any; \ No newline at end of file +// NOTE: HostEventRequest / HostEventResponse / TriggerPayload / TriggerResponse +// live in the sibling ./host-event-contracts, which layers the +// HostEventContractExtension map on top of the UI-passthrough mapping above. +// The former 2-tier definitions that lived here were superseded and removed. \ No newline at end of file diff --git a/src/embed/hostEventClient/host-event-client.spec.ts b/src/embed/hostEventClient/host-event-client.spec.ts index 069320d73..ea2b1841a 100644 --- a/src/embed/hostEventClient/host-event-client.spec.ts +++ b/src/embed/hostEventClient/host-event-client.spec.ts @@ -6,8 +6,8 @@ import { UIPassthroughEvent, UIPassthroughRequest, UIPassthroughArrayResponse, - HostEventRequest, -} from './contracts'; +} from '../../contracts/ui-passthrough-contracts'; +import { HostEventRequest } from '../../contracts/host-event-contracts'; import { HostEventClient } from './host-event-client'; jest.mock('../../utils/processTrigger'); diff --git a/src/embed/hostEventClient/host-event-client.ts b/src/embed/hostEventClient/host-event-client.ts index 79f417da0..efce4cb72 100644 --- a/src/embed/hostEventClient/host-event-client.ts +++ b/src/embed/hostEventClient/host-event-client.ts @@ -14,7 +14,7 @@ import { UIPassthroughEvent, UIPassthroughRequest, UIPassthroughResponse, -} from './contracts'; +} from '../../contracts/ui-passthrough-contracts'; // Contract resolution from the shared contracts module — see // src/contracts/host-event-contracts.ts (single source of truth). import { diff --git a/src/embed/hostEventClient/utils.ts b/src/embed/hostEventClient/utils.ts index 91e6a997b..9488a9262 100644 --- a/src/embed/hostEventClient/utils.ts +++ b/src/embed/hostEventClient/utils.ts @@ -4,7 +4,8 @@ import isString from 'lodash/isString'; import isUndefined from 'lodash/isUndefined'; import { EmbedErrorCodes, EmbedEvent, ErrorDetailsTypes, HostEvent } from '../../types'; import { ERROR_MESSAGE } from '../../errors'; -import { ApplicabilityLevel, HostEventRequest } from './contracts'; +import { ApplicabilityLevel } from '../../contracts/ui-passthrough-contracts'; +import { HostEventRequest } from '../../contracts/host-event-contracts'; import { embedEventStatus } from '../../utils'; const isValidApplicability = (a?: { level?: string; targetId?: string }) => { diff --git a/src/embed/ts-embed.spec.ts b/src/embed/ts-embed.spec.ts index 319ae3b6b..bb9ea5cd1 100644 --- a/src/embed/ts-embed.spec.ts +++ b/src/embed/ts-embed.spec.ts @@ -61,7 +61,7 @@ import { logger } from '../utils/logger'; import { version } from '../../package.json'; import { HiddenActionItemByDefaultForSearchEmbed } from './search'; import { processTrigger } from '../utils/processTrigger'; -import { UIPassthroughEvent } from './hostEventClient/contracts'; +import { UIPassthroughEvent } from '../contracts/ui-passthrough-contracts'; import * as sessionInfoService from '../utils/sessionInfoService'; import * as authToken from '../authToken'; import * as apiIntercept from '../api-intercept'; diff --git a/src/embed/ts-embed.ts b/src/embed/ts-embed.ts index bf76a0415..19d37739a 100644 --- a/src/embed/ts-embed.ts +++ b/src/embed/ts-embed.ts @@ -13,7 +13,7 @@ import { UIPassthroughArrayResponse, UIPassthroughEvent, UIPassthroughRequest, -} from './hostEventClient/contracts'; +} from '../contracts/ui-passthrough-contracts'; // Contract resolution comes from the shared contracts module (the single // source of truth for event payload shapes) rather than the legacy // UI-passthrough-only mapping. @@ -2333,12 +2333,18 @@ export class V1Embed extends TsEmbed { * }); * ``` */ - public on( - messageType: EmbedEvent, - callback: MessageCallback, + public on( + messageType: EmbedEventT, + callback: ( + payload: EmbedEventPayload, + responder?: (data: any) => void, + ) => void, options: MessageOptions = { start: false }, ): typeof TsEmbed.prototype { - const eventType = this.getCompatibleEventType(messageType); + // Mirror the base TsEmbed.on generic signature so the enriched + // EmbedEventPayload (e.g. CustomAction's answerService) flows through + // the override too, and the class hierarchy stays assignable. + const eventType = this.getCompatibleEventType(messageType) as EmbedEventT; return super.on(eventType, callback, options); } diff --git a/src/index.ts b/src/index.ts index 47f5ecf01..4cb9a8998 100644 --- a/src/index.ts +++ b/src/index.ts @@ -94,10 +94,6 @@ import { UIPassthroughRequest, UIPassthroughResponse, UIPassthroughArrayResponse, - HostEventRequest, - HostEventResponse, - TriggerPayload, - TriggerResponse, LiveboardTab, LiveboardGroup, ApplicabilityLevel, @@ -105,7 +101,13 @@ import { FilterUpdate, LiveboardFilter, LiveboardParameter, -} from './embed/hostEventClient/contracts'; +} from './contracts/ui-passthrough-contracts'; +import { + HostEventRequest, + HostEventResponse, + TriggerPayload, + TriggerResponse, +} from './contracts/host-event-contracts'; export { init, diff --git a/src/types.ts b/src/types.ts index b86c19a54..afcb6dc1a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8979,6 +8979,15 @@ export interface VizPoint { * @group Events */ export interface CustomActionPayload { + /** + * Id of the custom action that was triggered. Matches the `id` you set on + * the {@link CustomAction} — use it to distinguish which action fired. + */ + id: string; + /** + * Name of the custom action that was triggered, when the host includes it. + */ + name?: string; contextMenuPoints?: { clickedPoint: VizPoint; selectedPoints: VizPoint[]; From f88b02a87b93c2edd9af436e3cf628bdd5a124ea Mon Sep 17 00:00:00 2001 From: Prashant Patil Date: Tue, 25 Aug 2026 14:12:12 +0530 Subject: [PATCH 3/3] SCAL-325540 rebase and update --- .../__snapshots__/contracts.spec.ts.snap | 97 ------------- src/contracts/contracts.spec.ts | 134 +++++++++++++----- src/contracts/host-event-emitters.ts | 59 -------- src/contracts/index.ts | 1 - 4 files changed, 98 insertions(+), 193 deletions(-) delete mode 100644 src/contracts/__snapshots__/contracts.spec.ts.snap delete mode 100644 src/contracts/host-event-emitters.ts diff --git a/src/contracts/__snapshots__/contracts.spec.ts.snap b/src/contracts/__snapshots__/contracts.spec.ts.snap deleted file mode 100644 index 466ca1be5..000000000 --- a/src/contracts/__snapshots__/contracts.spec.ts.snap +++ /dev/null @@ -1,97 +0,0 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing - -exports[`event contracts (drift guardrails) UI passthrough wire values are stable (additive-only) 1`] = ` -[ - "addVizToPinboard", - "drillDown", - "getAnswerPageConfig", - "getAnswerSession", - "getAvailableUiPassthroughs", - "getDiscoverabilityStatus", - "getExportRequestForCurrentPinboard", - "getFilters", - "getGroups", - "getIframeUrl", - "getParameters", - "getPinboardPageConfig", - "getTML", - "getTabs", - "getUnsavedAnswerTML", - "saveAnswer", - "updateFilters", -] -`; - -exports[`event contracts (drift guardrails) embed event enum wire values referenced by typed payloads are stable 1`] = ` -[ - "EmbedListenerReady", - "authInit", - "customAction", -] -`; - -exports[`event contracts (drift guardrails) typed host event wire values are stable (additive-only) 1`] = ` -[ - "AIHighlights", - "AskSage", - "AskSpotter", - "CloseSpotterShareConversation", - "CloseSpotterVizPanel", - "DeleteLastPrompt", - "EditLastPrompt", - "ExitSpotterSharedConversation", - "InitSpotterVizConversation", - "Navigate", - "OpenSpotterVizPanel", - "PinSpotterConversation", - "PreviewSpotterData", - "ResetLiveboardPersonalisedView", - "ResetSpotterConversation", - "SelectPersonalisedView", - "SetActiveTab", - "SetPinboardHiddenTabs", - "SetPinboardVisibleTabs", - "SetPinboardVisibleVizs", - "ShareSpotterConversation", - "SpotterSearch", - "SpotterVizSendUserMessage", - "UnpinSpotterConversation", - "UpdateCrossFilter", - "UpdateParameters", - "UpdatePersonalisedView", - "UpdateRuntimeFilters", - "addColumns", - "answerChartSwitcher", - "createMonitor", - "downloadAsCSV", - "downloadAsPdf", - "downloadAsPng", - "downloadAsXLSX", - "edit", - "editTSL", - "embedDocument", - "explore", - "exportTSL", - "manage-pipeline", - "manageMonitor", - "onDeleteAnswer", - "openFilter", - "openParameter", - "present", - "refreshLiveboardBrowserCache", - "removeColumn", - "resetSearch", - "save", - "schedule-list", - "search", - "sendTestScheduleEmail", - "share", - "showUnderlyingData", - "spotIQAnalyze", - "subscription", - "sync-to-other-apps", - "sync-to-sheets", - "updateFilters", - "updateTSL", -] -`; diff --git a/src/contracts/contracts.spec.ts b/src/contracts/contracts.spec.ts index 516107475..b30d4b746 100644 --- a/src/contracts/contracts.spec.ts +++ b/src/contracts/contracts.spec.ts @@ -2,17 +2,18 @@ * Contract drift guardrails. * * These tests are the CI gate for the additive-only contract policy: - * - The snapshots below record which events have TYPED contracts. Removing - * an event from the typed maps (or renaming its wire value) fails the - * snapshot and must be treated as a breaking change, not a refactor. + * - The explicit wire-value lists below record which events have TYPED + * contracts. Removing an event from the typed maps (or renaming its wire + * value) fails the equality check and must be treated as a breaking change, + * not a refactor. * - Type-level assertions verify the request/response resolution helpers * keep resolving typed events to their contracts and unknown events to * `any` (backward compatibility). * - * When a snapshot fails: if you ADDED events, update the snapshot. If an - * existing entry disappeared or changed value, stop — that breaks published - * SDK consumers and the host runtime validation derived from these - * contracts. + * When a list check fails: if you ADDED an event, add its wire value to the + * expected list here. If an existing entry disappeared or changed value, + * stop — that breaks published SDK consumers and the host runtime validation + * derived from these contracts. */ import { HostEvent, EmbedEvent } from '../types'; import { UIPassthroughEvent } from './ui-passthrough-contracts'; @@ -28,7 +29,6 @@ import type { EmbedEventData, EmbedEventPayload, } from './embed-event-payloads'; -import { createHostEventEmitters } from './host-event-emitters'; // Events with explicitly typed contracts in HostEventContractExtension. // Keep in sync with the interface — this list is what the snapshot locks. @@ -108,14 +108,95 @@ const TYPED_HOST_EVENTS: HostEvent[] = [ const expectType = (value: T): T => value; describe('event contracts (drift guardrails)', () => { + // The lists below LOCK the set of typed events. Adding an event is an + // intentional, additive edit here; a removed/renamed wire value fails this + // test — which is the signal that it is a breaking change, not a refactor. test('typed host event wire values are stable (additive-only)', () => { - expect( - TYPED_HOST_EVENTS.map((event) => `${event}`).sort(), - ).toMatchSnapshot(); + expect(TYPED_HOST_EVENTS.map((event) => `${event}`).sort()).toEqual([ + 'AIHighlights', + 'AskSage', + 'AskSpotter', + 'CloseSpotterShareConversation', + 'CloseSpotterVizPanel', + 'DeleteLastPrompt', + 'EditLastPrompt', + 'ExitSpotterSharedConversation', + 'InitSpotterVizConversation', + 'Navigate', + 'OpenSpotterVizPanel', + 'PinSpotterConversation', + 'PreviewSpotterData', + 'ResetLiveboardPersonalisedView', + 'ResetSpotterConversation', + 'SelectPersonalisedView', + 'SetActiveTab', + 'SetPinboardHiddenTabs', + 'SetPinboardVisibleTabs', + 'SetPinboardVisibleVizs', + 'ShareSpotterConversation', + 'SpotterSearch', + 'SpotterVizSendUserMessage', + 'UnpinSpotterConversation', + 'UpdateCrossFilter', + 'UpdateParameters', + 'UpdatePersonalisedView', + 'UpdateRuntimeFilters', + 'addColumns', + 'answerChartSwitcher', + 'createMonitor', + 'downloadAsCSV', + 'downloadAsPdf', + 'downloadAsPng', + 'downloadAsXLSX', + 'edit', + 'editTSL', + 'embedDocument', + 'explore', + 'exportTSL', + 'manage-pipeline', + 'manageMonitor', + 'onDeleteAnswer', + 'openFilter', + 'openParameter', + 'present', + 'refreshLiveboardBrowserCache', + 'removeColumn', + 'resetSearch', + 'save', + 'schedule-list', + 'search', + 'sendTestScheduleEmail', + 'share', + 'showUnderlyingData', + 'spotIQAnalyze', + 'subscription', + 'sync-to-other-apps', + 'sync-to-sheets', + 'updateFilters', + 'updateTSL', + ]); }); test('UI passthrough wire values are stable (additive-only)', () => { - expect(Object.values(UIPassthroughEvent).sort()).toMatchSnapshot(); + expect(Object.values(UIPassthroughEvent).sort()).toEqual([ + 'addVizToPinboard', + 'drillDown', + 'getAnswerPageConfig', + 'getAnswerSession', + 'getAvailableUiPassthroughs', + 'getDiscoverabilityStatus', + 'getExportRequestForCurrentPinboard', + 'getFilters', + 'getGroups', + 'getIframeUrl', + 'getParameters', + 'getPinboardPageConfig', + 'getTML', + 'getTabs', + 'getUnsavedAnswerTML', + 'saveAnswer', + 'updateFilters', + ]); }); test('every typed host event is a real HostEvent member', () => { @@ -145,29 +226,6 @@ describe('event contracts (drift guardrails)', () => { ); }); - test('createHostEventEmitters exposes one emitter per HostEvent member', async () => { - const triggered: Array<{ type: HostEvent; data: any }> = []; - const fakeEmbed = { - trigger: (type: HostEvent, data?: any) => { - triggered.push({ type, data }); - return Promise.resolve({ ok: true }); - }, - }; - const emitters = createHostEventEmitters(fakeEmbed); - - expect(Object.keys(emitters).sort()).toEqual( - Object.keys(HostEvent).sort(), - ); - - const filters: RuntimeFilter[] = [ - { columnName: 'state', operator: 'EQ' as any, values: ['CA'] }, - ]; - await emitters.UpdateRuntimeFilters(filters); - expect(triggered).toEqual([ - { type: HostEvent.UpdateRuntimeFilters, data: filters }, - ]); - }); - test('embed event enum wire values referenced by typed payloads are stable', () => { expect( [ @@ -175,7 +233,11 @@ describe('event contracts (drift guardrails)', () => { EmbedEvent.EmbedListenerReady, EmbedEvent.CustomAction, ].map((e) => `${e}`).sort(), - ).toMatchSnapshot(); + ).toEqual([ + 'EmbedListenerReady', + 'authInit', + 'customAction', + ]); }); test('CustomAction resolves to its typed payload; answerService on the dedicated type', () => { diff --git a/src/contracts/host-event-emitters.ts b/src/contracts/host-event-emitters.ts deleted file mode 100644 index 81601ce15..000000000 --- a/src/contracts/host-event-emitters.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Copyright (c) 2026 - * - * Type-derived host event emitter helpers. - * - * Instead of hand-writing one helper per event (which would drift from the - * contracts), the emitter surface is DERIVED from the {@link HostEvent} enum - * and the contract maps at the type level, and implemented generically at - * runtime. Adding an event to the enum/contracts automatically adds a fully - * typed emitter — there is no per-event code to keep in sync. - * @module contracts - * @example - * ```js - * import { createHostEventEmitters } from '@thoughtspot/visual-embed-sdk/contracts'; - * - * const emit = createHostEventEmitters(liveboardEmbed); - * await emit.UpdateRuntimeFilters([{ columnName: 'state', operator: 'EQ', values: ['CA'] }]); - * await emit.Pin({ vizId: '123', newVizName: 'My viz' }); - * ``` - */ -import { ContextType, HostEvent } from '../types'; -import type { HostEventRequest, HostEventResponse } from './host-event-contracts'; - -/** - * Minimal surface of an embed instance needed to emit host events. - */ -export interface HostEventTrigger { - trigger( - messageType: HostEvent, - data?: any, - context?: ContextType, - ): Promise; -} - -/** - * One emitter method per {@link HostEvent} member, request/response typed - * from the event contracts. - */ -export type HostEventEmitters = { - [MemberK in keyof typeof HostEvent]: ( - data?: HostEventRequest<(typeof HostEvent)[MemberK]>, - context?: ContextType, - ) => Promise>; -}; - -/** - * Creates typed emitter helpers bound to an embed instance. - * @param embed Any embed instance exposing `trigger()`. - */ -export const createHostEventEmitters = ( - embed: HostEventTrigger, -): HostEventEmitters => { - const emitters = {} as Record Promise>; - (Object.keys(HostEvent) as Array).forEach((memberName) => { - emitters[memberName] = (data?: any, context?: ContextType) => - embed.trigger(HostEvent[memberName], data, context); - }); - return emitters as HostEventEmitters; -}; diff --git a/src/contracts/index.ts b/src/contracts/index.ts index aa8825391..0277923da 100644 --- a/src/contracts/index.ts +++ b/src/contracts/index.ts @@ -33,7 +33,6 @@ export { export * from './host-event-contracts'; export * from './embed-event-payloads'; -export * from './host-event-emitters'; // UI passthrough contracts remain in their historical home; re-exported so // the contracts subpath is self-sufficient for host-side consumers.